DevFinance
테크·14 min read

Playwright로 E2E 테스트 자동화 완벽 가이드

프론트엔드 개발자를 위한 Playwright E2E 테스트 실전 가이드. 설치부터 CI/CD 연동까지 단계별로 정리했습니다.

E2E 테스트가 필요한 이유

유닛 테스트는 함수 단위, 통합 테스트는 모듈 간 연결을 검증합니다. 하지만 실제 유저가 브라우저에서 겪는 문제는 이것만으로 잡을 수 없습니다.

테스트 유형검증 범위속도신뢰도
유닛 테스트개별 함수/컴포넌트빠름낮음 (UI 버그 못 잡음)
통합 테스트모듈 간 연결보통보통
E2E 테스트유저 시나리오 전체느림높음

E2E 테스트는 "유저가 로그인하고, 대시보드에서 데이터를 확인하고, 설정을 변경하는" 전체 흐름을 실제 브라우저에서 돌립니다. 배포 전 치명적 버그를 잡는 마지막 방어선입니다.

왜 Playwright인가?

주요 도구 비교

항목PlaywrightCypressSelenium
멀티 브라우저Chromium, Firefox, WebKitChromium 중심전체
언어TS/JS, Python, Java, C#JS/TS만다양
자동 대기내장 (액션 전 자동 대기)내장수동 구현
병렬 실행내장 (워커 기반)유료 (Dashboard)별도 설정
코드 생성codegen 내장없음없음
iframe/탭 지원네이티브제한적가능
속도빠름보통느림

Playwright의 핵심 장점:

  • 자동 대기(Auto-wait): 요소가 보이고, 활성화되고, 안정될 때까지 자동으로 기다림
  • 멀티 브라우저: Chromium, Firefox, WebKit 한 번에 테스트
  • 코드 생성기: 브라우저에서 클릭하면 테스트 코드가 생성됨
  • Trace Viewer: 실패한 테스트를 스크린샷 + DOM 스냅샷으로 디버깅

프로젝트 셋업

설치

npm init playwright@latest

설치 중 물어보는 선택:

✔ Where to put your end-to-end tests? → tests
✔ Add a GitHub Actions workflow? → true
✔ Install Playwright browsers? → true

디렉토리 구조

project/
├── tests/
│   ├── auth.spec.ts
│   ├── dashboard.spec.ts
│   └── fixtures/
│       └── auth.fixture.ts
├── playwright.config.ts
└── .github/
    └── workflows/
        └── playwright.yml

playwright.config.ts

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

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

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

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

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

핵심 설정:

  • fullyParallel: 테스트 파일 내부도 병렬 실행
  • forbidOnly: CI에서 .only 실수 방지
  • retries: CI에서 flaky 테스트 재시도
  • trace: 'on-first-retry': 실패 시에만 트레이스 수집 (성능 최적화)
  • webServer: 테스트 전 dev 서버 자동 실행

첫 번째 테스트 작성

로그인 → 대시보드 시나리오

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

test.describe('인증 흐름', () => {
  test('로그인 후 대시보드로 이동', async ({ page }) => {
    await page.goto('/login');

    // 폼 입력
    await page.getByLabel('이메일').fill('user@example.com');
    await page.getByLabel('비밀번호').fill('password123');
    await page.getByRole('button', { name: '로그인' }).click();

    // 대시보드 도착 확인
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible();
  });

  test('잘못된 비밀번호 에러 표시', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('이메일').fill('user@example.com');
    await page.getByLabel('비밀번호').fill('wrong');
    await page.getByRole('button', { name: '로그인' }).click();

    await expect(page.getByText('이메일 또는 비밀번호가 올바르지 않습니다')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });
});

실행

# 모든 테스트 실행
npx playwright test

# 특정 파일만
npx playwright test auth.spec.ts

# UI 모드 (브라우저에서 실시간 확인)
npx playwright test --ui

# 특정 브라우저만
npx playwright test --project=chromium

핵심 API 패턴

Locator: 요소 찾기

