목차
30 장

E2E 테스팅 — Playwright

Playwright로 실제 브라우저에서 풀 시나리오 자동화. 셋업, locator 규칙, 인증 storageState, 페이지 객체 패턴, flaky 다루기, CI 통합과 시각 회귀까지.

29장에서 컴포넌트와 훅 단위의 자동 테스트를 다뤘습니다. 컴포넌트가 각자의 위치에서 의도대로 동작하는지를 잘 검증해 줍니다. 다만 컴포넌트들이 합쳐져 만들어 내는 사용자 시나리오 전체 - 회원가입 → 로그인 → Todo 추가 → 완료 토글 - 가 정상 동작하는지는 단위 테스트로 확신할 수 없습니다. 본 챕터에서는 그 공백을 채우는 도구 Playwright를 살펴봅니다.

29장의 단위 테스트가 작은 부분을 자주 검증한다면, 본 챕터의 E2E 테스트는 큰 흐름을 가끔 검증합니다. 두 레벨은 보완 관계이고, 어느 하나로 다른 하나를 대체하려 들면 비용이 빠르게 올라갑니다. 그리고 5부의 마지막 챕터인 33장 (배포와 관측성)에서 본 챕터의 E2E를 preview deploy 환경에서 자동으로 돌리는 패턴까지 이어 가겠습니다.

E2E가 잡는 것 #

29장의 단위 / 통합 테스트로도 잡히지 않는 버그는 보통 다음과 같은 모양입니다.

  • 컴포넌트 사이의 contract가 어긋남: A 컴포넌트는 string을 보내는데 B는 number를 기대.
  • 라우팅 / state 보존: 페이지를 떠났다 돌아오면 입력이 사라지는 버그.
  • 인증 상태와 권한: 로그인 안 된 상태로 보호된 페이지에 접근.
  • production 빌드 전용 이슈: 동적 import의 timing, hydration mismatch.
  • 외부 의존성과의 통합: 실제 DB / Server Action / 인증 flow.

이런 버그는 모든 부품이 같이 돌아가는 환경에서만 드러납니다. Playwright는 실제 브라우저를 띄우고 사용자가 클릭하고 입력하는 흐름을 그대로 자동화합니다.

Playwright 셋업 #

Playwright 설치
pnpm create playwright@latest

질문에 답하면 됩니다 (TypeScript Yes, GitHub Actions Yes 추천).

설치가 끝나면 다음 파일이 생깁니다.

생성된 파일
modern-react-demo/
├── playwright.config.ts        ← 설정
├── tests/                       ← E2E 테스트 파일
│   └── example.spec.ts
└── tests-examples/              ← 학습용 예제 (필요 없으면 삭제)

playwright.config.ts의 핵심 설정.

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],

  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

핵심.

  • baseURL로 테스트마다 매번 호스트를 적지 않습니다.
  • webServer가 테스트 시작 전에 Next.js dev 서버를 자동으로 띄웁니다. CI에서도 동일.
  • projects로 Chromium / Firefox / WebKit 세 브라우저를 매트릭스로 돌립니다.
  • retries: 2 (CI 한정)로 일시적인 flaky를 두 번까지 자동 재시도합니다.

첫 시나리오 — 홈 페이지가 그려진다 #

tests/home.spec.ts
import { test, expect } from '@playwright/test';

test('홈 페이지가 그려진다', async ({ page }) => {
  await page.goto('/');

  await expect(page.getByRole('heading', { name: '홈 페이지' })).toBeVisible();
  await expect(page.getByRole('link', { name: '소개' })).toBeVisible();
});

test('소개 페이지로 이동한다', async ({ page }) => {
  await page.goto('/');

  await page.getByRole('link', { name: '소개' }).click();

  await expect(page).toHaveURL('/about');
  await expect(page.getByRole('heading', { name: '소개' })).toBeVisible();
});

테스트 실행.

테스트 실행
pnpm exec playwright test

--ui 플래그를 붙이면 UI 모드로 열려 각 스텝을 시각적으로 확인할 수 있습니다.

UI 모드
pnpm exec playwright test --ui

Locator의 우선순위 #

29장의 Testing Library에서 본 원칙이 그대로 적용됩니다. 사용자가 화면에서 인지할 수 있는 셀렉터를 우선합니다.

