반응형
Playwright — 현대 E2E 테스팅의 새 표준
프론트엔드 개발 · 테스팅 시리즈 #5
Microsoft가 만든 Playwright는 Chromium, Firefox, WebKit을 단일 API로 제어하는 E2E 테스트 프레임워크입니다. 자동 대기, 네트워크 인터셉트, 트레이스 뷰어까지 — Cypress를 넘어서는 현대 E2E 테스팅의 완전한 가이드입니다.
목차
- Playwright란 무엇인가
- 설치 및 기본 설정
- 테스트 작성 기초
- 로케이터(Locator) — 요소 선택의 새 방식
- 자동 대기(Auto-waiting)
- 네트워크 인터셉트와 API 모킹
- Page Object Model 패턴
- 트레이스 뷰어와 디버깅
- CI/CD 통합
- 결론 — Playwright vs Cypress
1. Playwright란 무엇인가
Playwright는 2020년 Microsoft가 출시한 E2E(End-to-End) 테스트 프레임워크입니다. 원래 Puppeteer 팀이 Microsoft로 이직해 만든 차세대 도구입니다.
Playwright의 핵심 장점:
1. 크로스 브라우저: Chromium, Firefox, WebKit (Safari 엔진) 모두 지원
2. 자동 대기: 요소가 준비될 때까지 자동으로 기다림
3. 격리된 테스트: 각 테스트가 새 브라우저 컨텍스트에서 실행
4. 네트워크 제어: 요청 가로채기 및 응답 모킹
5. 병렬 실행: 기본으로 병렬 테스트 실행
6. 다중 언어: JavaScript, TypeScript, Python, Java, C#Playwright vs Cypress vs Puppeteer
| 항목 | Playwright | Cypress | Puppeteer |
|---|---|---|---|
| 브라우저 | Chrome, Firefox, Safari | Chrome, Firefox (Edge 실험적) | Chrome, Firefox |
| 다중 탭 | ✅ | ❌ (v12 이후 실험적) | ✅ |
| iframe | ✅ | 제한적 | ✅ |
| 파일 업로드/다운로드 | ✅ | ✅ | ✅ |
| 네트워크 제어 | ✅ | ✅ | ✅ |
| 자동 대기 | ✅ | ✅ | ❌ |
| 트레이스 뷰어 | ✅ | ❌ | ❌ |
| Component 테스트 | ✅ (실험적) | ✅ | ❌ |
| 언어 지원 | 다중 | JS/TS만 | JS/TS만 |
2. 설치 및 기본 설정
# 설치 (브라우저도 함께 다운로드)
npm init playwright@latest
# 또는 기존 프로젝트에 추가
npm install --save-dev @playwright/test
npx playwright install # 브라우저 바이너리 설치
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// 테스트 파일 경로
testDir: './e2e',
// 전체 실패 허용 임계값
maxFailures: 10,
// 각 테스트 타임아웃
timeout: 30_000,
// expect 타임아웃
expect: { timeout: 5_000 },
// 병렬 실행
fullyParallel: true,
workers: process.env.CI ? 1 : undefined,
// 재시도 (CI에서만)
retries: process.env.CI ? 2 : 0,
// 리포터
reporter: [
['html'], // HTML 리포트
['junit', { outputFile: 'results.xml' }], // CI용
['list'], // 콘솔 출력
],
// 기본 URL (page.goto('/') 가능하게)
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // 실패 시 트레이스
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
},
// 다중 브라우저/기기 설정
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'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
// 테스트 전 개발 서버 자동 시작
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
3. 테스트 작성 기초
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('로그인 플로우', () => {
// 각 테스트 전 실행
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('올바른 자격증명으로 로그인', async ({ page }) => {
// 폼 입력
await page.getByLabel('이메일').fill('hong@example.com');
await page.getByLabel('비밀번호').fill('password123');
await page.getByRole('button', { name: '로그인' }).click();
// 리다이렉트 확인
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('홍길동님, 환영합니다!')).toBeVisible();
});
test('잘못된 자격증명으로 에러 표시', async ({ page }) => {
await page.getByLabel('이메일').fill('wrong@example.com');
await page.getByLabel('비밀번호').fill('wrongpass');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByRole('alert')).toContainText('이메일 또는 비밀번호가 틀렸습니다');
await expect(page).toHaveURL('/login');
});
test('빈 폼 제출 시 유효성 에러', async ({ page }) => {
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByText('이메일을 입력하세요')).toBeVisible();
await expect(page.getByText('비밀번호를 입력하세요')).toBeVisible();
});
});
여러 페이지와 탭 테스트
test('새 탭에서 링크 열기', async ({ page, context }) => {
await page.goto('/');
// 새 탭이 열릴 때 캡처
const pagePromise = context.waitForEvent('page');
await page.getByRole('link', { name: '새 탭에서 열기' }).click();
const newPage = await pagePromise;
await newPage.waitForLoadState();
expect(newPage.url()).toContain('example.com');
await newPage.close();
});
4. 로케이터(Locator) — 요소 선택의 새 방식
Playwright의 Locator는 지연 평가(lazy evaluation)되는 요소 참조입니다. 매번 DOM을 다시 쿼리하므로 동적 콘텐츠에 강합니다.
권장 로케이터 (접근성 우선)
// Role 기반 (최우선 권장)
page.getByRole('button', { name: '제출' })
page.getByRole('textbox', { name: '검색' })
page.getByRole('checkbox', { name: '동의' })
page.getByRole('link', { name: '홈' })
page.getByRole('heading', { name: '대시보드', level: 1 })
page.getByRole('combobox')
page.getByRole('dialog')
page.getByRole('listitem')
// 레이블 기반
page.getByLabel('이메일 주소')
// 플레이스홀더
page.getByPlaceholder('검색어 입력...')
// 텍스트
page.getByText('안녕하세요')
page.getByText(/안녕/)
// Alt 텍스트 (이미지)
page.getByAltText('프로필 사진')
// 타이틀
page.getByTitle('정보')
// 테스트 ID (최후 수단)
page.getByTestId('user-card')
로케이터 체이닝과 필터링
// 특정 영역 내에서 탐색
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: '확인' }).click();
// 필터링
const items = page.getByRole('listitem');
await items.filter({ hasText: '완료' }).count();
// nth() — n번째 요소
await page.getByRole('button').nth(2).click();
// 첫/마지막
await page.getByRole('listitem').first().click();
await page.getByRole('listitem').last().click();
5. 자동 대기(Auto-waiting)
Playwright의 가장 강력한 기능입니다. 요소가 준비될 때까지 자동으로 기다립니다.
// 이 코드는 자동으로 요소가:
// 1. DOM에 존재하고
// 2. 보이고 (visible)
// 3. 안정적이고 (not animating)
// 4. 활성화되어 있고 (not disabled)
// 5. 이벤트를 받을 수 있을 때 (not covered)
// 까지 기다림
await page.getByRole('button', { name: '제출' }).click();
// expect도 자동 대기
await expect(page.getByText('성공!')).toBeVisible();
// 기본 5초 동안 조건이 충족될 때까지 반복 확인
명시적 대기
// 특정 URL로 이동될 때까지 대기
await page.waitForURL('/dashboard');
await page.waitForURL(/dashboard/);
// 특정 이벤트 대기
await page.waitForLoadState('networkidle'); // 네트워크 조용해질 때까지
await page.waitForLoadState('domcontentloaded');
// 특정 응답 대기
await page.waitForResponse('/api/users');
await page.waitForResponse(resp => resp.url().includes('/api/') && resp.status() === 200);
// 커스텀 조건
await page.waitForFunction(() => document.title === '로딩 완료');
await page.waitForSelector('.spinner', { state: 'hidden' });
6. 네트워크 인터셉트와 API 모킹
test('API 응답 모킹', async ({ page }) => {
// 특정 URL 요청 가로채기
await page.route('/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: '홍길동' },
{ id: 2, name: '김철수' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('홍길동')).toBeVisible();
await expect(page.getByText('김철수')).toBeVisible();
});
test('API 에러 시뮬레이션', async ({ page }) => {
await page.route('/api/users', route => {
route.fulfill({ status: 500, body: 'Internal Server Error' });
});
await page.goto('/users');
await expect(page.getByText('서버 오류가 발생했습니다')).toBeVisible();
});
test('네트워크 요청 지연 시뮬레이션', async ({ page }) => {
await page.route('/api/slow-endpoint', async route => {
await new Promise(resolve => setTimeout(resolve, 2000)); // 2초 지연
await route.continue();
});
await page.goto('/slow-page');
await expect(page.getByText('로딩 중...')).toBeVisible();
});
// 실제 요청을 보내되 응답 수정
test('응답 내용 수정', async ({ page }) => {
await page.route('/api/config', async route => {
const response = await route.fetch();
const json = await response.json();
await route.fulfill({
response,
json: { ...json, featureFlag: true }, // 플래그만 변경
});
});
});
// 요청 감시 (인터셉트 없이)
test('API가 올바른 데이터로 호출된다', async ({ page }) => {
let capturedRequest: Request | null = null;
page.on('request', request => {
if (request.url().includes('/api/users')) {
capturedRequest = request;
}
});
await page.goto('/create-user');
await page.getByLabel('이름').fill('홍길동');
await page.getByRole('button', { name: '저장' }).click();
expect(capturedRequest?.method()).toBe('POST');
expect(JSON.parse(capturedRequest?.postData() ?? '{}')).toMatchObject({ name: '홍길동' });
});
7. Page Object Model 패턴
Page Object Model(POM) 은 페이지별로 로케이터와 액션을 클래스로 캡슐화하는 패턴입니다.
// e2e/pages/LoginPage.ts
import { type Page, expect } from '@playwright/test';
export class LoginPage {
private readonly page: Page;
// 로케이터 정의
private readonly emailInput;
private readonly passwordInput;
private readonly submitButton;
private readonly errorMessage;
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 navigate() {
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 getErrorMessage() {
return this.errorMessage.textContent();
}
// 단언 메서드
async expectErrorVisible(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
// e2e/pages/DashboardPage.ts
export class DashboardPage {
constructor(private readonly page: Page) {}
async expectWelcomeMessage(name: string) {
await expect(this.page.getByText(`${name}님, 환영합니다!`)).toBeVisible();
}
async navigateTo(section: string) {
await this.page.getByRole('link', { name: section }).click();
}
}
// e2e/auth.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
test('성공적인 로그인', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.navigate();
await loginPage.login('hong@example.com', 'password123');
await dashboardPage.expectWelcomeMessage('홍길동');
});
test('잘못된 비밀번호', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('hong@example.com', 'wrongpass');
await loginPage.expectErrorVisible('비밀번호가 틀렸습니다');
});
Fixtures로 인증 상태 재사용
// e2e/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
type MyFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
loggedInPage: Page; // 이미 로그인된 페이지
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
// 로그인 상태로 시작하는 fixture
loggedInPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('hong@example.com', 'password123');
await page.waitForURL('/dashboard');
await use(page); // 로그인된 page 제공
},
});
export { expect } from '@playwright/test';
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures';
test('로그인 후 대시보드 메뉴를 탐색한다', async ({ loggedInPage }) => {
// 이미 로그인된 상태에서 시작
await expect(loggedInPage.getByRole('heading', { name: '대시보드' })).toBeVisible();
});
8. 트레이스 뷰어와 디버깅
트레이스 기록
// playwright.config.ts
use: {
trace: 'on-first-retry', // 재시도 시 트레이스
// 또는
trace: 'on', // 항상 기록
// 또는
trace: 'retain-on-failure', // 실패 시만 보존
},
# 트레이스 뷰어로 보기
npx playwright show-trace trace.zip
트레이스 뷰어에서 확인 가능:
- 테스트의 모든 액션 단계별 타임라인
- 각 단계에서의 DOM 스냅샷
- 네트워크 요청/응답
- 콘솔 로그
- 소스 코드 연결
디버깅 모드
# UI 모드 — 테스트를 시각적으로 실행 및 디버깅
npx playwright test --ui
# 헤드풀 모드 — 브라우저를 보면서 실행
npx playwright test --headed
# 디버거 모드 — 각 단계를 수동으로 실행
npx playwright test --debug
# 특정 테스트만 실행
npx playwright test auth.spec.ts
npx playwright test --grep "로그인"
코드 생성기 (Codegen)
# 브라우저 동작을 테스트 코드로 자동 생성
npx playwright codegen http://localhost:3000
사용자가 브라우저에서 클릭/입력하는 동작이 실시간으로 코드로 생성됩니다.
9. CI/CD 통합
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright 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
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test
env:
CI: true
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
병렬 샤딩 (대규모 테스트 스위트)
# 테스트를 여러 머신에 분산
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4] # 4개 머신으로 분산
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4
10. 결론 — Playwright vs Cypress
Playwright 선택 시
- 다중 브라우저(Safari 포함) 테스트 필수
- 다중 탭/팝업 시나리오
- 복잡한 네트워크 모킹
- 빠른 CI 실행 필요 (병렬 + 샤딩)
- 비JavaScript 팀도 포함 (Python, Java 지원)
Cypress 선택 시
- 풍부한 플러그인 생태계 필요
- 시각적 디버깅 UI 선호
- Component 테스트와 E2E를 동일 도구로 통합
- 팀이 이미 Cypress에 익숙
실전 E2E 테스트 원칙
테스트하라:
✓ 핵심 비즈니스 흐름 (로그인, 결제, 핵심 기능)
✓ 크로스 브라우저 호환성
✓ 실제 API와의 통합 (스테이징 환경)
피해야 할 것:
✗ 단위 테스트로 충분한 것들
✗ UI의 픽셀 완벽 검증 (시각적 회귀는 Chromatic)
✗ 불안정한 타이밍 의존 테스트 (flaky tests)
한 줄 요약: Playwright는 2024년 현재 E2E 테스팅의 기술적 선두주자입니다. 자동 대기, 트레이스 뷰어, 멀티 브라우저 지원, 강력한 네트워크 제어 — 이 모든 것이 Playwright를 현대 E2E 테스팅의 새 표준으로 만들었습니다.
이전 글: Vue Test Utils — Vue 컴포넌트 테스트의 공식 도구
다음 글: Cypress — 개발자 친화적인 E2E 테스트 플랫폼
반응형
댓글