Playwright는 CSS 셀렉터 대신 시맨틱 Locator를 권장합니다.

// 추천: 역할 기반
page.getByRole('button', { name: '저장' })
page.getByRole('heading', { name: '설정' })
page.getByRole('link', { name: '홈으로' })

// 추천: 레이블/텍스트
page.getByLabel('이메일')
page.getByPlaceholder('검색어를 입력하세요')
page.getByText('환영합니다')

// 추천: 테스트 ID (시맨틱이 어려울 때)
page.getByTestId('submit-button')

// 비추천: CSS 셀렉터 (깨지기 쉬움)
page.locator('.btn-primary')
page.locator('#email-input')

Assertions: 검증

// 가시성
await expect(page.getByText('성공')).toBeVisible();
await expect(page.getByText('로딩')).toBeHidden();

// URL
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/\/users\/\d+/);

// 텍스트 내용
await expect(page.getByRole('heading')).toHaveText('대시보드');
await expect(page.getByTestId('count')).toContainText('42');

// 입력값
await expect(page.getByLabel('이름')).toHaveValue('홍길동');

// 개수
await expect(page.getByRole('listitem')).toHaveCount(5);

폼 입력

// 텍스트 입력
await page.getByLabel('이름').fill('홍길동');

// 드롭다운
await page.getByLabel('언어').selectOption('ko');

// 체크박스
await page.getByLabel('약관 동의').check();

// 파일 업로드
await page.getByLabel('프로필 사진').setInputFiles('avatar.png');

네트워크 인터셉트

// API 응답 모킹
await page.route('**/api/users', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: '홍길동' },
      { id: 2, name: '김철수' },
    ]),
  });
});

// API 요청 대기
const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: '새로고침' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);

Page Object Model 적용

테스트가 많아지면 셀렉터가 여기저기 흩어져 유지보수가 힘들어집니다. Page Object Model(POM)로 페이지별 상호작용을 캡슐화합니다.

페이지 객체

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

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(private page: Page) {
    this.emailInput = page.getByLabel('이메일');
    this.passwordInput = page.getByLabel('비밀번호');
    this.submitButton = page.getByRole('button', { name: '로그인' });
    this.errorMessage = page.getByRole('alert');
  }

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

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}
// tests/pages/dashboard.page.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class DashboardPage {
  readonly heading: Locator;
  readonly userMenu: Locator;

  constructor(private page: Page) {
    this.heading = page.getByRole('heading', { name: '대시보드' });
    this.userMenu = page.getByTestId('user-menu');
  }

  async expectLoaded() {
    await expect(this.heading).toBeVisible();
    await expect(this.page).toHaveURL('/dashboard');
  }
}

POM 사용

// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
import { DashboardPage } from './pages/dashboard.page';

test('로그인 성공', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await dashboardPage.expectLoaded();
});

UI가 변경되면 Page Object만 수정하면 됩니다. 테스트 파일은 손대지 않아도 됩니다.

CI/CD 연동

GitHub Actions 설정

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright Browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

핵심 포인트:

  • --with-deps: 브라우저 실행에 필요한 OS 패키지도 함께 설치
  • if: ${{ !cancelled() }}: 테스트 실패해도 리포트는 업로드
  • retention-days: 30: 아티팩트 보관 기간 설정

테스트 결과 확인

실패 시 GitHub Actions → Artifacts에서 playwright-report를 다운받아 열면 됩니다.

npx playwright show-report playwright-report

GitHub Actions 설정 자세한 내용은 GitHub Actions CI/CD 설정법을 참고하세요.

디버깅 팁

1. Trace Viewer

테스트 실패 원인을 추적하는 가장 강력한 도구입니다. 각 액션의 스크린샷, DOM 스냅샷, 네트워크 로그를 시간순으로 볼 수 있습니다.

# 트레이스 활성화하고 실행
npx playwright test --trace on

# 트레이스 파일 열기
npx playwright show-trace test-results/trace.zip

2. UI 모드

