학습 / 치트 시트
Playwright 치트 시트
매일 쓰는 Playwright Test API를 한 장에 모았습니다. 로케이터는 Playwright가 권장하는 순서로 정리했습니다. 역할(role), 레이블, 텍스트를 테스트 ID보다 먼저 쓰고, CSS나 XPath는 마지막 수단으로 씁니다. 여기 나오는 어설션은 모두 웹 퍼스트(web-first) 방식이라 통과하거나 타임아웃이 날 때까지 자동으로 재시도하므로 수동 대기가 필요 없습니다.
Playwright Test 1.5x, TypeScript 기준으로 검증
실행과 디버그
| 작업 | 코드 |
|---|---|
| 새 프로젝트 생성 | npm init playwright@latest |
| 모든 테스트 실행 | npx playwright test |
| 특정 파일의 특정 줄만 | npx playwright test tests/login.spec.ts:12 |
| 제목 또는 태그로 실행 | npx playwright test --grep @smoke |
| 브라우저 프로젝트 하나만 | npx playwright test --project=chromium |
| 브라우저 화면 보기 | npx playwright test --headed |
| 인스펙터로 단계별 실행 | npx playwright test --debug |
| UI 모드 (감시, 타임 트래블) | npx playwright test --ui |
| 직전 실행의 실패만 재실행 | npx playwright test --last-failed |
| 플래키 테스트 찾기 | npx playwright test my.spec.ts --repeat-each=20 |
| 여러 CI 머신에 샤딩 | npx playwright test --shard=1/4 |
| HTML 리포트 열기 | npx playwright show-report |
| 트레이스 열기 | npx playwright show-trace trace.zip |
| 스크립트 녹화 | npx playwright codegen https://example.com |
로케이터 (권장 순서대로)
| 작업 | 코드 |
|---|---|
| 역할과 접근 가능한 이름으로 | page.getByRole('button', { name: 'Sign in' }) |
| 레이블로 폼 필드 찾기 | page.getByLabel('Email') |
| placeholder로 | page.getByPlaceholder('Search') |
| 텍스트로 (정확히 일치) | page.getByText('Welcome back', { exact: true }) |
| 테스트 ID로 (data-testid) | page.getByTestId('checkout-submit') |
| CSS | page.locator('#orders tbody tr') |
| XPath | page.locator('xpath=//table[@id="orders"]//tr') |
| 텍스트로 필터링 | page.getByRole('row').filter({ hasText: 'Mei Lin' }) |
| 자식 요소로 필터링 | page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'Buy' }) }) |
| 찾은 요소 안에서 체이닝 | row.getByRole('button', { name: 'Cancel' }) |
| 여러 개 중 하나 선택 | .first() .last() .nth(2) |
| iframe 내부 | page.frameLocator('#payment').getByLabel('Card number') |
로케이터는 지연 평가(lazy)됩니다. 액션이나 어설션을 실행하기 전까지는 요소를 찾지 않으므로 한 번 정의해 두고 재사용하세요.
액션
| 작업 | 코드 |
|---|---|
| 페이지 이동 (baseURL 기준 상대 경로) | await page.goto('/login') |
| 클릭 | await locator.click() |
| 입력 필드 값 교체 | await locator.fill('mei@example.com') |
| 한 글자씩 입력 | await locator.pressSequentially('abc', { delay: 50 }) |
| 키 누르기 | await locator.press('Enter') |
| 체크박스 또는 라디오 버튼 | await locator.check() / await locator.uncheck() |
| 옵션 선택 | await page.getByLabel('Country').selectOption('UK') |
| 파일 업로드 | await locator.setInputFiles('fixtures/invoice.pdf') |
| 마우스 오버 | await locator.hover() |
| 드래그 앤 드롭 | await source.dragTo(target) |
| 다음 대화상자 수락 | page.once('dialog', (d) => d.accept()) |
웹 퍼스트 어설션
| 작업 | 코드 |
|---|---|
| 표시됨 / 숨겨짐 | await expect(locator).toBeVisible() / .toBeHidden() |
| 텍스트 정확히 일치 | await expect(locator).toHaveText('Order placed') |
| 텍스트 포함 | await expect(locator).toContainText('placed') |
| 입력값 | await expect(input).toHaveValue('42') |
| 일치하는 요소 개수 | await expect(page.getByRole('row')).toHaveCount(5) |
| 활성 / 비활성 / 체크됨 | toBeEnabled() toBeDisabled() toBeChecked() |
| 속성 | await expect(menu).toHaveAttribute('aria-expanded', 'true') |
| URL과 페이지 제목 | await expect(page).toHaveURL(/dashboard/) / .toHaveTitle('Orders') |
| 부정 | await expect(spinner).not.toBeVisible() |
| 실패해도 계속 진행 | await expect.soft(total).toHaveText('£40.00') |
| 임의의 비동기 검사 재시도 | await expect.poll(async () => (await api.get(url)).status()).toBe(200) |
| 시각적 비교 | await expect(page).toHaveScreenshot('home.png') |
네트워크와 API
| 작업 | 코드 |
|---|---|
| 응답 모킹 | await page.route('**/api/orders', (r) => r.fulfill({ json: [] })) |
| 요청 실패시키기 | await page.route('**/api/orders', (r) => r.abort()) |
| 실제 응답 일부 변경 | await page.route('**/api/me', async (r) => { const res = await r.fetch(); const json = await res.json(); await r.fulfill({ response: res, json: { ...json, plan: 'pro' } }); }) |
| 응답 기다리기 | const res = page.waitForResponse('**/api/orders');
await page.getByRole('button', { name: 'Save' }).click();
expect((await res).ok()).toBeTruthy(); |
| API 테스트 | test('list users', async ({ request }) => {
const res = await request.get('/api/users');
await expect(res).toBeOK();
expect(await res.json()).toHaveLength(3);
}); |
구조, 픽스처, 훅
| 작업 | 코드 |
|---|---|
| 테스트 그룹화 | test.describe('checkout', () => { /* tests */ }) |
| 각 테스트 전에 실행 | test.beforeEach(async ({ page }) => { await page.goto('/'); }) |
| 테스트에 태그 지정 | test('pays by card', { tag: '@smoke' }, async ({ page }) => { /* ... */ }) |
| 조건부 건너뛰기 | test.skip(browserName === 'webkit', 'Not supported on WebKit') |
| 파일 내 병렬 실행 | test.describe.configure({ mode: 'parallel' }) |
| 커스텀 픽스처 | export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => { await use(new LoginPage(page)); },
}); |
| 로그인 상태 재사용 | test.use({ storageState: 'playwright/.auth/user.json' }) |
| 리포트에 스텝 이름 표시 | await test.step('add to cart', async () => { /* ... */ }) |
playwright.config.ts
| 설정 | 예시 |
|---|---|
| page.goto의 기본 URL | use: { baseURL: 'http://localhost:3000' } |
| 실패 시 증거 수집 | use: { trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' } |
| CI에서만 재시도 | retries: process.env.CI ? 2 : 0 |
| 병렬 실행 | fullyParallel: true, workers: process.env.CI ? 2 : undefined |
| test.only가 커밋되면 실패 처리 | forbidOnly: !!process.env.CI |
| 앱을 먼저 기동 | webServer: { command: 'npm run start', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI } |
| 브라우저 | projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }] |
| 기본 타임아웃 | timeout: 30_000, expect: { timeout: 5_000 } |