
Playwright E2E 테스트: 브라우저 자동화로 버그 잡기
유닛 테스트가 전부 초록불인데 QA가 클릭 몇 번 만에 버그를 찾아낸다. E2E 테스트가 없어서다. Playwright로 실제 브라우저에서 사용자 시나리오를 자동화하는 법을 처음부터 정리했다.

유닛 테스트가 전부 초록불인데 QA가 클릭 몇 번 만에 버그를 찾아낸다. E2E 테스트가 없어서다. Playwright로 실제 브라우저에서 사용자 시나리오를 자동화하는 법을 처음부터 정리했다.
코드를 먼저 짜고 테스트하는 게 아닙니다. 테스트를 먼저 짜고, 그걸 통과하기 위해 코딩하는 것. 순서를 뒤집으면 버그가 사라집니다.

코드 푸시하면 로봇이 테스트하고(CI), 로봇이 배포합니다(CD). '내 컴퓨터에서는 잘 됐는데'라는 변명은 이제 안 통합니다. 자동화 파이프라인으로 하루 100번 배포하기.

백엔드 API가 준비되지 않아 프론트엔드 개발이 멈추는 문제를 MSW(Mock Service Worker)로 해결했다. 네트워크 레벨 API 모킹의 원리와 실전 패턴.

유닛 테스트가 다 통과해도 배포하면 에러가 나는 이유는 뭘까요? 사용자가 실제로 사용하는 흐름 그대로를 검증하는 E2E(End-to-End) 테스트가 필요합니다. Cypress와 Playwright의 장단점 비교, 깨지기 쉬운(Flaky) 테스트를 방지하는 전략, 그리고 테스트 피라미드 속 E2E의 역할을 정리합니다.

