Playwright 의 권장 locator 우선순위가 Testing Library 와 정확히 거울처럼 — 같은 이유로. 진짜 사용자가 접근성 메타데이터 통해 element 찾고; 같은 거 하는 테스트가 사용자 비가시 UI refactor 에서 살아남아.
page.getByRole(role, { name }) — 골드 스탠다드.
page.getByLabel(text) — 라벨 있는 폼 필드용.
page.getByPlaceholder(text) — 라벨 없을 때.
page.getByText(text) — non-interactive 컨텐츠용.
page.getByAltText(text) — 이미지용.
page.getByTitle(text) — title 속성 가진 element 용.
page.getByTestId(id) — escape hatch.
이 티어 아래에 page.locator('css selector') 와 page.locator('xpath=...') — 둘 다 통하고, 둘 다 fallback. 권장: role + name + filter 조합이 실패할 때까지 CSS 에 손 뻗지 마.
Locator 객체 vs 즉각 element
Playwright 의 getByRole 가 Locator 반환 — element 자체 아냐. Locator 는 lazy 참조: 쓸 때 (.click(), .fill(), 또는 expect 와) 까지 resolve 안 해. 매 resolution 이 DOM 다시 쿼리, 그래서 locator 가 auto-wait 와 retry 와 잘 어울려.
결과: locator 를 변수에 저장하는 게 싸고 안전. const submit = page.getByRole('button', { name: 'Submit' }) 가 테스트에서 여러 번 참조 가능 — 매 await submit.click() 이나 await expect(submit).toBeEnabled() 가 fresh 재쿼리.
체이닝과 필터링
Locator 체인. page.getByRole('list').getByRole('listitem').first() 는 "리스트 찾고, 그 안의 아이템들 찾고, 첫 번째." 이게 brittle CSS path 안 쓰고 복잡한 DOM 구조로 좁히는 모델.
Role 기반 쿼리가 매칭 안 되면 컴포넌트가 접근성 메타데이터 빠진 거. RTL 과 같은 진단: CSS escape hatch 손 뻗는 대신 컴포넌트 고쳐 (라벨 추가, 적절한 element 사용). Playwright 에선 bar 가 더 높아 — 진짜 브라우저에서 테스트 중이니까 접근성 트리가 assistive tech 가 보는 거 그 자체야.
CSS / XPath 가 진짜 맞는 답일 때
어떤 element 는 진짜로 접근 가능 이름 없음: aria-label 없이 스타일된 아이콘 전용 버튼, 커스텀 SVG 차트, 통제 못 하는 서드파티 위젯. 그 경우 page.locator('css selector') 가 정직 — 근데 고려해: 컴포넌트를 대신 고칠 수 있나? 대부분 아이콘 버튼은 aria-label 있어야 하고; 대부분 차트는 데이터 묘사하는 aria-label 있어야 하고; 대부분 서드파티 위젯은 안정적 test id 가지거나 하나에 wrap 가능.
Code
Role 기반 locator — default 접근·typescript
// e2e/login.spec.ts — role-based locators throughout
import { test, expect } from '@playwright/test';
test('sign-in flow uses role-based locators', async ({ page }) => {
await page.goto('/');
// Role-based: same locator API surface as RTL
await page.getByRole('link', { name: 'Sign in' }).click();
// Form fields by label
await page.getByLabel('Email').fill('pippa@example.com');
await page.getByLabel('Password').fill('s3cret');
// Button by role + name
await page.getByRole('button', { name: 'Submit' }).click();
// Heading by role + level + name — three filters on one query
await expect(
page.getByRole('heading', { name: /welcome back, pippa/i, level: 1 })
).toBeVisible();
});
체이닝 + 필터 — scope 된 locator 가 CSS path 대체·typescript
// Chaining + filtering — narrowing without CSS
import { test, expect } from '@playwright/test';
test('cart shows the right item', async ({ page }) => {
await page.goto('/cart');
// Find the cart list, then the listitem containing 'Coffee Beans'
const item = page
.getByRole('list', { name: /cart items/i })
.getByRole('listitem')
.filter({ hasText: 'Coffee Beans' });
await expect(item).toBeVisible();
// Inside that item, find the quantity input by label and the price by role
await expect(item.getByLabel('Quantity')).toHaveValue('2');
await expect(item.getByRole('text', { name: /\$24.00/ })).toBeVisible();
});
CSS / test-id escape — role 가용 안 할 때·typescript
// When you genuinely need CSS — own the escape
import { test, expect } from '@playwright/test';
test('chart container is rendered', async ({ page }) => {
await page.goto('/dashboard');
// Anti-pattern (brittle):
// page.locator('div.chart-wrapper.js-revenue-chart')
// Honest escape — the SVG has no accessible name we control
await expect(
page.locator('[data-testid="revenue-chart"]')
).toBeVisible();
// Even better — add an aria-label to the chart and use getByRole:
// <svg role="img" aria-label="Revenue chart"> → page.getByRole('img', { name: /revenue chart/i })
});
앱에서 아이템 리스트 있는 페이지 골라 (검색 결과 페이지, 대시보드, 카트). Playwright 테스트 짜: (1) 페이지로 navigate, (2) getByRole 로 리스트 찾기, (3) items = listLocator.getByRole('listitem') 의 expect(items).toHaveCount(N) 으로 N 아이템 있는지 단언, (4) 첫 아이템 클릭하고 detail 페이지로 navigation 단언. page.locator('css') 전적으로 피해.
Hint
리스트의 어떤 부분에 role 찾을 수 없으면 마크업이 non-semantic 일 가능성. <div> 대신 진짜 <ul>, 리스트에 aria-label 추가, 그럼 마크업과 함께 테스트가 깨끗해져.
Progress
Progress is local-only — sign in to sync across devices.