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

Anti-Flake 패턴 — Suite 신뢰

~16 min · playwright-locators, flakiness, anti-pattern

Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Flake 는 Playwright 문제 아냐. 너의 테스트가 가시화하는 설계 문제야."

Flake 의 흔한 다섯 원인

  1. 하나 가정하는데 여러 element 매칭하는 selector. 'Save' 이름의 버튼 두 개, 같은 텍스트의 리스트 아이템 두 개 — locator 가 레이아웃 바뀌어서 다른 첫 번째 매칭할 때까지 첫 번째 조용히 매칭.
  2. 테스트 순서 의존성. 테스트 A 가 사용자 생성; 테스트 B 가 사용자 카운트 1 단언. B 만 돌리면 실패; A 그 다음 B 면 통과; 병렬은 동전 던지기.
  3. State 단언 대신 sleep. 지난 lesson 에서 다룸. 추측 지속 시간은 일어날 race.
  4. 시간 / 날짜 의존. '오늘의 아이템' 필터 테스트가 UTC 자정에 깨져. 너의 코드와 같음 — 테스트가 실제 시간 쓰면 자정 버그 보게 돼.
  5. 진짜 네트워크 호출. 진짜 API 치는 테스트는 그 API 가 살아 있고, 빠르고, 결정적인 데 의존. 보통 다 참 아냐.

해독제 — 테스트 격리

모든 Playwright 테스트가 default 로 자기 브라우저 컨텍스트 — 별개 쿠키, 별개 localStorage, 별개 캐시. 그게 격리의 기초. 근데 격리는 테스트가 깊게 가는 만큼만 깊어: 테스트가 DB row 생성하면 뭔가 정리하지 않는 한 그 row 남아.

깨끗한 패턴: 모든 테스트가 자기 state setup 하고 tear down (또는 테스트 사이 reset 되는 DB 스냅샷에 대해 작업). 다른 테스트도 쓰는 'fixture' 에 대해 테스트하지 마.

해독제 — 결정적 시간과 데이터

테스트가 의존하면 시간과 데이터 핀. Playwright 에 페이지 안 wall-clock 시간 고정 위한 page.clock.install() 있어 (Vitest 의 useFakeTimers 와 비슷). MSW (다음 트랙) 가 네트워크 쪽 커버 — 모든 API response 가 네가 제어.

해독제 — 구체적 locator

Locator 가 한 element 이상 매칭하면 테스트가 암묵적으로 '첫 번째' 선택 — 오늘 DOM 이 우연히 첫 렌더하는 거. 정확히 한 매칭 있게 selector 충분히 구체적으로:

  • 부모 scope 추가: 그냥 page.getByRole('link', { name: 'Home' }) 아니라 page.getByRole('navigation').getByRole('link', { name: 'Home' }).
  • 리스트 안 컨텐츠 기반 disambiguation 엔 filter({ hasText }) 써.
  • 텍스트 매칭 시 { exact: true } 옵션 써: getByText('Save', { exact: true }) 는 'Save and continue' 매칭 안 함.
모든 flake 를 설계 신호로 다뤄, 일시적 짜증 아니라. 본능은 retry 를 2 에서 5 로 올리고 넘어가는 거. 규율은 묻기: 뭐가 race 했어? 어떤 state 가 공유됐어? 어떤 selector 가 모호했어? 제대로 고친 flake 하나가 버그 클래스 제거; retry 로 반창고 붙인 거 하나가 더 많은 flake 양육.

해독제 — 조사 도구

테스트가 flake 하면 trace viewer 가 첫 정거장. 보여줘:

  • Locator 가 쿼리된 순간의 DOM — element 가 거기 있었어?
  • 진행 중 네트워크 요청 — API 가 다르게 응답했어?
  • 액션 타임라인 — 클릭이 맞는 element 에 떨어졌어?

trace: 'on-first-retry' 활성화해서 retry 에서 trace 자동 캡처, 거기서부터 디버그.

Flake 분류

Flake 보면 순서대로 물어:

  1. Locator 가 여러 element 매칭해? (Trace 열고 DOM 봐.)
  2. 테스트가 다른 테스트의 state 에 의존해? (격리 실행; 통과하면 찾은 거.)
  3. 인터랙션 전에 waitForTimeout 이나 누락된 단언 있어? (Lesson 7.3 참고.)
  4. 테스트가 시간이나 진짜 네트워크에 의존해? (시간 핀, 네트워크 mock.)

Code

모호한 locator disambiguate·typescript
// Flake source #1: ambiguous selector
// ❌ This might match either Sign-in button, depending on render order.
await page.getByRole('button', { name: 'Sign in' }).click();

// ✅ Scope it to the specific region.
await page
  .getByRole('navigation')
  .getByRole('button', { name: 'Sign in' })
  .click();

// ✅ Or use exact text + filter.
await page.getByRole('button', { name: 'Sign in', exact: true })
          .filter({ hasNotText: 'with Google' })
          .click();
테스트 격리 — 테스트당 reset + seed·typescript
// Flake source #2: test order dependence
import { test, expect } from '@playwright/test';

// ❌ This test assumes there are exactly N users — depends on what other tests left behind
test('lists three users', async ({ page }) => {
  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(3);
});

// ✅ Reset state at test start
test('lists exactly the users we created', async ({ page, request }) => {
  // Reset the database via a test-only endpoint
  await request.post('/api/test/reset');

  // Seed exactly what this test needs
  await request.post('/api/test/seed', {
    data: { users: [{ name: 'Alice' }, { name: 'Bob' }] },
  });

  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(2);
});
page.clock 로 시간 핀·typescript
// Flake source #4: time dependence
import { test, expect } from '@playwright/test';

test('shows today\'s items', async ({ page }) => {
  // Pin the wall clock so 'today' is deterministic.
  await page.clock.install({ time: new Date('2026-05-25T12:00:00Z') });

  await page.goto('/items?filter=today');
  await expect(
    page.getByRole('heading', { name: /items for may 25, 2026/i })
  ).toBeVisible();
});

// Advancing the clock during the test
test('reminder fires after one hour', async ({ page }) => {
  await page.clock.install({ time: new Date('2026-05-25T12:00:00Z') });
  await page.goto('/reminders');
  await page.getByRole('button', { name: 'Set 1-hour reminder' }).click();

  // Jump forward 1 hour
  await page.clock.fastForward('1:00:00');

  await expect(page.getByRole('alert', { name: /reminder/i })).toBeVisible();
});
네트워크 mock — 결정적 response·typescript
// Flake source #5: real network
// (Full network mocking is the next track. Quick preview:)
import { test, expect } from '@playwright/test';

test('handles the API response shape', async ({ page }) => {
  // Intercept the API call and return a deterministic response
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Pippa' },
        { id: 2, name: 'Dad' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(2);
  // The test no longer depends on a real backend.
});

External links

Exercise

한 번이라도 flake 한 너의 프로젝트의 Playwright 테스트 골라. 실패한 run 의 trace 열어 (또는 재현하려 --repeat-each=50 으로 50번 재실행). Trace 걸어보며 답: locator 모호했어, state 공유됐어, wait 누락됐어, 시간 / 네트워크 관련됐어? 실제 원인 고쳐 — retry 추가하지 마 — 50번 더 재실행해서 확인. 원인 카테고리 적어두기; 그게 또 볼 거야.
Hint
로컬에서 50번 돌려도 재현 못 하면 원인이 CI 특정일 수 있어 (느린 CPU, 다른 네트워크). CI 에서 --repeat-each 와 trace 활성화로 돌려 — CI 실패의 trace 가 유일한 디버그 입력.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

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

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