유닛 테스트 커버리지 85%. 통합 테스트도 돌아가고 있었다. 근데 QA 담당자가 로그인 → 프로필 수정 → 저장 딱 세 번 클릭했더니 UI가 깨졌다.
왜? 각 컴포넌트는 개별적으로 잘 동작하는데, 연결 고리에서 문제가 생긴 거다. 폼 상태와 API 응답이 race condition을 일으킨 케이스였다. 유닛 테스트가 절대 잡을 수 없는 종류의 버그.
그날 이후 E2E 테스트를 도입했다. 그 중에서도 Playwright를 선택했는데, 지금은 후회가 없다.
테스트 피라미드를 먼저 이해하면 E2E의 역할이 명확해진다.
/\
/E2E\ ← 느리고 비싸지만, 실제 사용자 경험 검증
/------\
/ 통합 \ ← 모듈 간 연동 테스트
/----------\
/ 유닛 \ ← 빠르고 저렴, 개별 함수/컴포넌트
/--------------\
유닛 테스트: "이 함수가 올바른 값을 반환하나?" 통합 테스트: "이 컴포넌트들이 함께 올바르게 동작하나?" E2E 테스트: "사용자가 실제로 이 기능을 완료할 수 있나?"
E2E는 제일 느리고 유지보수 비용도 높다. 그래서 핵심 사용자 시나리오에만 써야 한다. 로그인, 결제, 핵심 CRUD 등.
둘 다 좋은 도구다. 선택 기준을 정리하면:
| Playwright | Cypress | |
|---|---|---|
| 지원 브라우저 | Chromium, Firefox, WebKit | Chromium 위주 |
| 병렬 실행 | 기본 지원 | 유료 플랜 필요 |
| 언어 지원 | JS, TS, Python, Java, C# | JS/TS |
| iframe 지원 | 우수 | 제한적 |
| 네트워크 인터셉트 | 강력 | 좋음 |
| 러닝 커브 | 약간 가파름 | 완만함 |
| 디버깅 UI | Trace Viewer | Time Travel |
Playwright를 선택하는 경우: 멀티 브라우저 지원, Python/Java 팀, 복잡한 네트워크 모킹 Cypress를 선택하는 경우: JS 전용, 빠른 온보딩, 시각적 디버깅 선호
이 글은 Playwright 기준으로 쓴다.
npm init playwright@latest
설치 시 대화형으로 설정:
tests/ (또는 e2e/)설치 후 생성되는 파일들:
playwright.config.ts
tests/
example.spec.ts
tests-examples/
demo-todo-app.spec.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true, // 테스트 병렬 실행
forbidOnly: !!process.env.CI, // CI에서 test.only 금지
retries: process.env.CI ? 2 : 0, // CI에서만 재시도
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // 첫 재시도 시 트레이스 캡처
screenshot: 'only-on-failure',
video: 'retain-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'] },
},
],
// 테스트 실행 전 dev 서버 시작
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
// tests/homepage.spec.ts
import { test, expect } from '@playwright/test'
test('홈페이지가 올바르게 로드된다', async ({ page }) => {
await page.goto('/')
// 타이틀 확인
await expect(page).toHaveTitle(/My Blog/)
// 네비게이션 존재 확인
await expect(page.getByRole('navigation')).toBeVisible()
// 최신 포스트 목록 확인
await expect(page.getByRole('article').first()).toBeVisible()
})
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.getByText('환영합니다')).toBeVisible()
})
Playwright의 로케이터는 웹 접근성 기반이 우선이다.
// 권장: 역할(role) 기반
page.getByRole('button', { name: '제출' })
page.getByRole('heading', { name: '대시보드' })
page.getByRole('textbox', { name: '검색' })
// 권장: 레이블 기반
page.getByLabel('이메일 주소')
page.getByLabel('비밀번호')
// 권장: 플레이스홀더 기반
page.getByPlaceholder('이메일을 입력하세요')
// 권장: 텍스트 기반
page.getByText('로그인')
// 권장: 테스트 ID (data-testid 속성)
page.getByTestId('submit-button')
// 비권장: CSS 셀렉터 (구현 변경에 취약)
page.locator('.btn-primary')
// 비권장: XPath
page.locator('//button[@class="submit"]')
로케이터 우선순위: role > label > placeholder > text > testId > CSS
10개 테스트가 모두 같은 로그인 로직을 직접 쓰면, 로그인 UI가 바뀔 때 10개를 다 수정해야 한다. Page Object Pattern으로 해결한다.
// tests/pages/LoginPage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.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()
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message)
}
}
// tests/pages/DashboardPage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class DashboardPage {
readonly page: Page
readonly welcomeMessage: Locator
readonly profileLink: Locator
constructor(page: Page) {
this.page = page
this.welcomeMessage = page.getByRole('heading', { level: 1 })
this.profileLink = page.getByRole('link', { name: '프로필' })
}
async expectLoggedIn(userName: string) {
await expect(this.page).toHaveURL('/dashboard')
await expect(this.welcomeMessage).toContainText(userName)
}
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
test.describe('인증 플로우', () => {
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.expectLoggedIn('김개발')
})
test('잘못된 비밀번호로 로그인 실패', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'wrongpassword')
await loginPage.expectError('비밀번호가 올바르지 않습니다')
})
test('이메일 미입력 시 유효성 검사 에러', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('', 'password123')
await loginPage.expectError('이메일을 입력해주세요')
})
})
E2E 테스트의 가장 큰 도전 중 하나가 비동기 처리다. Playwright는 기본적으로 auto-waiting을 지원한다.
// 이렇게 해도 됨 — Playwright가 알아서 기다림
await page.getByRole('button').click()
// 내부적으로: 요소가 보이고, 클릭 가능해질 때까지 기다림
// 특정 조건을 기다릴 때
await expect(page.getByText('저장 완료')).toBeVisible()
await expect(page.getByText('로딩 중')).not.toBeVisible()
// URL 변경 기다리기
await page.waitForURL('/dashboard')
// 네트워크 요청 기다리기
const responsePromise = page.waitForResponse('/api/users')
await page.getByRole('button', { name: '불러오기' }).click()
const response = await responsePromise
expect(response.status()).toBe(200)
// 특정 상태까지 기다리기
await page.waitForLoadState('networkidle')
test('API 에러 시 에러 메시지 표시', async ({ page }) => {
// API 요청을 가로채서 에러 응답 반환
await page.route('/api/users/*', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
})
})
await page.goto('/users/1')
await expect(page.getByRole('alert')).toContainText('데이터를 불러올 수 없습니다')
})
test('느린 네트워크 시뮬레이션', async ({ page }) => {
// 응답을 2초 지연
await page.route('/api/data', async route => {
await new Promise(resolve => setTimeout(resolve, 2000))
await route.continue()
})
await page.goto('/data-page')
// 로딩 스피너가 표시되는지 확인
await expect(page.getByTestId('loading-spinner')).toBeVisible()
await expect(page.getByTestId('loading-spinner')).not.toBeVisible()
})
모든 테스트마다 로그인하면 느리다. Playwright의 storageState를 쓰면 로그인 상태를 저장하고 재사용할 수 있다.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
import path from 'path'
const authFile = path.join(__dirname, '../.playwright/user.json')
setup('인증 상태 저장', 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')
// 쿠키 + localStorage 저장
await page.context().storageState({ path: authFile })
})
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'authenticated',
use: {
storageState: '.playwright/user.json',
},
dependencies: ['setup'],
},
{
name: 'unauthenticated',
// storageState 없음
},
],
})
// tests/profile.spec.ts — 이미 로그인된 상태로 시작
import { test, expect } from '@playwright/test'
test('프로필 업데이트', async ({ page }) => {
// 로그인 불필요 — storageState가 적용됨
await page.goto('/profile')
await page.getByLabel('이름').fill('박개발')
await page.getByRole('button', { name: '저장' }).click()
await expect(page.getByText('프로필이 업데이트됐습니다')).toBeVisible()
})
UI가 의도치 않게 바뀌는 걸 스크린샷으로 감지한다.
test('대시보드 비주얼 회귀 테스트', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForLoadState('networkidle')
// 전체 페이지 스크린샷
await expect(page).toHaveScreenshot('dashboard-full.png')
// 특정 요소만
await expect(page.getByRole('main')).toHaveScreenshot('dashboard-main.png')
})
처음 실행하면 기준 스크린샷이 생성된다. 이후 실행에서 차이가 나면 실패한다.
픽셀 허용 오차를 설정할 수 있다:
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100, // 픽셀 차이 허용
maxDiffPixelRatio: 0.01, // 1% 까지 허용
threshold: 0.2, // 색상 차이 허용
})
테스트가 실패하면 trace 파일이 저장된다. Playwright의 Trace Viewer로 무슨 일이 있었는지 영상처럼 확인할 수 있다.
# trace 열기
npx playwright show-trace test-results/failed-test/trace.zip
Trace Viewer에서 볼 수 있는 것:
개발 중 디버깅할 때는 --debug 플래그:
npx playwright test --debug tests/login.spec.ts
Playwright Inspector가 열리면서 스텝별로 실행할 수 있다.
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 의존성 설치
run: npm ci
- name: Playwright 브라우저 설치
run: npx playwright install --with-deps
- name: E2E 테스트 실행
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_URL }}
- name: 테스트 리포트 업로드
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
// 피해야 할 것
page.locator('[data-testid="submit-btn"]')
// 더 좋은 것
page.getByRole('button', { name: '제출' })
역할(role)과 레이블 기반 로케이터가 접근성도 테스트하는 효과가 있다.
2. 절대 하드코딩된 sleep 쓰지 마// 절대 하지 말 것
await page.waitForTimeout(3000) // 느리고 불안정
// 이렇게
await expect(page.getByText('저장 완료')).toBeVisible()
await page.waitForURL('/success')
3. 테스트 격리 유지
각 테스트는 독립적이어야 한다. 이전 테스트의 상태에 의존하면 실행 순서에 따라 결과가 달라진다.
test.beforeEach(async ({ page }) => {
// 각 테스트 전 깨끗한 상태로
await page.goto('/')
})
// 또는 API로 테스트 데이터 초기화
test.beforeEach(async ({ request }) => {
await request.post('/api/test/reset')
})
4. 핵심 사용자 플로우에 집중
E2E 테스트는 비싸다. 모든 것을 테스트하려 하지 마라:
사소한 UI 인터랙션은 유닛/컴포넌트 테스트로 처리한다.
E2E 테스트는 느리고 유지보수 비용이 있다. 하지만 QA가 3번 클릭으로 찾는 버그를 코드 리뷰 단계에서 잡아준다. 프로덕션 사고를 배포 전에 막아준다.
보험과 같다. 평소엔 비용처럼 느껴지지만, 필요할 때 그 가치가 빛난다. 핵심 사용자 시나리오 5-10개에 Playwright E2E를 붙여두면, 배포할 때 자신감이 달라진다.