← 블로그 목록

Locator 심화 — 커스텀 UI에서 Playwright가 헤매는 이유

2026-08-19
PlaywrightLocatorAnt Design자동화

Playwright 공식 문서는 getByRole()부터 쓰라고 한다. 맞는 말인데, Ant Design을 쓰는 UI에서 이걸 맹목적으로 따르다 보면 꽤 자주 막힌다.

내가 처음으로 "왜 안 되지?"를 반복했던 상황들과, 결국 어떻게 해결했는지를 정리한다.

기본 순서는 지킨다

셀렉터 선택 순서는 이게 맞다.

getByRole()  →  getByText()  →  locator()

getByRole이 가장 유지보수하기 좋고, locator('.class')는 마크업이 바뀌면 바로 깨진다. 다만 커스텀 컴포넌트에서는 getByRole이 통하지 않는 케이스가 생각보다 많다.

// getByRole이 되는 것들
await page.getByRole('button', { name: '검색', exact: true }).click();
await page.getByRole('textbox', { name: '정책 이름' }).fill('값');
await page.getByRole('checkbox', { name: '사용' }).check();

// 안 되면 getByText
await page.getByText('상세', { exact: true }).click();

// 그래도 안 되면 locator
await page.locator('tbody tr').filter({ hasText: '검색어' }).first().click();

Ant Design 테이블에서 getByRole('cell')이 안 되는 이유

// ❌ 이게 안 된다
await expect(page.getByRole('cell', { name: '정책 이름' })).toBeVisible();

Ant Design의 가상 스크롤 테이블은 <td role="cell">을 쓰지 않는다. Playwright가 찾으려는 role이 DOM에 없으니 당연히 실패한다.

// ✅ 헤더는 이렇게
await expect(page.getByText('정책 이름', { exact: true }).first()).toBeVisible();

// ✅ 특정 행 찾기
const targetRow = page.locator('tbody tr').filter({ hasText: '조회 정책 1' });
await expect(targetRow).toBeVisible();

// ✅ 테이블 로딩 확인
const firstRow = page.locator('tbody tr').first();
await expect(firstRow).toBeAttached({ timeout: 5000 });

데이터가 없는 경우도 처리해야 할 때가 있다.

const noData = page.getByText('데이터가 없습니다');
const isEmpty = await noData.isVisible({ timeout: 1000 }).catch(() => false);
if (isEmpty) {
  // 빈 상태 처리
}

사이드바와 테이블에 같은 텍스트가 있을 때

필터를 선택하면 사이드바에 "test_agent_03"이 뜨고, 동시에 테이블 본문에도 "test_agent_03"이 나온다. .first()를 무조건 붙이면 어느 쪽이 잡힐지 모른다.

// ❌ 사이드바가 잡힐 수 있음
await page.getByText('test_agent_03').first().click();

드롭다운 필터 값은 항목 수가 함께 붙는다. test_agent_03 (17건) 이런 식으로. 이 패턴을 쓰면 구분된다.

// ✅ "(N건)" 패턴으로 드롭다운만 선택
await page.getByText(/test_agent_03.*건/).first().click();

// ✅ 영역 한정
await page.locator('main').getByText('test_agent_03').first().click();
await page.locator('[role="tabpanel"]').getByText('설정값').first().click();

fill()이 안 되는 드롭다운

상세 검색에서 처음에 이렇게 했다.

// ❌ 쿼리 파싱이 안 됨
await page.getByRole('textbox', { name: '검색어 입력' }).fill('test_agent_03');
await page.keyboard.press('Enter');

자동완성 드롭다운은 직접 입력이 아니라 목록에서 선택하는 방식이다. fill()로 값을 넣어봤자 내부 쿼리 파싱이 안 된다.

// ✅ 클릭 → 드롭다운에서 선택
await page.getByText('상세', { exact: true }).click();

const searchInput = page.getByRole('textbox', { name: '검색어를 입력하거나 선택하십시오' });
await searchInput.click();
await page.waitForTimeout(500);

await page.getByText('사용자 아이디', { exact: true }).first().click();
await page.waitForTimeout(500);

await page.getByText(':', { exact: true }).first().click();
await page.waitForTimeout(500);

await page.getByText(/test_agent_03.*건/).first().click();
await page.waitForTimeout(500);

await page.keyboard.press('Escape');
await page.getByRole('button', { name: '검색', exact: true }).click();
await page.waitForLoadState('networkidle');

단계마다 waitForTimeout(500)이 있는 건 드롭다운 애니메이션 때문이다. 너무 빨리 다음 동작을 하면 클릭이 무시된다.

히든 라디오 버튼

라디오 버튼이 CSS로 숨겨진 경우 getByRole('radio')는 클릭 대상을 찾지 못한다.

// ❌
await page.getByRole('radio', { name: '상세' }).click();

// ✅ 라벨 텍스트 클릭
await page.getByText('상세', { exact: true }).click();

exact: true는 "상세 검색" 같은 더 긴 텍스트와 구분하기 위해 필요하다.

커스텀 드롭다운

일반 <select>가 아닌 커스텀 드롭다운은 selectOption()이 먹히지 않는다.

// ❌
await page.getByLabel('공격 유형').selectOption('악성코드');

// ✅
await page.getByRole('combobox', { name: '공격 유형' }).click();
await page.waitForTimeout(300);
await page.getByTitle('악성코드').click();

드롭다운 옵션에 title 속성이 설정된 경우가 많아서 getByTitle이 잘 맞는다. 없으면 getByText로 대체.

결과 검증은 테이블 본문에서

필터 결과를 검증할 때도 함정이 있다. 사이드바에도 같은 값이 표시되니까.

// ❌ 사이드바일 수 있음
await expect(page.getByText('test_agent_03').first()).toBeVisible();

// ✅ 테이블 행에서 확인
const row = page.locator('tbody tr').filter({ hasText: 'test_agent_03' });
await expect(row.first()).toBeVisible({ timeout: 5000 });
상황 잘못된 접근 올바른 접근
Ant Design 테이블 헤더 getByRole('cell') getByText('컬럼명').first()
동일 텍스트 중복 .first() 무조건 .*건 패턴 또는 영역 한정
자동완성 드롭다운 fill() 클릭 → 드롭다운에서 선택
히든 라디오 버튼 getByRole('radio') getByText('라벨', {exact:true})
커스텀 드롭다운 selectOption() click()getByTitle()

다음 편에서는 작성 단계 이전으로 돌아간다. TestLink TC를 받아서 스크립트로 옮길 때 첫 실행에서 성공하는 방법.