이 글은 누구를 위한 것인가
- Playwright로 E2E 테스트를 안정적으로 작성하려는 팀
- Cypress에서 Playwright로 전환을 검토하는 개발자
- 느리거나 불안정한(flaky) E2E 테스트를 개선하려는 팀
들어가며
E2E 테스트는 실사용자의 워크플로우를 자동화한다. Playwright는 Chromium/Firefox/WebKit을 동시에 지원하고, 자동 대기(auto-wait)로 flaky 테스트를 줄이며, 병렬 실행으로 속도를 높인다.
이 글은 bluefoxdev.kr의 Playwright E2E 가이드 를 참고하여 작성했습니다.
1. Playwright 핵심 개념
[Playwright vs Cypress]
Playwright:
- Chromium/Firefox/WebKit 지원
- 멀티 탭/팝업/iframe 지원
- 병렬 실행 (기본)
- 네트워크 모킹 강력
- TypeScript 네이티브 지원
- 자동 대기: 요소가 visible/enabled 될 때까지
Cypress:
- Chromium 기반 브라우저만 (일부 Firefox)
- 단일 탭 원칙
- 병렬: Cypress Cloud 유료
- Dashboard 시각화 강력
- 타임머신 디버깅
[로케이터 우선순위 (Playwright 권장)]
1. getByRole() → ARIA 역할 기반 (최우선)
2. getByLabel() → 폼 레이블 기반
3. getByPlaceholder() → placeholder 기반
4. getByText() → 텍스트 내용 기반
5. getByTestId() → data-testid 기반
✗ CSS selector, XPath → 구현 세부사항에 의존, 취약
[Auto-wait 동작]
page.click(locator):
1. 요소 찾을 때까지 대기 (actionTimeout: 30s)
2. visible 확인
3. stable 확인 (애니메이션 완료)
4. enabled 확인
5. 포커스
6. 클릭
→ 수동 sleep 불필요
2. Playwright 구현 패턴
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
['html'],
['junit', { outputFile: 'results.xml' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // 실패 시 trace 저장
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
// 인증 설정 프로젝트
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json', // 로그인 상태 재사용
},
dependencies: ['setup'],
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
// e2e/auth.setup.ts - 로그인 상태 저장
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '.auth/user.json');
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 expect(page).toHaveURL('/dashboard');
// 인증 상태 저장 (쿠키 + localStorage)
await page.context().storageState({ path: authFile });
});
// Page Object Model
// e2e/pages/checkout.page.ts
import { Page, Locator, expect } from '@playwright/test';
export class CheckoutPage {
readonly page: Page;
readonly cartItems: Locator;
readonly totalPrice: Locator;
readonly placeOrderButton: Locator;
constructor(page: Page) {
this.page = page;
this.cartItems = page.getByRole('listitem').filter({ has: page.getByTestId('cart-item') });
this.totalPrice = page.getByTestId('total-price');
this.placeOrderButton = page.getByRole('button', { name: '주문하기' });
}
async goto() {
await this.page.goto('/checkout');
}
async fillShippingAddress(address: {
name: string;
phone: string;
address: string;
}) {
await this.page.getByLabel('수령인').fill(address.name);
await this.page.getByLabel('연락처').fill(address.phone);
await this.page.getByLabel('배송 주소').fill(address.address);
}
async placeOrder() {
await this.placeOrderButton.click();
await expect(this.page).toHaveURL(/\/orders\/\d+/);
}
async getTotal(): Promise<number> {
const text = await this.totalPrice.textContent();
return parseInt(text!.replace(/[^0-9]/g, ''));
}
}
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { CheckoutPage } from './pages/checkout.page';
test.describe('결제 플로우', () => {
test('상품 주문 성공', async ({ page }) => {
const checkout = new CheckoutPage(page);
// API 모킹 (외부 결제 API 차단)
await page.route('**/api/payments/process', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, orderId: 'ORDER-123' }),
});
});
// 장바구니에 상품 추가
await page.goto('/products/laptop-1');
await page.getByRole('button', { name: '장바구니 담기' }).click();
await checkout.goto();
await checkout.fillShippingAddress({
name: '홍길동',
phone: '010-1234-5678',
address: '서울시 강남구 테헤란로 123',
});
const total = await checkout.getTotal();
expect(total).toBeGreaterThan(0);
await checkout.placeOrder();
// 주문 완료 확인
await expect(page.getByRole('heading', { name: '주문 완료' })).toBeVisible();
});
test('재고 없는 상품 주문 실패', async ({ page }) => {
// 재고 없음 API 응답 모킹
await page.route('**/api/cart/add', async (route) => {
await route.fulfill({
status: 409,
body: JSON.stringify({ error: 'OUT_OF_STOCK' }),
});
});
await page.goto('/products/sold-out-item');
await page.getByRole('button', { name: '장바구니 담기' }).click();
await expect(page.getByRole('alert')).toContainText('품절');
});
// 시각적 회귀 테스트
test('주문 완료 페이지 스냅샷', async ({ page }) => {
await page.goto('/orders/ORDER-123');
// 동적 데이터 마스킹
await page.getByTestId('order-date').evaluate(el => {
el.textContent = '2026-04-24'; // 고정값으로 교체
});
await expect(page).toHaveScreenshot('order-complete.png', {
maxDiffPixelRatio: 0.02, // 2% 픽셀 차이 허용
});
});
});
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --project=chromium
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
마무리
Playwright의 핵심은 getByRole()같은 접근성 기반 로케이터로 구현 세부사항에 의존하지 않는 테스트를 작성하는 것이다. 인증 상태를 storageState로 저장하면 매 테스트마다 로그인 과정을 반복하지 않아 속도가 크게 향상된다. page.route()로 외부 API를 모킹하면 네트워크 상태에 관계없이 안정적인 테스트를 만들 수 있다.