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 셋업 #
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의 핵심 설정.
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를 두 번까지 자동 재시도합니다.
첫 시나리오 — 홈 페이지가 그려진다 #
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 모드로 열려 각 스텝을 시각적으로 확인할 수 있습니다.
pnpm exec playwright test --uiLocator의 우선순위 #
29장의 Testing Library에서 본 원칙이 그대로 적용됩니다. 사용자가 화면에서 인지할 수 있는 셀렉터를 우선합니다.
권장 순서.
getByRole(role, { name })— ARIA role + 접근 이름. 가장 안정적.getByLabel(text)— 라벨로 연결된 폼 컨트롤.getByPlaceholder(text)— placeholder.getByText(text)— 텍스트 내용.getByTestId(id)—data-testid속성. 최후의 수단.
CSS 셀렉터(page.locator('.btn-primary'))도 가능하지만, CSS 클래스는 디자인 리팩터링에 약합니다. 같은 셀렉터가 디자인 시스템 교체 후 깨지는 비용이 큽니다. role / label 기반이 안정적입니다.
getByTestId는 위 옵션이 모두 어려울 때만. data-testid 속성은 production 빌드에서 빠지지 않으니, 정말 다른 셀렉터로 표현이 어려운 경우에만 둡니다.
인증 흐름 자동화 — storageState #
Todo 앱 같은 대부분의 시나리오는 로그인된 상태에서 동작합니다. 매 테스트마다 로그인 폼을 작성하면 느리고 불안정합니다.
Playwright는 로그인 상태를 한 번 저장해 두고 모든 테스트가 재사용하는 패턴을 표준으로 제공합니다.
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 프로젝트와 의존성을 설정합니다.
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();
// ... 셀렉터 중복
});페이지 객체 패턴은 같은 페이지에 대한 셀렉터와 액션을 한 객체에 모아 둡니다.
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를 쓸 일이 거의 없습니다. 다음은 안티패턴입니다.
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한 테스트는 따로 표시해 두고 원인을 추적하는 흐름이 건강합니다.
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:
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를 도는 것입니다. 실제 운영 빌드와 운영 환경 변수로 도는 셈이라 운영 전용 버그를 잘 잡습니다.
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 | 중간 |
| E2E | Playwright | 사용자 시나리오 (회원가입 → …) | ~5초 | 적게 |
E2E를 적게 두라는 게 가치를 낮게 친다는 뜻이 아닙니다. 한 개의 E2E가 막아 주는 버그의 폭이 크기 때문에 갯수보다 시나리오의 선정이 중요합니다.
좋은 E2E 시나리오의 기준.
- 비즈니스 핵심 흐름 (가입 → 결제 → 사용 같은 critical path)
- 회귀가 자주 발생한 영역
- 단위로 잘 표현하기 어려운 통합 동작 (예: 인증 + 권한 + 라우팅)
직접 해보기 — 27장 방명록의 E2E #
29장에서 단위 테스트한 방명록을 E2E로 검증합니다.
- 메시지 등록 시나리오:
/guestbook에 접속해 이름과 메시지를 입력하고 등록 → 목록에 새 메시지가 보이는지 검증. - 검증 실패 시나리오: 빈 이름으로 제출 → 에러 메시지가 화면에 보이고 목록에는 추가되지 않는지 검증.
- 삭제 시나리오: 등록 후 삭제 버튼 클릭 → 해당 메시지가 사라지는지 검증.
- 페이지 객체 추출: 위 세 시나리오를 작성한 뒤, 공통 셀렉터를
GuestbookPage객체로 추출합니다. 추출 전후의 코드를 비교해 가독성이 어떻게 달라지는지 직접 보세요.
이 네 단계를 거치면 E2E의 작성 흐름과 페이지 객체 추출 타이밍이 손에 익습니다.
연습문제 #
- Vitest와 Playwright의 역할 분담. 다음 다섯 가지 동작에 대해 어느 도구로 검증하는 게 적절한지 답해 보세요. (a)
formatDate유틸 함수의 timezone 처리, (b)useToggle훅의 boolean 토글, (c) 로그인 → 보호된 페이지 접근 차단, (d) 폼 제출 시 빈 입력 검증, (e) 결제 풀 흐름. 답을 적은 뒤 본문의 권장 비율 표와 맞춰 봅니다. - flaky 분석. 다음 테스트가 가끔 깨지는 이유를 추정하고 고쳐 보세요.
await page.click('button'); await page.waitForTimeout(1000); expect(...). 본문의 “auto-wait 활용” 절을 참고합니다. - 시각 회귀의 적용 범위 선정. 본인이 만든 (또는 이 책에서 만든) 앱에서 시각 회귀 테스트를 적용하면 가치 있을 페이지 두 곳과, 적용하면 비용만 늘 페이지 두 곳을 골라 보세요. 선정 기준을 한 문장으로 정리합니다.
한 줄 요약: Playwright는 실제 브라우저에서 사용자 시나리오를 자동화하는 표준 도구다. role / label 기반 locator를 우선하고,
storageState로 인증을 한 번만 통과한다. 페이지 객체는 시나리오가 모인 뒤에 추출하고,waitForTimeout대신expect로 무엇을 기다리는지 명시한다. 단위 / 통합 / E2E는 갯수 비율 (많이 / 중간 / 적게)보다 시나리오 선정이 본질이고, 실패 시 trace / video가 CI artifact로 자동 저장되도록 두는 게 디버깅 비용을 크게 낮춘다.
다음 챕터 #
다음 31장 성능 · 번들 · Web Vitals에서는 만들어진 앱이 사용자에게 얼마나 빠르게 도달하는지를 측정하고 개선하는 도구들을 다룹니다. 14장 (성능 최적화)이 리액트 내부 재렌더링 비용이었다면, 31장은 사용자가 실제로 체감하는 LCP / INP / CLS의 세 지표입니다. 그리고 26장 (Suspense와 use())의 streaming이 이 지표들과 어떻게 맞물리는지도 한 번 더 살펴봅니다.