뒤로가기
Playwright로 E2E 테스트 처음 도입한 이야기

October 21, 2024

testingfrontenddevops

유닛 테스트는 있었다. 컴포넌트 테스트도 좀 있었다. 그런데 "로그인하고 → 대시보드로 이동하고 → 프로필을 수정하고 → 저장 버튼을 누르면 토스트가 뜬다" 같은 흐름을 테스트하는 건 전부 사람이 했다. 배포할 때마다 QA 체크리스트를 손으로 돌리고 있었다.

한 번은 로그인 후 리다이렉트 로직을 수정했는데, 회원가입 플로우가 깨졌다. 유닛 테스트는 통과했다. 각 함수는 잘 동작했으니까. 그런데 함수들이 연결되는 지점에서 문제가 생긴 거다. 이걸 배포 후 사용자가 발견했다.

그 뒤로 E2E 테스트를 도입하기로 했다.

Playwright를 선택한 이유#

E2E 테스트 도구로 Cypress와 Playwright를 비교했다.

Playwright를 선택한 결정적 이유는 세 가지:

멀티 브라우저. Chromium, Firefox, WebKit을 하나의 테스트 코드로 돌릴 수 있다. Cypress는 Chrome 계열에 집중하고 있어서, Safari(WebKit) 테스트가 어렵다.

속도. Playwright는 브라우저 컨텍스트를 병렬로 돌린다. 테스트 파일이 30개면 동시에 실행된다. 실제로 같은 테스트를 Cypress와 비교했을 때 2~3배 빨랐다.

Auto-waiting. 요소가 나타날 때까지, 클릭 가능할 때까지 자동으로 기다린다. cy.wait(1000) 같은 하드코딩된 대기가 거의 필요 없다.

설치와 기본 설정#

bash
npm init playwright@latest

이 명령이 playwright.config.ts를 만들어준다:

typescript
// 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 서버를 띄워준다.

첫 번째 테스트: 로그인 플로우#

typescript
// 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를 쓰면 인증 상태를 파일로 저장하고 재사용할 수 있다.

typescript
// 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' });
});
typescript
// 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)#

yaml
# .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개가 배포 자신감에 더 기여한다.