Playwright는 Microsoft가 만든 E2E 테스트 프레임워크입니다. Cypress의 대안으로 급부상하며 2026년 현재 가장 인기 있는 E2E 테스트 도구입니다.
Playwright vs Cypress
| 항목 | Playwright | Cypress |
|---|---|---|
| 브라우저 | Chrome, Firefox, Safari, Edge | Chrome, Firefox, Edge |
| 실행 속도 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 병렬 실행 | 내장 (무료) | 유료 |
| 모바일 | 에뮬레이션 지원 | 제한적 |
| 네트워크 | 완벽한 제어 | 좋음 |
| 언어 | JS/TS, Python, C#, Java | JS/TS |
| 무료 대시보드 | ✅ (HTML 리포트) | 500 테스트/월 |
설치 및 초기 설정
설치
# 프로젝트에 설치
npm init playwright@latest
# 또는 수동 설치
npm install -D @playwright/test
npx playwright install
설정 파일
// 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',
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'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
기본 테스트 작성
첫 번째 테스트
// tests/example.spec.ts
import { test, expect } from '@playwright/test'
test('홈페이지가 정상적으로 로드된다', async ({ page }) => {
await page.goto('/')
// 제목 확인
await expect(page).toHaveTitle(/My App/)
// 요소 확인
await expect(page.getByRole('heading', { name: '환영합니다' })).toBeVisible()
})
test('네비게이션이 작동한다', async ({ page }) => {
await page.goto('/')
// 링크 클릭
await page.getByRole('link', { name: '소개' }).click()
// URL 확인
await expect(page).toHaveURL('/about')
})
Locator 전략
// 1. Role (권장)
page.getByRole('button', { name: '제출' })
page.getByRole('heading', { level: 1 })
page.getByRole('link', { name: /자세히/ })
// 2. Text
page.getByText('환영합니다')
page.getByText(/환영/, { exact: false })
// 3. Label (폼 요소)
page.getByLabel('이메일')
page.getByPlaceholder('이메일을 입력하세요')
// 4. Test ID (DOM 구조 변경에 강함)
page.getByTestId('submit-button')
// 5. CSS/XPath (최후의 수단)
page.locator('.submit-btn')
page.locator('//button[@type="submit"]')
액션
// 클릭
await page.getByRole('button').click()
await page.getByRole('button').dblclick()
await page.getByRole('button').click({ button: 'right' })
// 입력
await page.getByLabel('이메일').fill('test@example.com')
await page.getByLabel('검색').type('검색어', { delay: 100 })
// 선택
await page.getByLabel('국가').selectOption('KR')
await page.getByRole('checkbox').check()
await page.getByRole('radio', { name: '옵션 A' }).check()
// 파일 업로드
await page.getByLabel('파일').setInputFiles('test.pdf')
// 드래그 앤 드롭
await page.getByText('항목 1').dragTo(page.getByText('영역'))
// 키보드
await page.keyboard.press('Enter')
await page.keyboard.press('Control+A')
고급 기능
네트워크 인터셉트
test('API 응답을 모킹한다', async ({ page }) => {
// API 응답 모킹
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: '테스트 사용자' },
]),
})
})
await page.goto('/users')
await expect(page.getByText('테스트 사용자')).toBeVisible()
})
test('느린 네트워크를 시뮬레이션한다', async ({ page }) => {
// 응답 지연
await page.route('**/api/**', async (route) => {
await new Promise(resolve => setTimeout(resolve, 3000))
await route.continue()
})
await page.goto('/dashboard')
await expect(page.getByText('로딩 중...')).toBeVisible()
})
test('네트워크 요청을 검증한다', async ({ page }) => {
const requestPromise = page.waitForRequest('**/api/submit')
await page.goto('/form')
await page.getByLabel('이름').fill('홍길동')
await page.getByRole('button', { name: '제출' }).click()
const request = await requestPromise
expect(request.postDataJSON()).toEqual({ name: '홍길동' })
})
인증 상태 재사용
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
const authFile = 'playwright/.auth/user.json'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('이메일').fill('test@example.com')
await page.getByLabel('비밀번호').fill('password')
await page.getByRole('button', { name: '로그인' }).click()
await expect(page.getByText('대시보드')).toBeVisible()
// 인증 상태 저장
await page.context().storageState({ path: authFile })
})
// playwright.config.ts
export default defineConfig({
projects: [
// 인증 셋업
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// 인증된 테스트
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
})
시각적 회귀 테스트
test('스크린샷 비교', async ({ page }) => {
await page.goto('/dashboard')
// 전체 페이지
await expect(page).toHaveScreenshot('dashboard.png')
// 특정 요소
await expect(page.getByTestId('chart')).toHaveScreenshot('chart.png')
// 옵션
await expect(page).toHaveScreenshot('full.png', {
fullPage: true,
maxDiffPixels: 100,
})
})
테스트 구조화
Page Object Model
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('이메일')
this.passwordInput = page.getByLabel('비밀번호')
this.submitButton = page.getByRole('button', { name: '로그인' })
}
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/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('test@example.com', 'password')
await expect(page).toHaveURL('/dashboard')
})
Fixtures
// 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
}
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await use(loginPage)
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page)
await use(dashboardPage)
},
})
export { expect } from '@playwright/test'
// tests/dashboard.spec.ts
import { test, expect } from '../fixtures'
test('대시보드 테스트', async ({ loginPage, dashboardPage }) => {
await loginPage.goto()
await loginPage.login('test@example.com', 'password')
await dashboardPage.checkStats()
})
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'
- 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
샤딩 (병렬 실행)
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shard }}/4
디버깅
UI 모드
# 인터랙티브 UI로 테스트 실행
npx playwright test --ui
디버그 모드
# 브라우저 열고 단계별 실행
npx playwright test --debug
# 특정 테스트만
npx playwright test login.spec.ts --debug
Trace Viewer
// playwright.config.ts
use: {
trace: 'on-first-retry', // 실패 시 트레이스
}
# 트레이스 파일 열기
npx playwright show-trace trace.zip
코드 생성
# 브라우저에서 녹화하면 코드 생성
npx playwright codegen localhost:3000
모범 사례
1. 안정적인 Locator
// ❌ 깨지기 쉬움
page.locator('.btn-primary.submit')
page.locator('div > button:nth-child(2)')
// ✅ 안정적
page.getByRole('button', { name: '제출' })
page.getByTestId('submit-button')
2. 명시적 대기
// ❌ 임의의 대기
await page.waitForTimeout(3000)
// ✅ 조건부 대기
await expect(page.getByText('완료')).toBeVisible()
await page.waitForResponse('**/api/save')
3. 독립적인 테스트
// ❌ 테스트 간 의존성
test('1. 사용자 생성', async ({ page }) => { ... })
test('2. 사용자 로그인', async ({ page }) => { ... })
// ✅ 독립적 테스트
test('새 사용자가 가입할 수 있다', async ({ page }) => {
// 모든 셋업 포함
})
4. 테스트 격리
// 각 테스트는 새로운 컨텍스트에서 실행
test.describe('사용자 관리', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin/users')
})
test('사용자 목록을 본다', async ({ page }) => { ... })
test('사용자를 추가한다', async ({ page }) => { ... })
})
실행 명령어
# 모든 테스트 실행
npx playwright test
# 특정 파일
npx playwright test login.spec.ts
# 특정 브라우저
npx playwright test --project=chromium
# 헤드풀 모드 (브라우저 표시)
npx playwright test --headed
# 리포트 보기
npx playwright show-report
마치며
Playwright 선택 이유:
- 속도: 병렬 실행이 기본
- 안정성: 자동 대기로 flaky 테스트 감소
- 크로스 브라우저: Chromium, Firefox, WebKit
- DX: Codegen, UI Mode, Trace Viewer
- 무료: 모든 기능이 오픈소스
Cypress에서 마이그레이션하거나 새 프로젝트를 시작한다면 Playwright를 강력 추천합니다.