C.W.K.
Stream
Lesson 01 of 04 · published

Role 기반 Selector — getByRole 와 친구들

~17 min · playwright-locators, role, selectors

Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"RTL 과 같은 접근성 우선 철학, 진짜 브라우저에 적용."

같은 우선순위 리스트 (다른 API surface)

Playwright 의 권장 locator 우선순위가 Testing Library 와 정확히 거울처럼 — 같은 이유로. 진짜 사용자가 접근성 메타데이터 통해 element 찾고; 같은 거 하는 테스트가 사용자 비가시 UI refactor 에서 살아남아.

  1. page.getByRole(role, { name }) — 골드 스탠다드.
  2. page.getByLabel(text) — 라벨 있는 폼 필드용.
  3. page.getByPlaceholder(text) — 라벨 없을 때.
  4. page.getByText(text) — non-interactive 컨텐츠용.
  5. page.getByAltText(text) — 이미지용.
  6. page.getByTitle(text) — title 속성 가진 element 용.
  7. page.getByTestId(id) — escape hatch.

이 티어 아래에 page.locator('css selector')page.locator('xpath=...') — 둘 다 통하고, 둘 다 fallback. 권장: role + name + filter 조합이 실패할 때까지 CSS 에 손 뻗지 마.

Locator 객체 vs 즉각 element

Playwright 의 getByRoleLocator 반환 — 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 구조로 좁히는 모델.

유용한 필터 메서드:

  • .first(), .last(), .nth(n) — 위치 기반 좁히기.
  • .filter({ hasText }), .filter({ has: locator }) — 컨텐츠 기반.
  • .and(otherLocator), .or(otherLocator) — 집합 연산.
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 })
});
95% 시간 동안 쓸 role 어휘·typescript
// Common role + name patterns — memorize the dozen, ignore the rest
await page.getByRole('button',   { name: 'Sign in' }).click();
await page.getByRole('link',     { name: 'Pricing' }).click();
await page.getByRole('heading',  { name: /dashboard/i }).isVisible();
await page.getByRole('textbox',  { name: 'Email' }).fill('a@b.io');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('radio',    { name: 'Standard plan' }).check();
await page.getByRole('combobox', { name: 'Country' }).selectOption('KR');
await page.getByRole('tab',      { name: 'Billing' }).click();
await page.getByRole('menuitem', { name: 'Sign out' }).click();
await page.getByRole('dialog',   { name: 'Confirm deletion' }).waitFor();
await page.getByRole('alert')
          .filter({ hasText: /required/i })
          .first()
          .waitFor();

External links

Exercise

앱에서 아이템 리스트 있는 페이지 골라 (검색 결과 페이지, 대시보드, 카트). 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.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.