Playwright 완벽 가이드: 차세대 E2E 테스팅 도구

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대 웹 테스팅 도구로 자리잡았습니다.

핵심 특징

  1. 멀티 브라우저 지원

    • Chromium, Firefox, WebKit 모두 지원
    • 브라우저별 자동 설치 및 관리
    • 실제 브라우저 엔진 사용
  2. 자동 대기(Auto-waiting)

    • 요소가 준비될 때까지 자동 대기
    • 명시적 sleep() 불필요
    • Flaky 테스트 최소화
  3. 병렬 실행

    • Worker 기반 병렬 테스트
    • Sharding으로 분산 실행
    • 빠른 테스트 실행 시간
  4. 강력한 디버깅 도구

    • Trace Viewer: 타임라인 기반 디버깅
    • Codegen: 자동 테스트 코드 생성
    • Inspector: 스텝별 실행 및 디버깅

Selenium vs Cypress vs Playwright 비교

항목SeleniumCypressPlaywright
브라우저 지원Chrome, Firefox, Safari, EdgeChrome, Edge, Firefox (베타)Chromium, Firefox, WebKit
언어 지원Java, Python, C#, JS 등JavaScript/TypeScriptJavaScript, 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,
  },
});

관련 리소스

다음 단계

  1. Visual Testing 도구 통합

    • Percy, Chromatic 등과 연동
    • Pixel-perfect UI 검증
  2. 성능 테스팅

    • Lighthouse CI 통합
    • Web Vitals 측정
  3. 접근성 테스팅

    • axe-core 통합
    • WCAG 준수 확인

Playwright는 강력하고 빠르며 안정적인 E2E 테스팅 도구입니다. 자동 대기, 병렬 실행, 멀티 브라우저 지원으로 생산성을 크게 향상시킬 수 있습니다.