로케이터
로케이터 (Locators)
Playwright에서 화면의 요소를 찾는 방법은 여러 가지가 있어요. 로케이터는 Playwright의 자동 대기와 재시도 능력의 핵심인데, 간단히 말해 페이지에서 요소를 언제든 찾아내는 방법을 나타내요. 어떤 로케이터가 있는지, 어떻게 조합하고 필터링하는지를 하나씩 익혀 보면, 더 안정적인 테스트를 짜는 데 큰 도움이 될 거예요.
본문
빠른 가이드
추천하는 내장 로케이터들을 먼저 살펴볼게요.
method: Page.getByRole— 명시적·암묵적 접근성 속성으로 찾기method: Page.getByText— 텍스트 내용으로 찾기method: Page.getByLabel— 연관된 라벨 텍스트로 폼 컨트롤 찾기method: Page.getByPlaceholder— 플레이스홀더로 input 찾기method: Page.getByAltText— 텍스트 대체 속성으로 요소(보통 이미지) 찾기method: Page.getByTitle— title 속성으로 찾기method: Page.getByTestId—data-testid속성으로 찾기 (다른 속성으로도 설정 가능)
await page.getByLabel('User Name').fill('John');
await page.getByLabel('Password').fill('secret-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Welcome, John!')).toBeVisible();
요소 찾기
안정적인 테스트를 위해선 사용자에게 보이는 속성과 명시적인 계약(contract)을 우선하는 로케이터를 쓰는 걸 권장해요. 예를 들어 다음 DOM 구조에서,
<button>Sign in</button>
role이 button이면서 이름이 "Sign in"인 요소를 이렇게 찾아요.
await page.getByRole('button', { name: 'Sign in' }).click();
로케이터가 액션에 사용될 때마다 페이지에서 최신 DOM 요소를 다시 찾아요. 아래 코드에서 DOM 요소는 액션마다 두 번씩 새로 위치하게 돼요. 즉 그 사이에 리렌더링으로 DOM이 바뀌었다면, 로케이터에 해당하는 새 요소가 사용돼요.
const locator = page.getByRole('button', { name: 'Sign in' });
await locator.hover();
await locator.click();
로케이터를 만드는 메서드(getByLabel 등)는 [Locator]와 [FrameLocator] 클래스에도 있어서 체이닝해서 점차 좁힐 수 있어요.
const locator = page
.frameLocator('#my-frame')
.getByRole('button', { name: 'Sign in' });
await locator.click();
role로 찾기
[method: Page.getByRole]은 사용자와 보조 기술이 페이지를 어떻게 인식하는지를 반영해요 — 어떤 요소가 버튼인지, 체크박스인지 같은 것들이죠. role로 찾을 때는 접근 가능한 이름(accessible name)도 함께 넘겨 정확한 요소를 짚는 게 보통 좋아요. 다음 DOM 구조를 볼게요.
<h3>Sign up</h3>
<label>
<input type="checkbox" /> Subscribe
</label>
<br/>
<button>Submit</button>
각 요소를 암묵 역할로 찾을 수 있어요.
await expect(page.getByRole('heading', { name: 'Sign up' })).toBeVisible();
await page.getByRole('checkbox', { name: 'Subscribe' }).check();
await page.getByRole('button', { name: /submit/i }).click();
role 로케이터는 버튼·체크박스·헤딩·링크·리스트·테이블 등을 포함하고, W3C의 ARIA role·ARIA attributes·accessible name 명세를 따르며, <button>처럼 암묵적으로 정의된 role도 인식해요. 다만 role 로케이터가 접근성 감사·적합성 테스트를 대체하지는 않는다는 점, ARIA 지침에 대한 조기 피드백만 준다는 점을 기억하세요.
label로 찾기
대부분의 폼 컨트롤에는 전용 라벨이 있어요. [method: Page.getByLabel]로 컨트롤을 찾아 상호작용할 수 있어요.
<label>Password <input type="password" /></label>
await page.getByLabel('Password').fill('secret');
폼 필드를 찾을 때 이 로케이터를 쓰면 됩니다.
placeholder로 찾기
input에 placeholder 속성이 있다면 [method: Page.getByPlaceholder]로 찾을 수 있어요.
<input type="email" placeholder="[email protected]" />
await page
.getByPlaceholder('[email protected]')
.fill('[email protected]');
라벨은 없지만 placeholder는 있는 폼 요소를 찾을 때 유용해요.
text로 찾기
[method: Page.getByText]로 요소가 담은 텍스트를 기준으로 찾아요. 부분 문자열, 완전 일치, 정규식을 모두 지원해요.
<span>Welcome, John</span>
await expect(page.getByText('Welcome, John')).toBeVisible();
완전 일치로 설정하려면 exact: true를, 정규식으로는 이렇게 써요.
await expect(page.getByText('Welcome, John', { exact: true })).toBeVisible();
await expect(page.getByText(/welcome, [A-Za-z]+$/i)).toBeVisible();
텍스트 매칭은 항상 공백을 정규화해요(여러 칸은 하나로, 줄바꿈은 공백으로, 앞뒤 공백 무시). text 로케이터는 div, span, p 같은 비상호작용 요소에, 상호작용 요소(button, a, input)에는 role 로케이터를 권장해요.
alt text로 찾기
이미지는 alt 속성으로 설명을 담아야 해요. [method: Page.getByAltText]로 대체 텍스트를 기준으로 이미지를 찾을 수 있어요.
<img alt="playwright logo" src="/img/playwright-logo.svg" width="100" />
await page.getByAltText('playwright logo').click();
title로 찾기
title 속성이 있는 요소는 [method: Page.getByTitle]로 찾아요.
<span title='Issues count'>25 issues</span>
await expect(page.getByTitle('Issues count')).toHaveText('25 issues');
test id로 찾기
test id로 테스트하는 건 가장 탄력적인 방법이에요. 텍스트나 role이 바뀌어도 테스트는 통과하니까요. QA와 개발자는 명시적인 test id를 정의하고 [method: Page.getByTestId]로 조회해요. 다만 test id는 사용자에게 보이지 않는 방식이라는 단점이 있어요. role이나 text 값이 중요하다면 사용자 지향 로케이터를 고려하세요.
<button data-testid="directions">Itinéraire</button>
await page.getByTestId('directions').click();
기본은 data-testid 속성을 쓰지만, 테스트 설정이나 [method: Selectors.setTestIdAttribute]로 바꿀 수 있어요.
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
testIdAttribute: 'data-pw'
}
});
이제 HTML에서 data-pw를 test id로 쓸 수 있어요.
CSS나 XPath로 찾기
꼭 필요하다면 [method: Page.locator]로 CSS·XPath 로케이터를 만들 수 있어요. css=나 xpath= 접두사를 생략하면 자동으로 감지해요.
await page.locator('css=button').click();
await page.locator('xpath=//button').click();
await page.locator('button').click();
await page.locator('//button').click();
XPath와 CSS 셀렉터는 DOM 구조·구현에 묶이다 보니 구조가 바뀌면 깨지기 쉬워요. 아래처럼 긴 체인은 나쁜 관행의 대표적인 예예요.
await page.locator(
'#tsf > div:nth-child(2) > div.A8SBwf > div.RNNXgb > div > div.a4bIc > input'
).click();
DOM은 자주 바뀌므로 CSS·XPath보다는 사용자의 인식에 가까운 로케이터나 test id를 쓰는 걸 권장해요.
Shadow DOM 안에서 찾기
모든 Playwright 로케이터는 기본적으로 Shadow DOM 안의 요소에서도 동작해요. 단 두 가지 예외가 있어요.
- XPath로 찾는 것은 shadow root를 뚫지 못해요.
- 닫힌 모드(closed-mode) shadow root는 지원하지 않아요.
다음처럼 커스텀 웹 컴포넌트가 있다면, shadow root가 없는 것처럼 동일하게 찾을 수 있어요.
<x-details role=button aria-expanded=true aria-controls=inner-details>
<div>Title</div>
#shadow-root
<div id=inner-details>Details</div>
</x-details>
<div>Details</div>를 클릭하려면,
await page.getByText('Details').click();
<x-details>를 클릭하려면 hasText 옵션으로,
await page.locator('x-details', { hasText: 'Details' }).click();
<x-details> 안에 "Details" 텍스트가 있는지 확인하려면,
await expect(page.locator('x-details')).toContainText('Details');
로케이터 필터링
두 번째 상품 카드의 구매 버튼을 클릭하고 싶다고 해 볼게요. 필터로 올바른 로케이터를 좁힐 수 있어요.
<ul>
<li>
<h3>Product 1</h3>
<button>Add to cart</button>
</li>
<li>
<h3>Product 2</h3>
<button>Add to cart</button>
</li>
</ul>
text로 필터링
[method: Locator.filter]로 텍스트를 기준으로 필터링해요. 요소 내부(후손 포함)에서 문자열을 대소문자 구분 없이 찾고, 정규식도 넘길 수 있어요.
await page
.getByRole('listitem')
.filter({ hasText: 'Product 2' })
.getByRole('button', { name: 'Add to cart' })
.click();
특정 텍스트가 없는 것으로 필터링
// 5 in-stock items
await expect(page.getByRole('listitem').filter({ hasNotText: 'Out of stock' })).toHaveCount(5);
자식·후손으로 필터링
특정 후손이 있거나 없는 요소만 선택하는 옵션도 있어요. 다른 로케이터(role, test id, text 등)로 필터할 수 있어요.
await page
.getByRole('listitem')
.filter({ has: page.getByRole('heading', { name: 'Product 2' }) })
.getByRole('button', { name: 'Add to cart' })
.click();
상품 카드가 하나뿐인지도 어서션으로 확인할 수 있어요.
await expect(page
.getByRole('listitem')
.filter({ has: page.getByRole('heading', { name: 'Product 2' }) }))
.toHaveCount(1);
핵심 주의: 필터 로케이터는 원래 로케이터에 상대적이어야 해요. 원래 로케이터가 매칭한 지점부터 시작해서 쿼리되며 문서 루트에서 시작하지 않아요. 아래는 <ul>에서 시작해 원래 로케이터(<li>) 바깥이라 동작하지 않아요.
// ✖ WRONG
await expect(page
.getByRole('listitem')
.filter({ has: page.getByRole('list').getByText('Product 2') }))
.toHaveCount(1);
자식·후손이 없는 것으로 필터링
await expect(page
.getByRole('listitem')
.filter({ hasNot: page.getByText('Product 2') }))
.toHaveCount(1);
여기서 내부 로케이터도 바깥 로케이터에서부터 매칭을 시작해요(문서 루트 아님).
로케이터 연산자
로케이터 안에서 매칭
로케이터를 만드는 메서드를 체이닝해 페이지의 특정 부분으로 검색을 좁힐 수 있어요. 먼저 listitem role로 product 로케이터를 만들고 text로 필터한 뒤 재사용하는 예시예요.
const product = page.getByRole('listitem').filter({ hasText: 'Product 2' });
await product.getByRole('button', { name: 'Add to cart' }).click();
await expect(product).toHaveCount(1);
두 로케이터를 이어 붙일 수도 있어요. 특정 다이얼로그 안의 "Save" 버튼을 찾을 때처럼요.
const saveButton = page.getByRole('button', { name: 'Save' });
// ...
const dialog = page.getByTestId('settings-dialog');
await dialog.locator(saveButton).click();
두 로케이터를 동시에 매칭
[method: Locator.and]는 기존 로케이터에 추가 로케이터를 매칭해 좁혀요. role과 title을 동시에 매칭할 수 있어요.
const button = page.getByRole('button').and(page.getByTitle('Subscribe'));
대안 중 하나 매칭
둘 이상의 요소 중 어떤 게 뜰지 모를 때는 [method: Locator.or]로 둘 중 하나(또는 둘 다)에 매칭하는 로케이터를 만들 수 있어요. "New email" 버튼을 클릭하고 싶은데 가끔 보안 설정 다이얼로그가 대신 뜨는 시나리오를 볼게요.
const newEmail = page.getByRole('button', { name: 'New' });
const dialog = page.getByText('Confirm security settings');
await expect(newEmail.or(dialog).first()).toBeVisible();
if (await dialog.isVisible())
await page.getByRole('button', { name: 'Dismiss' }).click();
await newEmail.click();
"New email" 버튼과 다이얼로그가 동시에 화면에 뜨면 or 로케이터는 둘 다 매칭해 "strict mode violation" 오류가 날 수 있어요. 그럴 땐 [method: Locator.first]로 하나만 매칭하면 돼요.
보이는 요소만 매칭
<button style='display: none'>Invisible</button>
<button>Visible</button>
두 버튼을 모두 찾아 strictness 위반 오류가 날 수 있어요.
await page.locator('button').click();
보이는 두 번째 버튼만 찾으려면 visible()을 써요.
await page.locator('button').visible().click();
목록 (Lists)
목록에서 항목을 세거나 텍스트를 확인하거나 특정 항목을 고르는 방법을 볼게요.
<ul>
<li>apple</li>
<li>banana</li>
<li>orange</li>
</ul>
목록에 항목이 3개인지 확인하려면 count 어서션을 쓰고,
await expect(page.getByRole('listitem')).toHaveCount(3);
목록의 모든 텍스트를 확인하려면,
await expect(page
.getByRole('listitem'))
.toHaveText(['apple', 'banana', 'orange']);
특정 항목은 text로, filter로, test id로 고를 수 있어요.
await page.getByText('orange').click();
await page
.getByRole('listitem')
.filter({ hasText: 'orange' })
.click();
await page.getByTestId('orange').click();
동일한 요소들의 목록에서 순서로 구분해야만 한다면 first, last, nth를 쓸 수 있어요.
const banana = await page.getByRole('listitem').nth(1);
다만 이 방식은 신중하게 써야 해요. 페이지가 바뀌면 로케이터가 예상과 완전히 다른 요소를 가리킬 수 있으니까요. 대신 strictness 기준을 통과하는 고유한 로케이터를 만들도록 노력하세요.
필터 체이닝
비슷한 요소들이 여럿일 때 [method: Locator.filter]를 여러 번 체이닝해 좁힐 수 있어요. "Mary"와 "Say goodbye"가 들어 있는 행의 스크린샷을 찍으려면,
const rowLocator = page.getByRole('listitem');
await rowLocator
.filter({ hasText: 'Mary' })
.filter({ has: page.getByRole('button', { name: 'Say goodbye' }) })
.screenshot({ path: 'screenshot.png' });
각 요소에 무언가 하기
for (const row of await page.getByRole('listitem').all())
console.log(await row.textContent());
일반 for 루프로도 가능해요.
const rows = page.getByRole('listitem');
const count = await rows.count();
for (let i = 0; i < count; ++i)
console.log(await rows.nth(i).textContent());
[method: Locator.evaluateAll] 안의 코드는 페이지 안에서 실행되므로 DOM API를 호출할 수 있어요.
const rows = page.getByRole('listitem');
const texts = await rows.evaluateAll(
list => list.map(element => element.textContent));
Strictness (엄격성)
로케이터는 엄격해요. 어떤 DOM 요소를 목표하는 모든 연산은 매칭되는 요소가 둘 이상이면 예외를 던져요. 예를 들어 DOM에 버튼이 여러 개면 다음 호출은 실패해요.
await page.getByRole('button').click();
반면 Playwright는 다중 요소 연산을 이해하므로, 로케이터가 여러 요소로 풀릴 때 다음 호출은 잘 동작해요.
await page.getByRole('button').count();
여러 요소가 매칭될 때 어떤 요소를 쓸지 first, last, nth로 명시해 strictness 검사를 벗어날 수도 있어요. 다만 이들은 권장하지 않아요 — 페이지가 바뀌면 의도하지 않은 요소를 클릭할 수 있으니까요. 대신 위의 모범 사례대로 고유하게 식별하는 로케이터를 만드세요.