Playwright로 E2E 테스트 자동화 완벽 가이드
프론트엔드 개발자를 위한 Playwright E2E 테스트 실전 가이드. 설치부터 CI/CD 연동까지 단계별로 정리했습니다.
E2E 테스트가 필요한 이유
유닛 테스트는 함수 단위, 통합 테스트는 모듈 간 연결을 검증합니다. 하지만 실제 유저가 브라우저에서 겪는 문제는 이것만으로 잡을 수 없습니다.
| 테스트 유형 | 검증 범위 | 속도 | 신뢰도 |
|---|---|---|---|
| 유닛 테스트 | 개별 함수/컴포넌트 | 빠름 | 낮음 (UI 버그 못 잡음) |
| 통합 테스트 | 모듈 간 연결 | 보통 | 보통 |
| E2E 테스트 | 유저 시나리오 전체 | 느림 | 높음 |
E2E 테스트는 "유저가 로그인하고, 대시보드에서 데이터를 확인하고, 설정을 변경하는" 전체 흐름을 실제 브라우저에서 돌립니다. 배포 전 치명적 버그를 잡는 마지막 방어선입니다.
왜 Playwright인가?
주요 도구 비교
| 항목 | Playwright | Cypress | Selenium |
|---|---|---|---|
| 멀티 브라우저 | Chromium, Firefox, WebKit | Chromium 중심 | 전체 |
| 언어 | 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 실전 패턴 가이드를 참고하세요.