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

스스로 retry 하는 Web-First 단언

~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).
  • toContainText(string | regex) — 부분 문자열 매칭.
  • toHaveValue(string) — input 용.
  • toHaveAttribute(name, value)href, aria-*, data-* 용.
  • toHaveClass(string | regex)
  • toHaveCount(n) — 여러 element 매칭하는 locator 에.
  • toHaveURL(string | regex) — 페이지 레벨.
  • toHaveTitle(string | regex) — 페이지 레벨.

단언별 timeout 조정

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!');
});

External links

Exercise

프로젝트에서 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.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

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

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