Playwright E2E 테스트 완벽 가이드 2026 — 설치부터 CI/CD 연동까지
프론트엔드 개발자를 위한 Playwright E2E 테스트 실전 가이드. 설치, 셀렉터 전략, Page Object Model, 인증 처리, GitHub Actions 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 실전 패턴 가이드를 참고하세요.
관련 글: GitHub Actions CI/CD 설정법 · TypeScript 실전 패턴 가이드 · Docker 입문 완전 가이드
관련 글
React vs Next.js 선택 가이드 2026 — 프로젝트에 맞는 프레임워크 고르기
React와 Next.js의 차이점, 장단점, 선택 기준을 2026년 기준으로 비교합니다. SPA vs SSR/SSG, 라우팅, 성능, SEO, 배포 환경까지 실제 프로젝트 상황별로 어떤 것을 선택해야 할지 정리했습니다.
Tailwind CSS v4 마이그레이션 완전 가이드 2026
Tailwind CSS v3에서 v4로 마이그레이션하는 방법을 단계별로 정리했습니다. 설정 파일 변경, 테마 마이그레이션, 플러그인 호환성, 자주 발생하는 오류 해결까지 포함합니다.
Vercel 무료 배포 실전 가이드 2026 — Hobby 플랜으로 사이드 프로젝트 운영하기
Vercel Hobby 플랜으로 Next.js 사이드 프로젝트를 무료 배포하는 방법을 단계별로 정리했습니다. 커스텀 도메인, 환경 변수, 성능 최적화, 무료 한도 관리까지 포함합니다.