권장 순서.

  1. getByRole(role, { name }) — ARIA role + 접근 이름. 가장 안정적.
  2. getByLabel(text) — 라벨로 연결된 폼 컨트롤.
  3. getByPlaceholder(text) — placeholder.
  4. getByText(text) — 텍스트 내용.
  5. getByTestId(id)data-testid 속성. 최후의 수단.

CSS 셀렉터(page.locator('.btn-primary'))도 가능하지만, CSS 클래스는 디자인 리팩터링에 약합니다. 같은 셀렉터가 디자인 시스템 교체 후 깨지는 비용이 큽니다. role / label 기반이 안정적입니다.

getByTestId는 위 옵션이 모두 어려울 때만. data-testid 속성은 production 빌드에서 빠지지 않으니, 정말 다른 셀렉터로 표현이 어려운 경우에만 둡니다.

인증 흐름 자동화 — storageState #

Todo 앱 같은 대부분의 시나리오는 로그인된 상태에서 동작합니다. 매 테스트마다 로그인 폼을 작성하면 느리고 불안정합니다.

Playwright는 로그인 상태를 한 번 저장해 두고 모든 테스트가 재사용하는 패턴을 표준으로 제공합니다.

tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('로그인 상태 저장', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('이메일').fill('test@example.com');
  await page.getByLabel('비밀번호').fill('test-password');
  await page.getByRole('button', { name: '로그인' }).click();

  await expect(page).toHaveURL('/dashboard');

  await page.context().storageState({ path: authFile });
});

playwright.config.ts에 setup 프로젝트와 의존성을 설정합니다.

playwright.config.ts (인증 setup 추가)
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});

이제 모든 chromium 테스트는 시작 시 이미 로그인된 상태입니다. 로그인 폼을 매번 통과할 필요가 없어 속도가 크게 빨라집니다.

권한별 storageState #

관리자 / 일반 사용자처럼 권한이 다른 시나리오를 다룬다면 storageState 파일을 권한별로 만듭니다. 32장 (인증과 세션)에서 만들 role-based 패턴과 자연스럽게 맞물립니다.

페이지 객체 패턴 #

테스트가 늘어나면 같은 셀렉터와 동작이 여러 파일에 반복됩니다.

셀렉터가 반복되는 코드
test('Todo 추가', async ({ page }) => {
  await page.goto('/todos');
  await page.getByLabel('할 일').fill('운동');
  await page.getByRole('button', { name: '추가' }).click();
  // ...
});

test('Todo 완료', async ({ page }) => {
  await page.goto('/todos');
  await page.getByLabel('할 일').fill('독서');
  await page.getByRole('button', { name: '추가' }).click();
  // ... 셀렉터 중복
});

페이지 객체 패턴은 같은 페이지에 대한 셀렉터와 액션을 한 객체에 모아 둡니다.

tests/pages/TodosPage.ts
import { type Page, type Locator } from '@playwright/test';

export class TodosPage {
  readonly input: Locator;
  readonly addButton: Locator;

  constructor(readonly page: Page) {
    this.input = page.getByLabel('할 일');
    this.addButton = page.getByRole('button', { name: '추가' });
  }

  async goto() {
    await this.page.goto('/todos');
  }

  async addTodo(text: string) {
    await this.input.fill(text);
    await this.addButton.click();
  }

  async toggleByText(text: string) {
    await this.page.getByRole('checkbox', { name: text }).check();
  }
}
페이지 객체를 쓰는 테스트
import { test, expect } from '@playwright/test';
import { TodosPage } from './pages/TodosPage';

test('Todo 추가와 완료', async ({ page }) => {
  const todos = new TodosPage(page);
  await todos.goto();

  await todos.addTodo('운동');
  await expect(page.getByText('운동')).toBeVisible();

  await todos.toggleByText('운동');
  await expect(page.getByRole('checkbox', { name: '운동' })).toBeChecked();
});

경고: 너무 일찍 페이지 객체를 만들지 마세요. 시나리오가 두세 개 모이고 같은 셀렉터가 반복되기 시작할 때 추출합니다. 첫 한두 시나리오는 그냥 평범한 테스트로 두는 게 가독성이 좋습니다.

