Playwright E2E 테스트 완전 가이드: 빠르고 안정적인 자동화

프론트엔드

PlaywrightE2E 테스트자동화 테스트테스트CI/CD

이 글은 누구를 위한 것인가

  • Playwright로 E2E 테스트를 안정적으로 작성하려는 팀
  • Cypress에서 Playwright로 전환을 검토하는 개발자
  • 느리거나 불안정한(flaky) E2E 테스트를 개선하려는 팀

들어가며

E2E 테스트는 실사용자의 워크플로우를 자동화한다. Playwright는 Chromium/Firefox/WebKit을 동시에 지원하고, 자동 대기(auto-wait)로 flaky 테스트를 줄이며, 병렬 실행으로 속도를 높인다.

이 글은 bluefoxdev.kr의 Playwright E2E 가이드 를 참고하여 작성했습니다.


1. Playwright 핵심 개념

[Playwright vs Cypress]

Playwright:
  - Chromium/Firefox/WebKit 지원
  - 멀티 탭/팝업/iframe 지원
  - 병렬 실행 (기본)
  - 네트워크 모킹 강력
  - TypeScript 네이티브 지원
  - 자동 대기: 요소가 visible/enabled 될 때까지

Cypress:
  - Chromium 기반 브라우저만 (일부 Firefox)
  - 단일 탭 원칙
  - 병렬: Cypress Cloud 유료
  - Dashboard 시각화 강력
  - 타임머신 디버깅

[로케이터 우선순위 (Playwright 권장)]
  1. getByRole()      → ARIA 역할 기반 (최우선)
  2. getByLabel()     → 폼 레이블 기반
  3. getByPlaceholder() → placeholder 기반
  4. getByText()      → 텍스트 내용 기반
  5. getByTestId()    → data-testid 기반
  ✗ CSS selector, XPath → 구현 세부사항에 의존, 취약

[Auto-wait 동작]
  page.click(locator):
    1. 요소 찾을 때까지 대기 (actionTimeout: 30s)
    2. visible 확인
    3. stable 확인 (애니메이션 완료)
    4. enabled 확인
    5. 포커스
    6. 클릭
  → 수동 sleep 불필요

2. Playwright 구현 패턴

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ['html'],
    ['junit', { outputFile: 'results.xml' }],
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',  // 실패 시 trace 저장
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    // 인증 설정 프로젝트
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'e2e/.auth/user.json',  // 로그인 상태 재사용
      },
      dependencies: ['setup'],
    },
    {
      name: 'mobile',
      use: { ...devices['iPhone 14'] },
      dependencies: ['setup'],
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
// e2e/auth.setup.ts - 로그인 상태 저장
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '.auth/user.json');

setup('인증 설정', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('이메일').fill('test@example.com');
  await page.getByLabel('비밀번호').fill('password123');
  await page.getByRole('button', { name: '로그인' }).click();
  
  // 로그인 완료 확인
  await expect(page).toHaveURL('/dashboard');
  
  // 인증 상태 저장 (쿠키 + localStorage)
  await page.context().storageState({ path: authFile });
});
// Page Object Model
// e2e/pages/checkout.page.ts
import { Page, Locator, expect } from '@playwright/test';

export class CheckoutPage {
  readonly page: Page;
  readonly cartItems: Locator;
  readonly totalPrice: Locator;
  readonly placeOrderButton: Locator;
  
  constructor(page: Page) {
    this.page = page;
    this.cartItems = page.getByRole('listitem').filter({ has: page.getByTestId('cart-item') });
    this.totalPrice = page.getByTestId('total-price');
    this.placeOrderButton = page.getByRole('button', { name: '주문하기' });
  }
  
  async goto() {
    await this.page.goto('/checkout');
  }
  
  async fillShippingAddress(address: {
    name: string;
    phone: string;
    address: string;
  }) {
    await this.page.getByLabel('수령인').fill(address.name);
    await this.page.getByLabel('연락처').fill(address.phone);
    await this.page.getByLabel('배송 주소').fill(address.address);
  }
  
  async placeOrder() {
    await this.placeOrderButton.click();
    await expect(this.page).toHaveURL(/\/orders\/\d+/);
  }
  
  async getTotal(): Promise<number> {
    const text = await this.totalPrice.textContent();
    return parseInt(text!.replace(/[^0-9]/g, ''));
  }
}
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { CheckoutPage } from './pages/checkout.page';

test.describe('결제 플로우', () => {
  test('상품 주문 성공', async ({ page }) => {
    const checkout = new CheckoutPage(page);
    
    // API 모킹 (외부 결제 API 차단)
    await page.route('**/api/payments/process', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ success: true, orderId: 'ORDER-123' }),
      });
    });
    
    // 장바구니에 상품 추가
    await page.goto('/products/laptop-1');
    await page.getByRole('button', { name: '장바구니 담기' }).click();
    
    await checkout.goto();
    await checkout.fillShippingAddress({
      name: '홍길동',
      phone: '010-1234-5678',
      address: '서울시 강남구 테헤란로 123',
    });
    
    const total = await checkout.getTotal();
    expect(total).toBeGreaterThan(0);
    
    await checkout.placeOrder();
    
    // 주문 완료 확인
    await expect(page.getByRole('heading', { name: '주문 완료' })).toBeVisible();
  });
  
  test('재고 없는 상품 주문 실패', async ({ page }) => {
    // 재고 없음 API 응답 모킹
    await page.route('**/api/cart/add', async (route) => {
      await route.fulfill({
        status: 409,
        body: JSON.stringify({ error: 'OUT_OF_STOCK' }),
      });
    });
    
    await page.goto('/products/sold-out-item');
    await page.getByRole('button', { name: '장바구니 담기' }).click();
    
    await expect(page.getByRole('alert')).toContainText('품절');
  });
  
  // 시각적 회귀 테스트
  test('주문 완료 페이지 스냅샷', async ({ page }) => {
    await page.goto('/orders/ORDER-123');
    
    // 동적 데이터 마스킹
    await page.getByTestId('order-date').evaluate(el => {
      el.textContent = '2026-04-24';  // 고정값으로 교체
    });
    
    await expect(page).toHaveScreenshot('order-complete.png', {
      maxDiffPixelRatio: 0.02,  // 2% 픽셀 차이 허용
    });
  });
});
# .github/workflows/e2e.yml
name: E2E 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
      - run: npx playwright install --with-deps chromium
      
      - name: Run E2E tests
        run: npx playwright test --project=chromium
        env:
          BASE_URL: http://localhost:3000
      
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

마무리

Playwright의 핵심은 getByRole()같은 접근성 기반 로케이터로 구현 세부사항에 의존하지 않는 테스트를 작성하는 것이다. 인증 상태를 storageState로 저장하면 매 테스트마다 로그인 과정을 반복하지 않아 속도가 크게 향상된다. page.route()로 외부 API를 모킹하면 네트워크 상태에 관계없이 안정적인 테스트를 만들 수 있다.