본문 바로가기
카테고리 없음

Playwright — 현대 E2E 테스팅의 새 표준

by SuldenLion 2026. 6. 27.
반응형

Playwright — 현대 E2E 테스팅의 새 표준

프론트엔드 개발 · 테스팅 시리즈 #5
Microsoft가 만든 Playwright는 Chromium, Firefox, WebKit을 단일 API로 제어하는 E2E 테스트 프레임워크입니다. 자동 대기, 네트워크 인터셉트, 트레이스 뷰어까지 — Cypress를 넘어서는 현대 E2E 테스팅의 완전한 가이드입니다.


목차

  1. Playwright란 무엇인가
  2. 설치 및 기본 설정
  3. 테스트 작성 기초
  4. 로케이터(Locator) — 요소 선택의 새 방식
  5. 자동 대기(Auto-waiting)
  6. 네트워크 인터셉트와 API 모킹
  7. Page Object Model 패턴
  8. 트레이스 뷰어와 디버깅
  9. CI/CD 통합
  10. 결론 — Playwright vs Cypress

1. Playwright란 무엇인가

Playwright는 2020년 Microsoft가 출시한 E2E(End-to-End) 테스트 프레임워크입니다. 원래 Puppeteer 팀이 Microsoft로 이직해 만든 차세대 도구입니다.

Playwright의 핵심 장점:

1. 크로스 브라우저: Chromium, Firefox, WebKit (Safari 엔진) 모두 지원
2. 자동 대기: 요소가 준비될 때까지 자동으로 기다림
3. 격리된 테스트: 각 테스트가 새 브라우저 컨텍스트에서 실행
4. 네트워크 제어: 요청 가로채기 및 응답 모킹
5. 병렬 실행: 기본으로 병렬 테스트 실행
6. 다중 언어: JavaScript, TypeScript, Python, Java, C#

Playwright vs Cypress vs Puppeteer

항목 Playwright Cypress Puppeteer
브라우저 Chrome, Firefox, Safari Chrome, Firefox (Edge 실험적) Chrome, Firefox
다중 탭 ❌ (v12 이후 실험적)
iframe 제한적
파일 업로드/다운로드
네트워크 제어
자동 대기
트레이스 뷰어
Component 테스트 ✅ (실험적)
언어 지원 다중 JS/TS만 JS/TS만

2. 설치 및 기본 설정

# 설치 (브라우저도 함께 다운로드)
npm init playwright@latest

# 또는 기존 프로젝트에 추가
npm install --save-dev @playwright/test
npx playwright install  # 브라우저 바이너리 설치

playwright.config.ts

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