느린 E2E를 빠르게 #

E2E는 기본적으로 느립니다. 다음 도구들로 빠르게 만들 수 있습니다.

1. 병렬 실행 #

fullyParallel: true가 켜져 있으면 한 파일 안의 테스트들도 병렬로 돕니다. workers 옵션으로 동시 실행 수를 조정합니다.

병렬 설정
export default defineConfig({
  fullyParallel: true,
  workers: process.env.CI ? 4 : undefined,
});

병렬화의 전제는 테스트가 서로 격리되어 있는 것입니다. 같은 사용자 ID를 쓰는 두 테스트가 동시에 돌면 충돌합니다. 사용자별 / 테스트별로 데이터를 격리하는 패턴이 필요합니다.

2. 결정적 셀렉터 #

getByText('완료')처럼 흔한 단어를 쓰면 화면에 여러 개가 있을 때 모호해집니다. 다음 패턴으로 좁힙니다.

범위로 좁히기
await page.getByRole('listitem')
  .filter({ hasText: '운동' })
  .getByRole('button', { name: '삭제' })
  .click();

3. auto-wait 활용 #

Playwright는 액션 전에 요소가 보일 때까지 자동으로 기다립니다. 따로 waitFor를 쓸 일이 거의 없습니다. 다음은 안티패턴입니다.

🚫 불필요한 sleep
await page.waitForTimeout(2000);  // 거의 항상 잘못된 신호
await page.click('button');

waitForTimeout은 단지 “잘 모르겠어서 기다림"입니다. 짧으면 flaky, 길면 느립니다. 대신 무엇을 기다리는지 명시합니다.

✅ 무엇을 기다리는지 명시
await expect(page.getByText('등록 완료!')).toBeVisible();
await page.click('button');

4. retry가 필요한 경우 #

한 두 번씩 실패하는 테스트는 flaky입니다. retry를 켜 두면 일시적인 flaky를 자동으로 흡수합니다. 다만 retry는 “버그를 숨기는 도구"가 될 수도 있습니다. flaky한 테스트는 따로 표시해 두고 원인을 추적하는 흐름이 건강합니다.

flaky 표시
test('가끔 깨지는 시나리오', async ({ page }) => {
  test.fixme();  // 알려진 flaky, 작업 중
  // ...
});

시각 회귀 (옵션) #

UI의 픽셀 단위 변화를 검증하고 싶다면 toHaveScreenshot를 씁니다.

시각 회귀 테스트
test('홈 페이지 시각 일관성', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('home.png');
});

처음 실행하면 기준 스크린샷이 저장되고, 이후 실행은 픽셀 비교를 합니다.

비용: 폰트 / 안티에일리어싱이 환경마다 달라 false positive가 잘 납니다. 시각 회귀는 모든 페이지에 두지 말고, 디자인 시스템의 핵심 컴포넌트 또는 꼭 일관성을 유지해야 하는 페이지에 한정해서 둡니다.

CI 통합 — GitHub Actions #

.github/workflows/e2e.yml:

.github/workflows/e2e.yml
name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps
      - run: pnpm exec playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

핵심.

  • playwright install --with-deps로 브라우저와 시스템 의존성을 함께 설치합니다.
  • if: failure() 조건의 artifact 업로드로 실패 시 trace / video / screenshot이 자동으로 GitHub Actions에 저장됩니다. 디버깅이 훨씬 쉬워집니다.

Preview deploy 환경에서 E2E #

33장 (배포와 관측성)에서 다시 다루겠지만, 가장 강력한 패턴은 Vercel / Cloudflare Pages의 preview deploy URL에서 Playwright를 도는 것입니다. 실제 운영 빌드와 운영 환경 변수로 도는 셈이라 운영 전용 버그를 잘 잡습니다.

playwright.config.ts (preview deploy 대응)
export default defineConfig({
  use: {
    baseURL: process.env.PREVIEW_URL ?? 'http://localhost:3000',
  },
  // CI에서는 webServer를 띄우지 않음
  webServer: process.env.PREVIEW_URL
    ? undefined
    : { command: 'pnpm dev', url: 'http://localhost:3000' },
});

단위 / 통합 / E2E의 분담 — 다시 한 번 #

