~15 min · playwright-locators, assertions, web-first
Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Playwright 에선 단언이 wait 야."
'Web-First' 가 실제로 뭘 의미해
일반 단언 (expect(value).toBe(...)) 은 한 번 돌아. 값이 매칭 안 되면 즉시 실패. State 가 동기인 in-process 테스트엔 맞는 동작.
브라우저에선 state 가 비동기. 폼 검증되는 동안 버튼이 100ms 동안 disable 일 수 있어. Toast 가 fade-in 에 200ms 걸릴 수 있어. Fetch 완료 후 리스트가 re-render 될 수 있어. 이것들에 대한 one-shot 단언은 race — 가끔 브라우저가 따라잡았고, 가끔 안 따라잡았어.
Web-first 단언 (expect(locator).toBe...) 은 조건이 통과하거나 timeout (default 5초) 만료까지 retry. 버튼이 enable 안 됐어? 기다려. Toast 가 아직 visible 안 됐어? 기다려. 리스트가 0 아이템이야? 기다려. Retry 내장 — waitFor 안 쓰고, sleep 안 하고, 그냥 참이길 기대하는 거 단언.
쓸 단언 어휘
toBeVisible() / toBeHidden() — element 존재하고 viewport 안 / 아님.
toBeEnabled() / toBeDisabled()
toBeChecked() / not.toBeChecked()
toHaveText(string | regex) — 정확한 텍스트 매칭 (부분 문자열엔 regex).
Default 5초 timeout 이 대부분 케이스에 맞아. 알려진 느린 작업 (결제 게이트웨이, 오래 걸리는 검색) 엔 마지막 인자로 { timeout: 15000 } 넘겨. 어느 지연에서든 시끄럽게 실패 원하는 알려진 빠른 거엔 { timeout: 1000 } 으로 낮춰.
`await page.waitForTimeout(2000)` 쓰고 있다면 멈춰.waitForTimeout 은 진짜 메서드인데 거의 항상 틀려. 자연스럽게 retry 하는 단언이 있거나, 기다려야 할 누락된 앱 state 이벤트가 있어. 시간만큼 sleep 하는 건 추측; 단언이 진실.
Web-First 단언이 아닌 것
마법 아냐. 조건이 진짜로 절대 참이 안 될 거면 (버튼 이름 오타, element 가 렌더 안 됨), timeout 후 실패 — 일반 단언과 같고, 그냥 더 느림. 핵심은 비동기 UI 의 JITTER 흡수지 진짜 버그 paper-over 가 아냐.
page 레벨 vs locator 레벨 단언
대부분 단언이 expect(locator) 통해 가. 중요한 페이지 레벨 두 개:
expect(page).toHaveURL(...) — navigation 후.
expect(page).toHaveTitle(...) — <title> element 에.
이것들도 retry — navigation 이 짧은 redirect 체인 포함할 때 유용.
Code
Web-first 단언 — 수동 wait 필요 없음·typescript
// All of these RETRY until they pass or the timeout (default 5s) expires
import { test, expect } from '@playwright/test';
test('web-first assertions don\'t need waits around them', async ({ page }) => {
await page.goto('/dashboard');
// Visibility — retries until the element appears (or 5s)
await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();
// Text content — handles slow-loading text
await expect(page.getByTestId('user-name')).toHaveText('Pippa Choi');
// Count — retries until N items render
await expect(page.getByRole('listitem')).toHaveCount(3);
// URL after navigation — handles redirect chains
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page).toHaveURL(/\/settings$/);
// Form input value — handles async state from form libraries
await page.getByLabel('Email').fill('new@example.com');
await expect(page.getByLabel('Email')).toHaveValue('new@example.com');
});
단언별 timeout — 느린 path 는 올리고, 빠른 path 는 내려·typescript
// Adjusting timeout when defaults aren't right
import { test, expect } from '@playwright/test';
test('payment confirmation can take up to 30s', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay $99.99' }).click();
// Override the default 5s to 30s — payment gateway is slow
await expect(
page.getByText(/payment confirmed/i)
).toBeVisible({ timeout: 30_000 });
});
test('fast-path assertion should fail loudly on any delay', async ({ page }) => {
await page.goto('/login');
// This page should render instantly; if it's slow, that's a bug.
await expect(
page.getByRole('heading', { name: 'Sign in' })
).toBeVisible({ timeout: 1_000 });
});
부정 단언 — `toBeHidden` 도 retry·typescript
// Negative assertions — for things that should DISAPPEAR
import { test, expect } from '@playwright/test';
test('toast auto-dismisses', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Save' }).click();
// Toast appears
const toast = page.getByRole('alert', { name: /saved/i });
await expect(toast).toBeVisible();
// Toast disappears — also retries (waits up to 5s for it to go away)
await expect(toast).toBeHidden();
// Or use the explicit semantic:
// await expect(toast).not.toBeVisible();
});
안티 패턴 vs 맞는 패턴·typescript
// Anti-pattern: don't do this
import { test, expect } from '@playwright/test';
test('the wrong way (DON\'T)', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Load' }).click();
// ❌ Sleeping for a guessed duration — fragile and slow
await page.waitForTimeout(2000);
// ❌ Now you assert against possibly-stale state
const text = await page.getByTestId('result').textContent();
expect(text).toBe('Loaded!');
});
test('the right way (DO)', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Load' }).click();
// ✅ Wait FOR the condition, not for time
await expect(page.getByTestId('result')).toHaveText('Loaded!');
});
프로젝트에서 page.waitForTimeout 이나 page.waitForSelector 쓰는 기존 E2E 테스트 가져와서 web-first 단언만 써서 다시 짜. 다시 쓴 후 테스트가 더 짧아야 하고 (측정하면) 최소 같은 속도. 그 다음 일부러 단언 깨 (안 나타날 텍스트 단언) 실패 메시지가 어떻게 보이는지 관찰 — timeout, locator 묘사, 실제 state 주목.
Hint
waitForSelector('.thing') 을 대체할 web-first 단언 못 찾으면, 기저 이슈가 보통 '단지 element 가 존재함' 이 아니라 state (텍스트, 가시성, 값) 에 단언해야 하는 거. '내가 실제로 기다리는 사용자 가시 뭐야?' 물으면 보통 맞는 단언이 드러나.
Progress
Progress is local-only — sign in to sync across devices.