Playwright 완벽 가이드: 차세대 E2E 테스팅 도구
이 글의 핵심
Playwright는 Microsoft가 개발한 차세대 E2E 테스팅 프레임워크로 Chromium, Firefox, WebKit을 모두 지원합니다. 자동 대기, 병렬 실행, 강력한 디버깅 도구로 Selenium/Cypress 대비 높은 생산성을 제공합니다. Trace Viewer, Codegen, Inspector 등의 도구로 테스트 작성 및 디버깅이 매우 편리합니다.
Playwright란?
Playwright는 Microsoft가 개발한 오픈소스 E2E(End-to-End) 테스팅 프레임워크입니다. 2020년 출시 이후 빠르게 성장하며 Selenium, Cypress와 함께 3대 웹 테스팅 도구로 자리잡았습니다.
핵심 특징
-
멀티 브라우저 지원
- Chromium, Firefox, WebKit 모두 지원
- 브라우저별 자동 설치 및 관리
- 실제 브라우저 엔진 사용
-
자동 대기(Auto-waiting)
- 요소가 준비될 때까지 자동 대기
- 명시적
sleep()불필요 - Flaky 테스트 최소화
-
병렬 실행
- Worker 기반 병렬 테스트
- Sharding으로 분산 실행
- 빠른 테스트 실행 시간
-
강력한 디버깅 도구
- Trace Viewer: 타임라인 기반 디버깅
- Codegen: 자동 테스트 코드 생성
- Inspector: 스텝별 실행 및 디버깅
Selenium vs Cypress vs Playwright 비교
| 항목 | Selenium | Cypress | Playwright |
|---|---|---|---|
| 브라우저 지원 | Chrome, Firefox, Safari, Edge | Chrome, Edge, Firefox (베타) | Chromium, Firefox, WebKit |
| 언어 지원 | Java, Python, C#, JS 등 | JavaScript/TypeScript | JavaScript, TypeScript, Python, C#, Java |
| 자동 대기 | ❌ 수동 구현 필요 | ✅ 자동 | ✅ 자동 |
| 병렬 실행 | ⚠️ 외부 도구 필요 | ⚠️ 유료 플랜 | ✅ 기본 지원 |
| 탭/윈도우 전환 | ✅ | ❌ | ✅ |
| 네트워크 모킹 | ❌ | ✅ | ✅ |
| 비디오 녹화 | ❌ | ✅ | ✅ |
| 학습 곡선 | 높음 | 낮음 | 중간 |
| CI/CD 통합 | ✅ | ✅ | ✅ |
설치 및 초기 설정
프로젝트 생성
# npm으로 설치 (권장)
npm init playwright@latest
# 대화형 설정
# - TypeScript 사용 여부
# - 테스트 폴더 위치
# - GitHub Actions 워크플로우 추가 여부
# - Playwright 브라우저 설치
수동 설치
npm install -D @playwright/test
npx playwright install
프로젝트 구조
my-project/
├── tests/
│ ├── example.spec.ts
│ └── auth.spec.ts
├── playwright.config.ts
├── package.json
└── .gitignore
기본 테스트 작성
첫 번째 테스트
// tests/example.spec.ts
import { test, expect } from '@playwright/test';
test('기본 네비게이션 테스트', async ({ page }) => {
// 페이지 이동
await page.goto('https://playwright.dev/');
// 제목 확인
await expect(page).toHaveTitle(/Playwright/);
// 링크 클릭
await page.getByRole('link', { name: 'Get started' }).click();
// URL 확인
await expect(page).toHaveURL(/.*intro/);
});
Locator 사용법
test('다양한 Locator 방법', async ({ page }) => {
await page.goto('https://example.com');
// Role 기반 (권장)
await page.getByRole('button', { name: '제출' }).click();
// Text 기반
await page.getByText('로그인').click();
// Label 기반 (폼 요소)
await page.getByLabel('이메일').fill('[email protected]');
// Placeholder 기반
await page.getByPlaceholder('검색어를 입력하세요').fill('playwright');
// TestId 기반 (안정적)
await page.getByTestId('submit-button').click();
// CSS Selector
await page.locator('.submit-btn').click();
// XPath (비권장)
await page.locator('xpath=//button[@type="submit"]').click();
});
자동 대기의 힘
test('자동 대기 예제', async ({ page }) => {
await page.goto('https://example.com');
// 요소가 나타날 때까지 자동 대기
await page.getByRole('button', { name: '로드' }).click();
// 로딩 완료 후 요소가 visible할 때까지 대기
const result = page.getByTestId('result');
await expect(result).toBeVisible();
await expect(result).toHaveText('완료');
// 명시적 sleep() 불필요!
// Playwright가 알아서 대기합니다
});
고급 테스트 패턴
Page Object Model (POM)
// pages/LoginPage.ts
import { Page, Locator } 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.getByTestId('error-message');
}
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 getErrorMessage() {
return await this.errorMessage.textContent();
}
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('로그인 실패 시 에러 메시지 표시', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrongpassword');
const error = await loginPage.getErrorMessage();
expect(error).toContain('로그인 실패');
});
Fixtures로 재사용 가능한 Setup
// fixtures/auth.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// 모든 테스트 전에 로그인
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
// 로그인된 상태로 테스트 실행
await use(page);
// 테스트 후 정리 작업 (옵션)
},
});
// tests/dashboard.spec.ts
import { test } from '../fixtures/auth';
import { expect } from '@playwright/test';
test('대시보드 접근 (인증 필요)', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard');
await expect(authenticatedPage).toHaveURL('/dashboard');
await expect(authenticatedPage.getByRole('heading', { name: '대시보드' })).toBeVisible();
});
API 테스트
test('REST API 테스트', async ({ request }) => {
// GET 요청
const response = await request.get('https://api.example.com/users/1');
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const user = await response.json();
expect(user.id).toBe(1);
expect(user.email).toBe('[email protected]');
// POST 요청
const createResponse = await request.post('https://api.example.com/users', {
data: {
name: 'New User',
email: '[email protected]'
}
});
expect(createResponse.ok()).toBeTruthy();
const newUser = await createResponse.json();
expect(newUser.id).toBeDefined();
});
test('E2E와 API 혼합 테스트', async ({ page, request }) => {
// API로 테스트 데이터 준비
const response = await request.post('https://api.example.com/posts', {
data: { title: '테스트 포스트', content: '내용...' }
});
const post = await response.json();
// UI로 확인
await page.goto(`/posts/${post.id}`);
await expect(page.getByRole('heading')).toHaveText('테스트 포스트');
});
네트워크 모킹 및 인터셉트
test('API 응답 모킹', async ({ page }) => {
// API 요청 인터셉트
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
users: [
{ id: 1, name: 'Mock User 1' },
{ id: 2, name: 'Mock User 2' }
]
})
});
});
await page.goto('/users');
await expect(page.getByText('Mock User 1')).toBeVisible();
});
test('네트워크 요청 감시', async ({ page }) => {
// 특정 요청 대기
const responsePromise = page.waitForResponse('**/api/data');
await page.goto('/');
await page.getByRole('button', { name: '데이터 로드' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.items).toHaveLength(10);
});
Playwright 설정 최적화
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// 테스트 파일 위치
testDir: './tests',
// 테스트 타임아웃 (30초)
timeout: 30 * 1000,
// 각 expect 타임아웃 (5초)
expect: {
timeout: 5000
},
// 실패 시 재시도
retries: process.env.CI ? 2 : 0,
// 병렬 실행 워커 수
workers: process.env.CI ? 1 : undefined,
// 리포터 설정
reporter: [
['html'],
['json', { outputFile: 'test-results.json' }],
['junit', { outputFile: 'test-results.xml' }]
],
// 모든 프로젝트 공통 설정
use: {
// Base URL
baseURL: 'http://localhost:3000',
// 실패 시 스크린샷
screenshot: 'only-on-failure',
// 실패 시 비디오
video: 'retain-on-failure',
// Trace 수집
trace: 'on-first-retry',
// 브라우저 옵션
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
// 헤드리스 모드
headless: true,
},
// 브라우저별 프로젝트
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,
timeout: 120 * 1000,
},
});
CI/CD 통합
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
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: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
retention-days: 30
Docker 통합
# Dockerfile
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]
# docker-compose.yml
version: '3.8'
services:
playwright:
build: .
volumes:
- ./tests:/app/tests
- ./test-results:/app/test-results
- ./playwright-report:/app/playwright-report
environment:
- CI=true
GitLab CI
# .gitlab-ci.yml
image: mcr.microsoft.com/playwright:v1.40.0-jammy
stages:
- test
playwright-tests:
stage: test
script:
- npm ci
- npx playwright install
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/
- test-results/
expire_in: 1 week
cache:
paths:
- node_modules/
디버깅 도구
Playwright Inspector
# 디버그 모드로 테스트 실행
npx playwright test --debug
# 특정 테스트만 디버그
npx playwright test example.spec.ts --debug
# 특정 라인부터 디버그
npx playwright test example.spec.ts:10 --debug
Trace Viewer
// 코드에서 trace 수동 시작
test('trace 예제', async ({ page }) => {
await page.context().tracing.start({ screenshots: true, snapshots: true });
await page.goto('https://example.com');
await page.getByRole('button', { name: '제출' }).click();
await page.context().tracing.stop({ path: 'trace.zip' });
});
# Trace 파일 열기
npx playwright show-trace trace.zip
Codegen - 자동 테스트 생성
# URL에서 테스트 자동 생성
npx playwright codegen https://example.com
# 특정 브라우저로 생성
npx playwright codegen --browser=webkit https://example.com
# 모바일 기기 에뮬레이션
npx playwright codegen --device="iPhone 12" https://example.com
# 인증 상태 저장
npx playwright codegen --save-storage=auth.json https://example.com
UI Mode (대화형 실행)
# UI 모드로 테스트 실행
npx playwright test --ui
# 특정 프로젝트만
npx playwright test --ui --project=chromium
비주얼 리그레션 테스팅
스크린샷 비교
test('비주얼 리그레션 테스트', async ({ page }) => {
await page.goto('/');
// 전체 페이지 스크린샷
await expect(page).toHaveScreenshot('homepage.png');
// 특정 요소만 스크린샷
const header = page.getByRole('banner');
await expect(header).toHaveScreenshot('header.png');
// 픽셀 차이 허용 (0-1 사이)
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100
});
});
반응형 테스트
const viewports = [
{ width: 375, height: 667, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1920, height: 1080, name: 'desktop' }
];
for (const viewport of viewports) {
test(`${viewport.name} 레이아웃 테스트`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto('/');
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
});
}
성능 및 최적화
병렬 실행 최적화
// playwright.config.ts
export default defineConfig({
// CPU 코어 수만큼 워커 사용
workers: '50%',
// 또는 고정 숫자
workers: 4,
// 테스트 간 격리
fullyParallel: true,
});
Sharding (분산 실행)
# 4개 머신에 분산
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
테스트 그룹화
// 순차 실행이 필요한 테스트
test.describe.serial('순차 실행 그룹', () => {
test('첫 번째 테스트', async ({ page }) => {
// ...
});
test('두 번째 테스트', async ({ page }) => {
// 첫 번째 테스트 완료 후 실행
});
});
// 병렬 실행 강제
test.describe.parallel('병렬 실행 그룹', () => {
test('테스트 A', async ({ page }) => {
// ...
});
test('테스트 B', async ({ page }) => {
// 동시 실행
});
});
실전 사용 사례
E-Commerce 체크아웃 플로우
test('전체 구매 플로우', async ({ page }) => {
// 1. 상품 페이지 이동
await page.goto('/products/laptop');
await expect(page.getByRole('heading')).toContain('노트북');
// 2. 장바구니 추가
await page.getByRole('button', { name: '장바구니 담기' }).click();
await expect(page.getByText('장바구니에 추가되었습니다')).toBeVisible();
// 3. 장바구니 확인
await page.getByRole('link', { name: '장바구니' }).click();
await expect(page.getByTestId('cart-item')).toHaveCount(1);
// 4. 체크아웃
await page.getByRole('button', { name: '주문하기' }).click();
// 5. 배송 정보 입력
await page.getByLabel('이름').fill('홍길동');
await page.getByLabel('주소').fill('서울시 강남구');
await page.getByLabel('전화번호').fill('010-1234-5678');
// 6. 결제 정보 입력
await page.getByLabel('카드번호').fill('1234-5678-9012-3456');
await page.getByLabel('유효기간').fill('12/25');
await page.getByLabel('CVV').fill('123');
// 7. 주문 완료
await page.getByRole('button', { name: '결제하기' }).click();
// 8. 주문 확인 페이지
await expect(page).toHaveURL(/.*order-complete/);
await expect(page.getByText('주문이 완료되었습니다')).toBeVisible();
// 9. 주문번호 추출
const orderNumber = await page.getByTestId('order-number').textContent();
expect(orderNumber).toMatch(/ORD-\d+/);
});
무한 스크롤 테스트
test('무한 스크롤 리스트', async ({ page }) => {
await page.goto('/posts');
let previousCount = 0;
const maxScrolls = 5;
for (let i = 0; i < maxScrolls; i++) {
// 현재 아이템 수
const items = page.getByTestId('post-item');
const currentCount = await items.count();
expect(currentCount).toBeGreaterThan(previousCount);
previousCount = currentCount;
// 페이지 끝까지 스크롤
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
// 새 아이템 로딩 대기
await page.waitForTimeout(1000);
}
// 최소 아이템 수 확인
const finalCount = await page.getByTestId('post-item').count();
expect(finalCount).toBeGreaterThanOrEqual(30);
});
파일 업로드 및 다운로드
test('파일 업로드', async ({ page }) => {
await page.goto('/upload');
// 파일 선택
const fileInput = page.getByLabel('파일 선택');
await fileInput.setInputFiles('test-files/sample.pdf');
// 업로드 실행
await page.getByRole('button', { name: '업로드' }).click();
// 성공 메시지
await expect(page.getByText('업로드 완료')).toBeVisible();
});
test('파일 다운로드', async ({ page }) => {
await page.goto('/downloads');
// 다운로드 시작
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: '보고서 다운로드' }).click();
const download = await downloadPromise;
// 파일명 확인
expect(download.suggestedFilename()).toBe('report.pdf');
// 파일 저장
await download.saveAs('downloads/' + download.suggestedFilename());
});
베스트 프랙티스
1. Locator 우선순위
// ✅ 좋음: Role 기반 (접근성 우선)
await page.getByRole('button', { name: '제출' });
// ✅ 좋음: TestId (안정적)
await page.getByTestId('submit-button');
// ⚠️ 주의: Text 기반 (번역 시 변경)
await page.getByText('제출');
// ❌ 나쁨: CSS Selector (취약함)
await page.locator('.btn.btn-primary');
2. 명시적 Assertion 사용
// ✅ 좋음: 명확한 기대값
await expect(page.getByRole('alert')).toHaveText('저장되었습니다');
await expect(page).toHaveURL('/dashboard');
// ❌ 나쁨: 불명확한 대기
await page.waitForTimeout(3000);
3. 재사용 가능한 Helper 함수
// utils/helpers.ts
export async function login(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel('이메일').fill(email);
await page.getByLabel('비밀번호').fill(password);
await page.getByRole('button', { name: '로그인' }).click();
await expect(page).toHaveURL('/dashboard');
}
// 사용
import { login } from './utils/helpers';
test('대시보드 테스트', async ({ page }) => {
await login(page, '[email protected]', 'password');
// 테스트 로직...
});
4. 환경별 설정 분리
// playwright.config.ts
const config = {
development: {
baseURL: 'http://localhost:3000',
workers: 3,
},
staging: {
baseURL: 'https://staging.example.com',
workers: 2,
},
production: {
baseURL: 'https://example.com',
workers: 1,
}
};
const env = process.env.TEST_ENV || 'development';
export default defineConfig({
...config[env],
// 공통 설정...
});
트러블슈팅
Flaky 테스트 해결
// ❌ 문제: 타이밍 이슈
test('검색 결과', async ({ page }) => {
await page.goto('/search');
await page.fill('#search', 'playwright');
await page.click('#submit');
await page.waitForTimeout(2000); // ❌ 고정 대기
const results = await page.textContent('.results');
expect(results).toContain('10개');
});
// ✅ 해결: 조건 기반 대기
test('검색 결과', async ({ page }) => {
await page.goto('/search');
await page.fill('#search', 'playwright');
await page.click('#submit');
// 특정 상태까지 대기
await page.waitForSelector('.results', { state: 'visible' });
const results = page.locator('.results');
await expect(results).toContainText('10개');
});
메모리 누수 방지
// Context를 명시적으로 관리
test('컨텍스트 관리', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto('/');
// 테스트 로직...
} finally {
await context.close(); // 명시적 정리
}
});
CI 환경 최적화
// playwright.config.ts
export default defineConfig({
// CI에서 재시도
retries: process.env.CI ? 2 : 0,
// CI에서 워커 제한
workers: process.env.CI ? 1 : undefined,
// CI에서 헤드리스 강제
use: {
headless: process.env.CI ? true : false,
},
});
관련 리소스
다음 단계
-
Visual Testing 도구 통합
- Percy, Chromatic 등과 연동
- Pixel-perfect UI 검증
-
성능 테스팅
- Lighthouse CI 통합
- Web Vitals 측정
-
접근성 테스팅
- axe-core 통합
- WCAG 준수 확인
Playwright는 강력하고 빠르며 안정적인 E2E 테스팅 도구입니다. 자동 대기, 병렬 실행, 멀티 브라우저 지원으로 생산성을 크게 향상시킬 수 있습니다.