export default defineConfig({
  // 테스트 파일 경로
  testDir: './e2e',

  // 전체 실패 허용 임계값
  maxFailures: 10,

  // 각 테스트 타임아웃
  timeout: 30_000,

  // expect 타임아웃
  expect: { timeout: 5_000 },

  // 병렬 실행
  fullyParallel: true,
  workers: process.env.CI ? 1 : undefined,

  // 재시도 (CI에서만)
  retries: process.env.CI ? 2 : 0,

  // 리포터
  reporter: [
    ['html'],                              // HTML 리포트
    ['junit', { outputFile: 'results.xml' }],  // CI용
    ['list'],                              // 콘솔 출력
  ],

  // 기본 URL (page.goto('/') 가능하게)
  use: {
    baseURL:       'http://localhost:3000',
    trace:         'on-first-retry',  // 실패 시 트레이스
    screenshot:    'only-on-failure',
    video:         'retain-on-failure',
    actionTimeout: 10_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 5'] },
    },
    {
      name: 'Mobile Safari',
      use:  { ...devices['iPhone 12'] },
    },
  ],

  // 테스트 전 개발 서버 자동 시작
  webServer: {
    command: 'npm run dev',
    url:     'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

3. 테스트 작성 기초

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('로그인 플로우', () => {
  // 각 테스트 전 실행
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('올바른 자격증명으로 로그인', async ({ page }) => {
    // 폼 입력
    await page.getByLabel('이메일').fill('hong@example.com');
    await page.getByLabel('비밀번호').fill('password123');
    await page.getByRole('button', { name: '로그인' }).click();

    // 리다이렉트 확인
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('홍길동님, 환영합니다!')).toBeVisible();
  });

  test('잘못된 자격증명으로 에러 표시', async ({ page }) => {
    await page.getByLabel('이메일').fill('wrong@example.com');
    await page.getByLabel('비밀번호').fill('wrongpass');
    await page.getByRole('button', { name: '로그인' }).click();

    await expect(page.getByRole('alert')).toContainText('이메일 또는 비밀번호가 틀렸습니다');
    await expect(page).toHaveURL('/login');
  });

  test('빈 폼 제출 시 유효성 에러', async ({ page }) => {
    await page.getByRole('button', { name: '로그인' }).click();

    await expect(page.getByText('이메일을 입력하세요')).toBeVisible();
    await expect(page.getByText('비밀번호를 입력하세요')).toBeVisible();
  });
});

여러 페이지와 탭 테스트

test('새 탭에서 링크 열기', async ({ page, context }) => {
  await page.goto('/');

  // 새 탭이 열릴 때 캡처
  const pagePromise = context.waitForEvent('page');
  await page.getByRole('link', { name: '새 탭에서 열기' }).click();
  const newPage = await pagePromise;

  await newPage.waitForLoadState();
  expect(newPage.url()).toContain('example.com');
  await newPage.close();
});

4. 로케이터(Locator) — 요소 선택의 새 방식

Playwright의 Locator는 지연 평가(lazy evaluation)되는 요소 참조입니다. 매번 DOM을 다시 쿼리하므로 동적 콘텐츠에 강합니다.

권장 로케이터 (접근성 우선)

// Role 기반 (최우선 권장)
page.getByRole('button', { name: '제출' })
page.getByRole('textbox', { name: '검색' })
page.getByRole('checkbox', { name: '동의' })
page.getByRole('link',    { name: '홈' })
page.getByRole('heading', { name: '대시보드', level: 1 })
page.getByRole('combobox')
page.getByRole('dialog')
page.getByRole('listitem')

// 레이블 기반
page.getByLabel('이메일 주소')

// 플레이스홀더
page.getByPlaceholder('검색어 입력...')

// 텍스트
page.getByText('안녕하세요')
page.getByText(/안녕/)

// Alt 텍스트 (이미지)
page.getByAltText('프로필 사진')

// 타이틀
page.getByTitle('정보')

// 테스트 ID (최후 수단)
page.getByTestId('user-card')

로케이터 체이닝과 필터링

// 특정 영역 내에서 탐색
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: '확인' }).click();

// 필터링
const items = page.getByRole('listitem');
await items.filter({ hasText: '완료' }).count();

// nth() — n번째 요소
await page.getByRole('button').nth(2).click();

// 첫/마지막
await page.getByRole('listitem').first().click();
await page.getByRole('listitem').last().click();

5. 자동 대기(Auto-waiting)

Playwright의 가장 강력한 기능입니다. 요소가 준비될 때까지 자동으로 기다립니다.

// 이 코드는 자동으로 요소가:
// 1. DOM에 존재하고
// 2. 보이고 (visible)
// 3. 안정적이고 (not animating)
// 4. 활성화되어 있고 (not disabled)
// 5. 이벤트를 받을 수 있을 때 (not covered)
// 까지 기다림
await page.getByRole('button', { name: '제출' }).click();

// expect도 자동 대기
await expect(page.getByText('성공!')).toBeVisible();
// 기본 5초 동안 조건이 충족될 때까지 반복 확인

명시적 대기

// 특정 URL로 이동될 때까지 대기
await page.waitForURL('/dashboard');
await page.waitForURL(/dashboard/);

