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

page.route 로 네트워크 가로채기

~18 min · playwright-advanced, network, route

Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"E2E 테스트가 진짜 백엔드 안 쳐도 돼. 그렇게 한 것처럼 행동만 하면 돼."

왜 E2E 에서 네트워크 가로채

진짜 API 치는 E2E 테스트는 테스트 시간에 세 가지 참인 것에 의존: API 살아있음, API 빠름, API 가 결정적 데이터 반환. 어느 하나 빠지면 flake. page.route() 가 outbound HTTP 가로채서 테스트에서 응답하게 해, 백엔드 동작이 테스트하려는 게 아닌 케이스에 세 실패 모드 다 제거.

진짜 백엔드 테스트 원할 때 (integration end-to-end, contract 검증) 는 가로채지 마. 두 테스트 모드는 다른 목적 serve 하고 서로 옆에 살아.

Route 로 할 수 있는 네 가지

page.route(pattern, handler) 발동하면 핸들러가 네 exit path 가진 Route 객체 받아:

  • route.fulfill({ status, body, contentType }) — fake response 로 응답. 요청이 네트워크에 절대 안 닿음.
  • route.continue({ headers, postData }) — request 수정하고 진짜 서버로 보내.
  • route.abort() — request 실패. 에러 path 테스트에 유용.
  • route.fallback() — 다음 매칭 route 핸들러로 미뤄 (route 핸들러 스택).

패턴 매칭

패턴은:

  • Glob: '**/api/users/*'
  • Regex: /\/api\/users\/\d+/
  • URL 에 대한 predicate 함수.

Glob 이 쉬운 default. 더 엄격한 매칭 필요할 때 (예: 숫자 id 만) regex. URL 이상에 의존하는 매칭 (method, header) 엔 predicate.

Scope — page vs context vs Browser

Scope 세 레벨:

  • page.route() — 이 Page 의 요청만 가로채. 좋은 default.
  • context.route() — context 의 모든 Page 에서 가로채 (드물; 공유 state 브라우저 컨텍스트에 유용).
  • browser.route() — 존재 안 함; 대신 fixture 로 올려.

test.beforeEach 에서 추가한 route 는 테스트 동안 지속; fixture setup 에서 추가한 route 는 fixture 만큼 지속.

테스트가 UI 에 대한 거면 네트워크 mock; 테스트가 네트워크에 대한 거면 mock 하지 마. 'API 가 세 아이템 반환하면 카트가 세 아이템 보여줌' 단언하는 테스트는 UI 테스트 — mock. '결제 흐름이 staging 에 대해 완료됨' 단언하는 테스트는 contract 테스트 — mock 하지 마.

HAR 파일 — 한 번 녹화, 영원히 replay

수동 mocking 이 지루한 복잡한 흐름엔 Playwright 가 진짜 백엔드에 대해 한 번 HAR 파일 (모든 request/response 의 archive) 녹화하고, 후속 모든 run 에 replay 가능. 결과: per-request mock 코드 zero 인 현실적 response.

백엔드가 복잡하지만 안정적일 때 HAR 패턴 써; 변형 response (에러, edge case) 테스트해야 할 때 수동 route.fulfill 로 돌아와.

Code

테스트 둘, 같은 endpoint, 다른 response·typescript
// Fulfilling a request — the most common pattern
import { test, expect } from '@playwright/test';

test('shows the user list when API returns three', async ({ page }) => {
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Pippa', email: 'pippa@example.com' },
        { id: 2, name: 'Dad', email: 'dad@example.com' },
        { id: 3, name: 'Mom', email: 'mom@example.com' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(3);
  await expect(page.getByText('Pippa')).toBeVisible();
});

test('shows the empty state when API returns nothing', async ({ page }) => {
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: '[]',
    });
  });

  await page.goto('/users');
  await expect(page.getByText(/no users yet/i)).toBeVisible();
});
에러 path — 500 과 네트워크 실패·typescript
// Aborting and erroring — testing error paths
import { test, expect } from '@playwright/test';

test('shows error UI when API returns 500', async ({ page }) => {
  await page.route('**/api/users', (route) =>
    route.fulfill({ status: 500, body: 'Internal Server Error' })
  );

  await page.goto('/users');
  await expect(page.getByRole('alert', { name: /failed to load/i })).toBeVisible();
  await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
});

test('shows network-error UI when fetch fails', async ({ page }) => {
  // abort() simulates the request failing entirely (DNS failure, etc.)
  await page.route('**/api/users', (route) => route.abort('failed'));

  await page.goto('/users');
  await expect(page.getByText(/network error/i)).toBeVisible();
});
Modify-and-forward — `route.continue()`·typescript
// Modifying a real request — continue() with overrides
import { test } from '@playwright/test';

test('injects an x-test header on outbound requests', async ({ page }) => {
  await page.route('**/api/**', async (route, request) => {
    const headers = {
      ...request.headers(),
      'x-test-run': 'pippa-quest-suite',
    };
    await route.continue({ headers });
  });

  await page.goto('/');
  // The real API receives every request with the extra header attached.
  // Useful for tagging test traffic in observability.
});
HAR 패턴 — 한 번 녹화, 항상 replay·typescript
// HAR record/replay — when manual mocking would be tedious
import { test, expect } from '@playwright/test';

// First run — record
test('records the user-list flow', async ({ page, context }) => {
  test.skip(!process.env.RECORD, 'Only runs with RECORD=true');

  await context.routeFromHAR('e2e/.har/users-flow.har', {
    update: true,    // record this run into the HAR
    url: '**/api/**',
  });

  await page.goto('/users');
  // ... run the flow once against the real backend
});

// Every subsequent run — replay
test('replays the user-list flow from HAR', async ({ page, context }) => {
  await context.routeFromHAR('e2e/.har/users-flow.har', {
    url: '**/api/**',
  });

  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(3);
});

External links

Exercise

Suite 에서 진짜 백엔드 치는 E2E 테스트 골라. 그 테스트의 메인 API endpoint 를 mock 하는 page.route() 추가. 진짜 백엔드 멈췄을 때 (dev 서버 일시적으로 죽임) 도 테스트 통과 확인. 그 다음 같은 컴포넌트에 대한 두 번째 테스트 짜는데 API mock 이 500 반환 — 에러 UI 가 맞게 렌더되는지 확인. 두 번째 테스트가 진짜 백엔드에 대해 짜기 얼마나 어려웠을지 주목.
Hint
Route 추가 후에도 테스트가 네트워크 치면 패턴이 매칭 안 하는 거. Playwright 의 --debug 모드로 어느 요청이 날고 있는지 그리고 URL 이 뭔지 봐, 그 다음 정확한 path 매칭하게 glob 좁혀.

Progress

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

댓글 0

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

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