TechFeedTechFeed
Frontend

Playwright E2E 테스트 자동화 실전 가이드 — 설치부터 CI/CD 통합까지

Playwright로 E2E 테스트를 처음부터 구축하는 단계별 튜토리얼. 설치, 설정, 첫 테스트 작성, Page Object 패턴, API 모킹, 인증 상태 재사용, GitHub Actions CI 통합, 디버깅 기법까지 실제 동작하는 코드로 안내한다.

"수동으로 클릭해서 테스트하고 있다면, 그건 테스트가 아니라 기도다." 프론트엔드 배포 후 로그인이 깨지고, 결제 플로우가 멈추고, 모달이 안 닫히는 걸 사용자가 먼저 발견한 경험이 있다면 — 이 가이드가 필요하다.

Playwright는 Microsoft가 만든 E2E 테스트 프레임워크로, Chromium·Firefox·WebKit 세 엔진을 하나의 API로 제어한다. 이 글에서는 빈 프로젝트에서 시작해서 CI/CD 파이프라인에 Playwright를 통합하기까지, 실제 동작하는 코드로 단계별로 진행한다.

※ 이 글은 2026년 4월 기준, Playwright 1.52 / Node.js 22 LTS 기반으로 작성됐습니다.

Playwright 설치와 프로젝트 초기화

Playwright는 npm으로 설치한다. 프로젝트 루트에서 아래 명령어를 실행하면 설정 파일, 예제 테스트, GitHub Actions 워크플로우까지 자동 생성된다.

Playwright 설치 및 초기화
# 신규 프로젝트 초기화 (이미 있으면 스킵) npm init -y # Playwright 설치 — 브라우저 바이너리도 함께 다운로드 npm init playwright@latest # 설치 중 선택지: # ? Do you want to use TypeScript or JavaScript? → TypeScript # ? Where to put your end-to-end tests? → tests # ? Add a GitHub Actions workflow? → true # ? Install Playwright browsers? → true

설치가 끝나면 프로젝트에 아래 구조가 생긴다.

설치 후 디렉토리 구조
my-project/ ├── playwright.config.ts ← 브라우저·타임아웃·리포터 설정 ├── tests/ │ └── example.spec.ts ← 예제 테스트 ├── tests-examples/ │ └── demo-todo-app.spec.ts └── .github/ └── workflows/ └── playwright.yml ← CI 워크플로우 (자동 생성)

playwright.config.ts가 핵심이다. 여기서 어떤 브라우저로 테스트할지, 타임아웃은 몇 초인지, 베이스 URL은 무엇인지 모두 제어한다. 기본 설정을 그대로 쓰되, 프로젝트에 맞게 조정할 부분을 아래에서 다룬다.

Playwright 공식 사이트의 브라우저 지원 현황 — Chromium, Firefox, WebKit
Playwright가 지원하는 3개 브라우저 엔진 (출처: playwright.dev)

playwright.config.ts 핵심 설정

자동 생성된 설정 파일에서 실무 프로젝트에서 반드시 수정하는 항목 4가지를 짚는다.

playwright.config.ts — 실무 설정 예시
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', // 병렬 실행 — CI에서는 워커 수를 제한 fullyParallel: true, workers: process.env.CI ? 2 : undefined, // 실패 시 재시도 (flaky 테스트 대응) retries: process.env.CI ? 2 : 0, // 리포터: CI에서는 HTML + JUnit, 로컬은 list reporter: process.env.CI ? [['html', { open: 'never' }], ['junit', { outputFile: 'results.xml' }]] : 'list', use: { // 개발 서버 주소 baseURL: 'http://localhost:3000', // 실패 시 스크린샷·트레이스 자동 저장 screenshot: 'only-on-failure', trace: 'on-first-retry', }, // 테스트 전에 dev 서버 자동 실행 webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120_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 7'] } }, ], });

핵심 포인트:

  • webServer: 테스트 실행 전에 dev 서버를 자동으로 띄운다. 별도 터미널에서 서버를 켜둘 필요 없음
  • retries: CI 환경에서만 재시도를 켠다. 네트워크 지연 등으로 인한 flaky 실패를 흡수
  • trace: on-first-retry: 첫 번째 재시도에서 트레이스를 기록. 실패 원인을 타임라인으로 확인 가능
  • projects: 크로스 브라우저 + 모바일 뷰포트까지 한 번에 돌릴 수 있다