// 특정 이벤트 대기
await page.waitForLoadState('networkidle');  // 네트워크 조용해질 때까지
await page.waitForLoadState('domcontentloaded');

// 특정 응답 대기
await page.waitForResponse('/api/users');
await page.waitForResponse(resp => resp.url().includes('/api/') && resp.status() === 200);

// 커스텀 조건
await page.waitForFunction(() => document.title === '로딩 완료');
await page.waitForSelector('.spinner', { state: 'hidden' });

6. 네트워크 인터셉트와 API 모킹

test('API 응답 모킹', async ({ page }) => {
  // 특정 URL 요청 가로채기
  await page.route('/api/users', async route => {
    await route.fulfill({
      status:      200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: '홍길동' },
        { id: 2, name: '김철수' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.getByText('홍길동')).toBeVisible();
  await expect(page.getByText('김철수')).toBeVisible();
});

test('API 에러 시뮬레이션', async ({ page }) => {
  await page.route('/api/users', route => {
    route.fulfill({ status: 500, body: 'Internal Server Error' });
  });

  await page.goto('/users');
  await expect(page.getByText('서버 오류가 발생했습니다')).toBeVisible();
});

test('네트워크 요청 지연 시뮬레이션', async ({ page }) => {
  await page.route('/api/slow-endpoint', async route => {
    await new Promise(resolve => setTimeout(resolve, 2000));  // 2초 지연
    await route.continue();
  });

  await page.goto('/slow-page');
  await expect(page.getByText('로딩 중...')).toBeVisible();
});

// 실제 요청을 보내되 응답 수정
test('응답 내용 수정', async ({ page }) => {
  await page.route('/api/config', async route => {
    const response = await route.fetch();
    const json = await response.json();

    await route.fulfill({
      response,
      json: { ...json, featureFlag: true },  // 플래그만 변경
    });
  });
});

// 요청 감시 (인터셉트 없이)
test('API가 올바른 데이터로 호출된다', async ({ page }) => {
  let capturedRequest: Request | null = null;

  page.on('request', request => {
    if (request.url().includes('/api/users')) {
      capturedRequest = request;
    }
  });

  await page.goto('/create-user');
  await page.getByLabel('이름').fill('홍길동');
  await page.getByRole('button', { name: '저장' }).click();

  expect(capturedRequest?.method()).toBe('POST');
  expect(JSON.parse(capturedRequest?.postData() ?? '{}')).toMatchObject({ name: '홍길동' });
});

7. Page Object Model 패턴

Page Object Model(POM) 은 페이지별로 로케이터와 액션을 클래스로 캡슐화하는 패턴입니다.

// e2e/pages/LoginPage.ts
import { type Page, expect } from '@playwright/test';

export class LoginPage {
  private readonly page: Page;

  // 로케이터 정의
  private readonly emailInput;
  private readonly passwordInput;
  private readonly submitButton;
  private readonly errorMessage;

  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 navigate() {
    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 this.errorMessage.textContent();
  }

  // 단언 메서드
  async expectErrorVisible(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

// e2e/pages/DashboardPage.ts
export class DashboardPage {
  constructor(private readonly page: Page) {}

  async expectWelcomeMessage(name: string) {
    await expect(this.page.getByText(`${name}님, 환영합니다!`)).toBeVisible();
  }

  async navigateTo(section: string) {
    await this.page.getByRole('link', { name: section }).click();
  }
}
// e2e/auth.spec.ts
import { test } from '@playwright/test';
import { LoginPage }    from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

test('성공적인 로그인', async ({ page }) => {
  const loginPage    = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);

  await loginPage.navigate();
  await loginPage.login('hong@example.com', 'password123');

  await dashboardPage.expectWelcomeMessage('홍길동');
});

test('잘못된 비밀번호', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.navigate();
  await loginPage.login('hong@example.com', 'wrongpass');
  await loginPage.expectErrorVisible('비밀번호가 틀렸습니다');
});

Fixtures로 인증 상태 재사용

// e2e/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;
  loggedInPage:  Page;  // 이미 로그인된 페이지
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },

  // 로그인 상태로 시작하는 fixture
  loggedInPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.login('hong@example.com', 'password123');
    await page.waitForURL('/dashboard');
    await use(page);  // 로그인된 page 제공
  },
});