실시간으로 테스트 실행을 관찰하면서 디버깅합니다.

npx playwright test --ui

시간 여행 디버깅이 가능합니다. 각 스텝을 클릭하면 그 시점의 DOM 상태를 볼 수 있습니다.

3. Headed 모드

브라우저 창을 띄워서 테스트를 실행합니다.

npx playwright test --headed

특정 시점에서 멈추고 싶으면 page.pause()를 사용합니다.

test('디버깅용', async ({ page }) => {
  await page.goto('/login');
  await page.pause(); // 여기서 브라우저가 멈추고, Inspector가 열림
  await page.getByLabel('이메일').fill('test@example.com');
});

4. VS Code 확장

Playwright Test for VS Code 확장을 설치하면:

  • 에디터에서 바로 테스트 실행/디버그
  • 클릭으로 Locator 생성
  • Watch 모드로 코드 수정 시 자동 재실행

5. Codegen

브라우저에서 직접 조작하면 테스트 코드를 자동 생성합니다.

npx playwright codegen http://localhost:3000

생성된 코드를 복사해서 수정하면 빠르게 테스트를 만들 수 있습니다. 초보자에게 특히 유용합니다.

실전 꿀팁

1. 플레이크 테스트 줄이기

flaky 테스트의 주요 원인과 해결:

// Bad: 고정 대기
await page.waitForTimeout(3000);

// Good: 조건부 대기
await expect(page.getByText('완료')).toBeVisible();
// Bad: 순서에 의존하는 테스트
test('항목 삭제', async ({ page }) => {
  // '항목 추가' 테스트가 먼저 실행되어야 함
  await page.getByRole('button', { name: '삭제' }).click();
});

// Good: 독립적인 테스트
test('항목 삭제', async ({ page }) => {
  // 테스트 자체에서 데이터 생성
  await page.goto('/items');
  await page.getByRole('button', { name: '추가' }).click();
  await page.getByLabel('이름').fill('테스트 항목');
  await page.getByRole('button', { name: '저장' }).click();
  // 그 다음 삭제
  await page.getByRole('button', { name: '삭제' }).click();
  await expect(page.getByText('테스트 항목')).toBeHidden();
});

2. 인증 상태 재사용

매 테스트마다 로그인하면 느립니다. 인증 상태를 저장해서 재사용합니다.

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

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

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('이메일').fill('user@example.com');
  await page.getByLabel('비밀번호').fill('password123');
  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: 'tests/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

3. 병렬 실행 최적화

// playwright.config.ts
export default defineConfig({
  fullyParallel: true,
  workers: process.env.CI ? 2 : undefined, // CI에서 워커 수 조절
});

테스트 간 데이터 충돌이 있으면 test.describe.serial()로 순차 실행합니다.

test.describe.serial('결제 흐름', () => {
  test('장바구니 추가', async ({ page }) => { /* ... */ });
  test('결제 진행', async ({ page }) => { /* ... */ });
  test('결제 확인', async ({ page }) => { /* ... */ });
});

4. 시각 회귀 테스트

스크린샷을 비교해서 UI가 의도치 않게 바뀌지 않았는지 확인합니다.

test('대시보드 레이아웃 스냅샷', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixelRatio: 0.01,
  });
});

처음 실행하면 기준 스크린샷이 생성되고, 이후 실행에서 비교합니다. 차이가 있으면 테스트가 실패하고 diff 이미지를 보여줍니다.

# 기준 스크린샷 업데이트
npx playwright test --update-snapshots

5. 테스트 태깅

test('빠른 테스트 @smoke', async ({ page }) => { /* ... */ });
test('느린 테스트 @regression', async ({ page }) => { /* ... */ });
# smoke 테스트만 실행
npx playwright test --grep @smoke

# regression 제외
npx playwright test --grep-invert @regression

PR에서는 @smoke만, 메인 브랜치에서는 전체 실행하는 전략이 효과적입니다.

TypeScript 설정과 패턴에 대해서는 TypeScript 실전 패턴 가이드를 참고하세요.