첫 번째 E2E 테스트 작성하기

로그인 폼을 테스트하는 실전 시나리오를 만들어 보자. "이메일과 비밀번호를 입력하고 로그인 버튼을 누르면, 대시보드 페이지로 이동한다"를 검증한다.

tests/login.spec.ts — 로그인 플로우 테스트
import { test, expect } from '@playwright/test'; test.describe('로그인 플로우', () => { test('올바른 자격증명으로 대시보드에 진입', async ({ page }) => { // 1. 로그인 페이지로 이동 await page.goto('/login'); // 2. 폼 입력 — getByLabel로 접근성 기반 셀렉터 사용 await page.getByLabel('이메일').fill('user@example.com'); await page.getByLabel('비밀번호').fill('securePassword123'); // 3. 로그인 버튼 클릭 await page.getByRole('button', { name: '로그인' }).click(); // 4. 대시보드 도달 확인 await expect(page).toHaveURL('/dashboard'); await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); }); test('잘못된 비밀번호로 에러 메시지 표시', async ({ page }) => { await page.goto('/login'); await page.getByLabel('이메일').fill('user@example.com'); await page.getByLabel('비밀번호').fill('wrong'); await page.getByRole('button', { name: '로그인' }).click(); // 에러 메시지가 보이는지 확인 await expect(page.getByText('이메일 또는 비밀번호가 올바르지 않습니다')).toBeVisible(); // URL은 여전히 로그인 페이지 await expect(page).toHaveURL('/login'); }); });

주목할 점은 셀렉터 전략이다. page.getByLabel(), page.getByRole(), page.getByText()는 접근성 속성 기반 셀렉터로, DOM 구조가 바뀌어도 테스트가 깨지지 않는다. #login-btn 같은 CSS 셀렉터는 리팩토링에 취약하니 피하자.

💡 팁: getByRole, getByLabel, getByText 순서로 셀렉터를 선택하라. CSS/XPath 셀렉터는 최후의 수단이다. Playwright 공식 문서도 이 우선순위를 권장한다.

테스트를 실행해 보자.

테스트 실행 명령어
# 전체 테스트 실행 npx playwright test # 특정 파일만 실행 npx playwright test tests/login.spec.ts # 특정 브라우저만 실행 npx playwright test --project=chromium # UI 모드로 실행 (브라우저 동작을 눈으로 확인) npx playwright test --ui # 디버그 모드 (한 줄씩 실행하며 확인) npx playwright test --debug
Playwright UI 모드에서 테스트 실행 화면 — 좌측 테스트 목록, 우측 브라우저 미리보기
Playwright UI 모드: 테스트별 실행 결과와 브라우저 스냅샷을 실시간으로 확인할 수 있다 (출처: playwright.dev)

Page Object 패턴으로 테스트 구조화하기

테스트가 10개를 넘으면 셀렉터 중복이 문제가 된다. 로그인 폼의 이메일 필드 셀렉터를 5개 테스트에서 각각 쓰고 있는데, HTML 구조가 바뀌면? 5곳을 모두 수정해야 한다. Page Object 패턴은 페이지별 셀렉터와 액션을 하나의 클래스에 모아서 이 문제를 해결한다.

tests/pages/login.page.ts — Page Object
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); } }

이제 테스트 코드가 훨씬 간결해진다.

Page Object를 사용한 테스트 리팩토링
import { test, expect } from '@playwright/test'; import { LoginPage } from './pages/login.page'; test.describe('로그인 플로우', () => { let loginPage: LoginPage; test.beforeEach(async ({ page }) => { loginPage = new LoginPage(page); await loginPage.goto(); }); test('올바른 자격증명으로 대시보드 진입', async ({ page }) => { await loginPage.login('user@example.com', 'securePassword123'); await expect(page).toHaveURL('/dashboard'); }); test('잘못된 비밀번호 에러', async () => { await loginPage.login('user@example.com', 'wrong'); await loginPage.expectError('이메일 또는 비밀번호가 올바르지 않습니다'); }); });

API 모킹과 네트워크 인터셉트

E2E 테스트에서 가장 흔한 실패 원인은 백엔드 의존성이다. API 서버가 느리거나, 테스트 데이터가 오염되거나, 외부 서비스가 다운되면 테스트가 깨진다. Playwright의 route()를 사용하면 네트워크 요청을 가로채서 원하는 응답을 돌려줄 수 있다.

API 응답 모킹 예시
import { test, expect } from '@playwright/test'; test('상품 목록이 정상 렌더링된다', async ({ page }) => { // /api/products 요청을 가로채서 가짜 데이터 반환 await page.route('**/api/products', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 1, name: 'MacBook Pro 16', price: 3490000 }, { id: 2, name: 'LG 울트라파인 32UN880', price: 890000 }, ]), }); }); await page.goto('/products'); // 모킹된 데이터가 렌더링되었는지 확인 await expect(page.getByText('MacBook Pro 16')).toBeVisible(); await expect(page.getByText('LG 울트라파인 32UN880')).toBeVisible(); }); test('API 에러 시 에러 화면이 표시된다', async ({ page }) => { await page.route('**/api/products', async (route) => { await route.fulfill({ status: 500 }); }); await page.goto('/products'); await expect(page.getByText('잠시 후 다시 시도해주세요')).toBeVisible(); });

모킹의 핵심 원칙: 해피 패스와 에러 패스 모두 테스트하라. 200 응답만 테스트하면 실제 장애 상황에서 사용자가 보는 화면을 검증할 수 없다. 500, 401, 네트워크 타임아웃까지 커버해야 의미 있는 E2E 테스트다.

⚠️ 주의: API 모킹은 프론트엔드 렌더링 검증에만 쓰자. 실제 API 계약(contract)이 맞는지는 별도의 통합 테스트로 검증해야 한다. 모킹만으로는 백엔드 스키마 변경을 감지할 수 없다.

인증 상태 재사용으로 테스트 속도 높이기

20개 테스트가 모두 로그인 후 동작한다면, 매번 로그인 폼을 거치는 건 낭비다. Playwright의 storageState를 사용하면 로그인 세션(쿠키, localStorage)을 파일로 저장하고, 다른 테스트에서 재사용할 수 있다.

인증 상태 저장 및 재사용 (global setup)
// tests/auth.setup.ts import { test as setup, expect } from '@playwright/test'; const authFile = 'playwright/.auth/user.json'; setup('로그인 후 인증 상태 저장', async ({ page }) => { await page.goto('/login'); await page.getByLabel('이메일').fill('user@example.com'); await page.getByLabel('비밀번호').fill('securePassword123'); await page.getByRole('button', { name: '로그인' }).click(); // 대시보드 도달 확인 후 상태 저장 await expect(page).toHaveURL('/dashboard'); await page.context().storageState({ path: authFile }); }); // playwright.config.ts에 추가 // projects: [ // { name: 'setup', testMatch: /.*\.setup\.ts/ }, // { // name: 'chromium', // use: { // ...devices['Desktop Chrome'], // storageState: 'playwright/.auth/user.json', // }, // dependencies: ['setup'], // }, // ]

이렇게 하면 setup 프로젝트가 먼저 실행되어 인증 상태를 저장하고, 이후 chromium 프로젝트의 모든 테스트가 이미 로그인된 상태에서 시작한다. 테스트 20개 기준으로 총 실행 시간이 40~60% 단축되는 효과가 있다.

Playwright 테스트 실행 파이프라인 — setup에서 인증 후 병렬 테스트 실행 구조
storageState를 활용한 인증 상태 재사용 흐름 (출처: Playwright 공식 문서)

GitHub Actions에 Playwright 통합하기

로컬에서 통과하는 테스트가 CI에서 실패하는 주범은 세 가지다: 브라우저 바이너리 미설치, 타임아웃 부족, dev 서버 미기동. 아래 워크플로우는 이 세 가지를 모두 해결한다.

.github/workflows/playwright.yml
name: Playwright E2E Tests on: push: branches: [main] pull_request: branches: [main] jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps - name: Run E2E tests run: npx playwright test - name: Upload test report uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: name: playwright-report path: playwright-report/ retention-days: 14

핵심 설정 해설:

  • --with-deps: 브라우저 실행에 필요한 OS 레벨 라이브러리(libgbm, libnss 등)까지 함께 설치한다. 이걸 빠뜨리면 browserType.launch()에서 크래시가 난다
  • if: !cancelled(): 테스트가 실패해도 리포트는 업로드한다. 실패 원인 분석에 필수
  • timeout-minutes: 15: 기본 360분은 과도하다. 15분이면 E2E 스위트 대부분은 끝난다
💡 팁: CI 비용이 걱정된다면 --project=chromium으로 단일 브라우저만 돌리고, 크로스 브라우저 테스트는 main 머지 시에만 실행하는 전략도 유효하다. PR 빌드 시간이 절반으로 줄어든다.

테스트 실패 디버깅 실전 기법

E2E 테스트가 CI에서만 실패할 때, 로컬에서 재현이 안 되면 답답하다. Playwright는 이를 위한 디버깅 도구를 3가지 제공한다.

1. Trace Viewer — 가장 강력한 도구. 테스트 실행의 모든 순간을 타임라인으로 보여준다. 각 액션 시점의 DOM 스냅샷, 네트워크 요청, 콘솔 로그를 확인할 수 있다.

Trace Viewer 사용법
# 트레이스를 켜고 테스트 실행 npx playwright test --trace on # CI에서 다운로드한 trace.zip 열기 npx playwright show-trace trace.zip # 또는 온라인 뷰어 사용 # trace.playwright.dev에 zip 파일 드래그 앤 드롭

2. HTML 리포트 — 테스트별 통과/실패 현황, 실행 시간, 스크린샷을 한 화면에서 확인한다.

HTML 리포트 열기
# 테스트 실행 후 리포트 열기 npx playwright show-report

3. VS Code 확장 — Playwright Test for VS Code 확장을 설치하면 에디터에서 바로 개별 테스트를 실행하고, 브레이크포인트를 걸어 디버깅할 수 있다. --debug 플래그보다 훨씬 편하다.

⚠️ 실패 원인 TOP 3:
타이밍 이슈: waitForSelector 대신 expect(locator).toBeVisible()을 써라 — 자동 재시도가 내장돼 있다
셀렉터 깨짐: CSS 셀렉터 대신 Role/Label 기반 셀렉터를 쓰면 리팩토링에 강해진다
테스트 간 상태 오염: 각 테스트는 독립적인 브라우저 컨텍스트에서 실행되는지 확인하라

프로덕션 도입 전 체크리스트

Playwright를 팀에 도입할 때, 아래 항목을 순서대로 진행하면 삽질을 줄일 수 있다.

단계항목설명
1크리티컬 패스 선정로그인, 결제, 회원가입 등 깨지면 매출에 직결되는 플로우 3~5개
2Page Object 설계크리티컬 패스에 관련된 페이지부터 Page Object 클래스 작성
3CI 파이프라인 통합PR 생성 시 자동 실행 — 실패하면 머지 차단
4Flaky 테스트 관리3회 연속 flaky인 테스트는 격리하고 원인 파악 후 복귀
5커버리지 확장크리티컬 패스 안정화 후 에러 시나리오, 엣지 케이스로 확장

처음부터 모든 페이지를 E2E로 커버하려 하면 유지보수가 불가능해진다. 전체 테스트의 80%는 유닛 테스트, 15%는 통합 테스트, 5%가 E2E라는 테스트 피라미드 원칙을 지키자. E2E는 "깨지면 사업에 영향이 가는" 핵심 플로우만 커버하는 게 맞다.

PlaywrightE2E 테스트테스트 자동화프론트엔드CI/CDGitHub ActionsTypeScriptPage Object

관련 포스트

TypeScript 6.0 마이그레이션 가이드 — strict 기본값화, ESM 전환, Go 컴파일러 전환 준비2026-04-05pnpm vs npm vs Yarn Berry 2026 — JavaScript 패키지 매니저 속도·디스크·워크스페이스 실전 비교2026-04-21Vercel AI SDK 6 완전 가이드 — 에이전트 1급 추상화, MCP 풀 지원, DevTools2026-04-17Next.js 15 핵심 변경사항 총정리2026-02-15