유닛 테스트는 있었다. 컴포넌트 테스트도 좀 있었다. 그런데 "로그인하고 → 대시보드로 이동하고 → 프로필을 수정하고 → 저장 버튼을 누르면 토스트가 뜬다" 같은 흐름을 테스트하는 건 전부 사람이 했다. 배포할 때마다 QA 체크리스트를 손으로 돌리고 있었다.
한 번은 로그인 후 리다이렉트 로직을 수정했는데, 회원가입 플로우가 깨졌다. 유닛 테스트는 통과했다. 각 함수는 잘 동작했으니까. 그런데 함수들이 연결되는 지점에서 문제가 생긴 거다. 이걸 배포 후 사용자가 발견했다.
그 뒤로 E2E 테스트를 도입하기로 했다.
Playwright를 선택한 이유
E2E 테스트 도구로 Cypress와 Playwright를 비교했다.
Playwright를 선택한 결정적 이유는 세 가지:
멀티 브라우저. Chromium, Firefox, WebKit을 하나의 테스트 코드로 돌릴 수 있다. Cypress는 Chrome 계열에 집중하고 있어서, Safari(WebKit) 테스트가 어렵다.
속도. Playwright는 브라우저 컨텍스트를 병렬로 돌린다. 테스트 파일이 30개면 동시에 실행된다. 실제로 같은 테스트를 Cypress와 비교했을 때 2~3배 빨랐다.
Auto-waiting. 요소가 나타날 때까지, 클릭 가능할 때까지 자동으로 기다린다. cy.wait(1000) 같은 하드코딩된 대기가 거의 필요 없다.
설치와 기본 설정
npm init playwright@latest이 명령이 playwright.config.ts를 만들어준다:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0, // CI에서만 재시도
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // 실패 시 트레이스 수집
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
});webServer 설정이 편리하다. 테스트 실행 전에 자동으로 dev 서버를 띄워준다.
첫 번째 테스트: 로그인 플로우
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test('로그인 후 대시보드로 이동한다', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('test@example.com');
await page.getByLabel('비밀번호').fill('password123');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('안녕하세요')).toBeVisible();
});
test('잘못된 비밀번호로 로그인하면 에러 메시지가 표시된다', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('test@example.com');
await page.getByLabel('비밀번호').fill('wrong');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByText('이메일 또는 비밀번호가 올바르지 않습니다')).toBeVisible();
await expect(page).toHaveURL('/login'); // 페이지 이동 없음
});getByRole, getByLabel, getByText — 사용자가 실제로 보는 것을 기준으로 요소를 찾는다. data-testid를 남발하지 않아도 된다. 접근성이 좋은 UI라면 role과 label만으로 충분하다.
인증 상태 재사용
모든 테스트에서 로그인을 반복하면 느리다. Playwright의 storageState를 쓰면 인증 상태를 파일로 저장하고 재사용할 수 있다.
// e2e/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('로그인', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('test@example.com');
await page.getByLabel('비밀번호').fill('password123');
await page.getByRole('button', { name: '로그인' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: './e2e/.auth/user.json' });
});// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: './e2e/.auth/user.json',
},
},
],
});setup 프로젝트가 먼저 실행되어 로그인하고, 나머지 테스트는 저장된 인증 상태를 사용한다. 로그인을 한 번만 수행하니까 전체 테스트 시간이 크게 줄어든다.
CI 연동 (GitHub Actions)
# .github/workflows/e2e.yml
name: E2E Tests
on:
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --project=chromium
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/CI에서는 Chromium만 돌렸다. 세 브라우저 다 돌리면 시간이 3배이고, 브라우저 간 차이로 인한 flaky 테스트가 늘어난다. WebKit 테스트는 주 1회 스케줄로 따로 돌리는 게 현실적이었다.
실패하면 playwright-report를 아티팩트로 올려서 트레이스를 확인할 수 있다. 트레이스에는 매 액션의 스크린샷, 네트워크 요청, 콘솔 로그가 다 담겨 있어서 로컬에서 재현하지 않아도 원인을 파악할 수 있다.
유지보수에서 배운 것
E2E 테스트의 진짜 비용은 작성이 아니라 유지보수다.
핵심 플로우만 테스트한다. 로그인, 회원가입, 결제, 핵심 CRUD — 이것만 커버해도 충분하다. 모든 페이지를 E2E로 테스트하려는 유혹에 빠지면 유지보수 지옥이 된다.
테스트 데이터를 격리한다. 테스트끼리 데이터를 공유하면 실행 순서에 따라 결과가 달라진다. 각 테스트가 자기만의 데이터를 만들고 정리하도록 했다.
flaky 테스트는 즉시 고친다. "가끔 실패하는 테스트"를 방치하면, 팀이 테스트 실패를 무시하기 시작한다. 한 번 그 습관이 붙으면 E2E 테스트 전체의 신뢰가 무너진다. flaky 테스트가 발견되면 원인을 파악해서 바로 수정하거나, 수정할 수 없으면 일시적으로 skip하고 이슈를 등록했다.
E2E 테스트가 만능은 아니다. 느리고, 깨지기 쉽고, 유지보수 비용이 크다. 그래도 "이 배포가 핵심 플로우를 깨뜨리지 않는다"는 확신을 주는 건 E2E 테스트밖에 없다. 유닛 테스트 1000개보다 잘 짜인 E2E 테스트 10개가 배포 자신감에 더 기여한다.