29장에서 그린 표를 본 챕터 기준으로 채워 보겠습니다.

레벨대표 도구검증 대상1개당 속도권장 비율
단위Vitest순수 함수, 작은 컴포넌트, 훅~10ms많이
통합Vitest + jsdom컴포넌트 간 협업, 폼 흐름~100ms중간
E2EPlaywright사용자 시나리오 (회원가입 → …)~5초적게

E2E를 적게 두라는 게 가치를 낮게 친다는 뜻이 아닙니다. 한 개의 E2E가 막아 주는 버그의 폭이 크기 때문에 갯수보다 시나리오의 선정이 중요합니다.

좋은 E2E 시나리오의 기준.

  • 비즈니스 핵심 흐름 (가입 → 결제 → 사용 같은 critical path)
  • 회귀가 자주 발생한 영역
  • 단위로 잘 표현하기 어려운 통합 동작 (예: 인증 + 권한 + 라우팅)

직접 해보기 — 27장 방명록의 E2E #

29장에서 단위 테스트한 방명록을 E2E로 검증합니다.

  1. 메시지 등록 시나리오: /guestbook에 접속해 이름과 메시지를 입력하고 등록 → 목록에 새 메시지가 보이는지 검증.
  2. 검증 실패 시나리오: 빈 이름으로 제출 → 에러 메시지가 화면에 보이고 목록에는 추가되지 않는지 검증.
  3. 삭제 시나리오: 등록 후 삭제 버튼 클릭 → 해당 메시지가 사라지는지 검증.
  4. 페이지 객체 추출: 위 세 시나리오를 작성한 뒤, 공통 셀렉터를 GuestbookPage 객체로 추출합니다. 추출 전후의 코드를 비교해 가독성이 어떻게 달라지는지 직접 보세요.

이 네 단계를 거치면 E2E의 작성 흐름과 페이지 객체 추출 타이밍이 손에 익습니다.

연습문제 #

  1. Vitest와 Playwright의 역할 분담. 다음 다섯 가지 동작에 대해 어느 도구로 검증하는 게 적절한지 답해 보세요. (a) formatDate 유틸 함수의 timezone 처리, (b) useToggle 훅의 boolean 토글, (c) 로그인 → 보호된 페이지 접근 차단, (d) 폼 제출 시 빈 입력 검증, (e) 결제 풀 흐름. 답을 적은 뒤 본문의 권장 비율 표와 맞춰 봅니다.
  2. flaky 분석. 다음 테스트가 가끔 깨지는 이유를 추정하고 고쳐 보세요. await page.click('button'); await page.waitForTimeout(1000); expect(...). 본문의 “auto-wait 활용” 절을 참고합니다.
  3. 시각 회귀의 적용 범위 선정. 본인이 만든 (또는 이 책에서 만든) 앱에서 시각 회귀 테스트를 적용하면 가치 있을 페이지 두 곳과, 적용하면 비용만 늘 페이지 두 곳을 골라 보세요. 선정 기준을 한 문장으로 정리합니다.

한 줄 요약: Playwright는 실제 브라우저에서 사용자 시나리오를 자동화하는 표준 도구다. role / label 기반 locator를 우선하고, storageState로 인증을 한 번만 통과한다. 페이지 객체는 시나리오가 모인 뒤에 추출하고, waitForTimeout 대신 expect로 무엇을 기다리는지 명시한다. 단위 / 통합 / E2E는 갯수 비율 (많이 / 중간 / 적게)보다 시나리오 선정이 본질이고, 실패 시 trace / video가 CI artifact로 자동 저장되도록 두는 게 디버깅 비용을 크게 낮춘다.

다음 챕터 #

다음 31장 성능 · 번들 · Web Vitals에서는 만들어진 앱이 사용자에게 얼마나 빠르게 도달하는지를 측정하고 개선하는 도구들을 다룹니다. 14장 (성능 최적화)이 리액트 내부 재렌더링 비용이었다면, 31장은 사용자가 실제로 체감하는 LCP / INP / CLS의 세 지표입니다. 그리고 26장 (Suspense와 use())의 streaming이 이 지표들과 어떻게 맞물리는지도 한 번 더 살펴봅니다.

X