export { expect } from '@playwright/test';
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures';

test('로그인 후 대시보드 메뉴를 탐색한다', async ({ loggedInPage }) => {
  // 이미 로그인된 상태에서 시작
  await expect(loggedInPage.getByRole('heading', { name: '대시보드' })).toBeVisible();
});

8. 트레이스 뷰어와 디버깅

트레이스 기록

// playwright.config.ts
use: {
  trace: 'on-first-retry',  // 재시도 시 트레이스
  // 또는
  trace: 'on',              // 항상 기록
  // 또는
  trace: 'retain-on-failure', // 실패 시만 보존
},
# 트레이스 뷰어로 보기
npx playwright show-trace trace.zip

트레이스 뷰어에서 확인 가능:

  • 테스트의 모든 액션 단계별 타임라인
  • 각 단계에서의 DOM 스냅샷
  • 네트워크 요청/응답
  • 콘솔 로그
  • 소스 코드 연결

디버깅 모드

# UI 모드 — 테스트를 시각적으로 실행 및 디버깅
npx playwright test --ui

# 헤드풀 모드 — 브라우저를 보면서 실행
npx playwright test --headed

# 디버거 모드 — 각 단계를 수동으로 실행
npx playwright test --debug

# 특정 테스트만 실행
npx playwright test auth.spec.ts
npx playwright test --grep "로그인"

코드 생성기 (Codegen)

# 브라우저 동작을 테스트 코드로 자동 생성
npx playwright codegen http://localhost:3000

사용자가 브라우저에서 클릭/입력하는 동작이 실시간으로 코드로 생성됩니다.


9. CI/CD 통합

GitHub Actions

# .github/workflows/playwright.yml
name: Playwright 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

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run Playwright tests
        run: npx playwright test
        env:
          CI: true

      - name: Upload Playwright report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

병렬 샤딩 (대규모 테스트 스위트)

# 테스트를 여러 머신에 분산
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]  # 4개 머신으로 분산
    steps:
      - run: npx playwright test --shard=${{ matrix.shard }}/4

10. 결론 — Playwright vs Cypress

Playwright 선택 시

  • 다중 브라우저(Safari 포함) 테스트 필수
  • 다중 탭/팝업 시나리오
  • 복잡한 네트워크 모킹
  • 빠른 CI 실행 필요 (병렬 + 샤딩)
  • 비JavaScript 팀도 포함 (Python, Java 지원)

Cypress 선택 시

  • 풍부한 플러그인 생태계 필요
  • 시각적 디버깅 UI 선호
  • Component 테스트와 E2E를 동일 도구로 통합
  • 팀이 이미 Cypress에 익숙

실전 E2E 테스트 원칙

테스트하라:
✓ 핵심 비즈니스 흐름 (로그인, 결제, 핵심 기능)
✓ 크로스 브라우저 호환성
✓ 실제 API와의 통합 (스테이징 환경)

피해야 할 것:
✗ 단위 테스트로 충분한 것들
✗ UI의 픽셀 완벽 검증 (시각적 회귀는 Chromatic)
✗ 불안정한 타이밍 의존 테스트 (flaky tests)

한 줄 요약: Playwright는 2024년 현재 E2E 테스팅의 기술적 선두주자입니다. 자동 대기, 트레이스 뷰어, 멀티 브라우저 지원, 강력한 네트워크 제어 — 이 모든 것이 Playwright를 현대 E2E 테스팅의 새 표준으로 만들었습니다.


이전 글: Vue Test Utils — Vue 컴포넌트 테스트의 공식 도구
다음 글: Cypress — 개발자 친화적인 E2E 테스트 플랫폼

반응형

댓글