Playwright E2E 테스트 심화: Selenium·Cypress 비교, 설정 최적화, 병렬 실행, 비주얼 회귀

이 글의 핵심

Playwright는 Chromium·Firefox·WebKit을 하나의 API로 조작하는 E2E 테스트 프레임워크입니다. 이 글은 Selenium·Cypress와의 구조적 차이, 로그인 상태 재사용과 네트워크 모킹, 병렬·샤딩 설정, 스냅샷 비교를 예제로 다루고, strict mode violation·타임아웃·CI 전용 실패처럼 실제로 자주 만나는 오류와 그 원인을 함께 설명합니다.

Playwright란?

Playwright는 Microsoft가 개발한 오픈소스 E2E(End-to-End) 테스팅 프레임워크입니다. 2020년 첫 공개 이후 Selenium, Cypress와 함께 가장 많이 거론되는 웹 E2E 도구가 되었습니다.

구조를 먼저 알아 두면 뒤의 특징들이 왜 그렇게 동작하는지 이해가 쉽습니다. 테스트 코드는 Node.js 프로세스에서 실행되고, Playwright는 각 브라우저 엔진과 직접 통신하는 프로토콜(Chromium은 CDP, Firefox·WebKit은 Playwright가 패치해 넣은 자체 프로토콜)로 브라우저를 조작합니다. 그래서 Playwright가 설치하는 브라우저는 시스템에 깔린 Chrome이나 Safari가 아니라 Playwright 버전에 맞춰 빌드된 전용 브라우저이며, 이 점이 뒤에서 다룰 Docker 이미지 버전 불일치 문제의 원인이 됩니다. 테스트마다 새 BrowserContext(쿠키·스토리지가 분리된 시크릿 창과 비슷한 단위)를 만들기 때문에 브라우저 프로세스를 새로 띄우지 않고도 테스트를 격리할 수 있고, 이것이 병렬 실행을 가볍게 만드는 이유이기도 합니다.

핵심 특징

  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 (WebKit은 실험적)Chromium, Firefox, WebKit
언어 지원Java, Python, C#, JS 등JavaScript/TypeScriptJavaScript, TypeScript, Python, C#, Java
자동 대기❌ 수동 구현 필요✅ 자동✅ 자동
병렬 실행⚠️ 외부 도구 필요⚠️ 유료 플랜✅ 기본 지원
탭/윈도우 전환✅❌✅
네트워크 모킹⚠️ Selenium 4의 CDP/BiDi로 제한적✅✅
비디오 녹화❌✅✅
학습 곡선높음낮음중간
CI/CD 통합✅✅✅

표의 ✅/❌만으로 도구를 고르기보다는 팀 상황을 먼저 보는 것이 좋습니다. 테스트를 Java나 Python으로 작성해야 하고 실제 Safari나 오래된 브라우저, 원격 디바이스 팜(Selenium Grid 호환 서비스)까지 돌려야 한다면 Selenium이 여전히 현실적인 선택입니다. 프런트엔드 개발자가 직접 테스트를 쓰고 브라우저 안에서 앱 상태를 들여다보며 디버깅하는 경험이 중요하다면 Cypress가 편합니다. Playwright가 특히 유리한 것은 WebKit까지 포함한 브라우저 매트릭스, 여러 탭·팝업·다른 도메인을 오가는 OAuth 로그인 같은 흐름, 그리고 별도 유료 서비스 없이 병렬·샤딩을 쓰고 싶은 경우입니다. 반대로 Playwright의 WebKit은 macOS의 실제 Safari와 완전히 같지 않으므로, Safari 전용 버그를 확실히 잡아야 한다면 실제 기기 테스트를 병행해야 합니다.

설치 및 초기 설정

Playwright를 시작하는 가장 쉬운 방법은 공식 CLI를 사용하는 것입니다. 대화형 설치 프로세스가 프로젝트 설정을 자동으로 처리해주므로 몇 분 만에 테스트를 작성할 수 있습니다.

프로젝트 생성

npm init playwright@latest 명령어는 프로젝트를 자동으로 설정합니다. TypeScript를 사용할지, 테스트 폴더를 어디에 둘지, CI/CD 설정을 추가할지 등을 대화형으로 선택할 수 있습니다. 모든 설정이 완료되면 Chromium, Firefox, WebKit 브라우저가 자동으로 다운로드됩니다.

브라우저 다운로드는 시간이 걸릴 수 있지만, 한 번만 하면 되고 이후에는 로컬에서 빠르게 실행됩니다. 각 브라우저는 약 100-300MB 정도의 용량을 차지합니다.

# npm으로 설치 (권장)
npm init playwright@latest

# 대화형 설정
# - TypeScript 사용 여부
# - 테스트 폴더 위치
# - GitHub Actions 워크플로우 추가 여부
# - Playwright 브라우저 설치

수동 설치

기존 프로젝트에 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();
});

getByRole을 권장하는 이유는 사용자가 실제로 인식하는 방식(버튼, 링크, 제목과 그 접근 가능한 이름)으로 요소를 찾기 때문입니다. 클래스 이름이나 DOM 구조가 리팩터링으로 바뀌어도 화면상 “제출 버튼”이 그대로라면 테스트는 깨지지 않고, 반대로 getByRole로 찾을 수 없다면 스크린 리더 사용자도 그 요소를 제대로 인식하지 못한다는 접근성 문제의 신호일 수 있습니다. getByTestId는 문구가 자주 바뀌거나 다국어 사이트라서 역할과 이름으로 특정하기 어려울 때 쓰는 안정적인 차선책입니다.

Locator를 처음 쓸 때 가장 자주 만나는 오류는 다음과 같습니다.

Error: locator.click: Error: strict mode violation: getByRole('button', { name: '제출' }) resolved to 2 elements

Playwright의 Locator는 엄격 모드라서, click()이나 fill() 같은 동작 대상이 두 개 이상이면 아무거나 누르지 않고 실패합니다. 모달 안과 페이지 본문에 같은 이름의 버튼이 있는 경우가 전형적입니다. page.getByRole('dialog').getByRole('button', { name: '제출' })처럼 부모로 범위를 좁히는 것이 올바른 해결이고, .first()는 정말로 “여러 개 중 첫 번째”가 의도일 때만 씁니다. 습관적으로 .first()를 붙이면 나중에 요소 순서가 바뀌었을 때 엉뚱한 요소를 누르고도 테스트가 통과할 수 있습니다.

자동 대기의 힘

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가 알아서 대기합니다
});

자동 대기에는 두 종류가 있습니다. click() 같은 동작은 실행 전에 요소가 DOM에 붙어 있고, 보이고, 움직이지 않고(애니메이션 종료), 활성화되어 있고, 다른 요소에 가려지지 않았는지(actionability)를 확인하며 기다립니다. expect(locator).toHaveText() 같은 웹 퍼스트 단언은 조건이 참이 될 때까지 기본 5초 동안 반복해서 다시 확인합니다. 반면 const text = await locator.textContent(); expect(text).toBe('완료')처럼 값을 먼저 꺼낸 뒤 일반 expect로 비교하면 그 순간의 값을 한 번만 보므로 재시도가 일어나지 않습니다. 자동 대기가 있는데도 테스트가 흔들린다면 이 패턴을 쓰고 있지 않은지부터 확인하세요. 뒤의 POM 예제의 getErrorMessage()도 같은 이유로, 실무에서는 await expect(loginPage.errorMessage).toContainText('로그인 실패')처럼 Locator를 그대로 단언하는 편이 안정적입니다.

자동 대기가 끝내 실패하면 오류 메시지에 무엇을 기다리다 실패했는지가 나옵니다.

Error: locator.click: Test timeout of 30000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: '로드' })
  -   locator resolved to <button class="load">로드</button>
  - attempting click action
  -   element is visible, enabled and stable
  -   <div class="overlay">…</div> intercepts pointer events

마지막 줄의 intercepts pointer events는 쿠키 동의 배너나 로딩 오버레이 같은 다른 요소가 버튼을 덮고 있다는 뜻입니다. 이때 click({ force: true })로 검사를 건너뛰면 테스트는 통과하지만 실제 사용자는 누를 수 없는 버튼을 통과시키는 셈이므로, 오버레이가 사라지기를 기다리거나 배너를 먼저 닫는 것이 올바른 해결입니다.

고급 테스트 패턴

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, Page } 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();
});

로그인 상태 재사용 (storageState)

Playwright는 테스트마다 새 BrowserContext를 만들어 쿠키와 localStorage를 격리합니다. 테스트끼리 상태가 새지 않는다는 장점이 있지만, 로그인이 필요한 앱이라면 모든 테스트가 매번 로그인 화면을 거쳐야 해서 느리고, 로그인 폼 하나가 깨지면 전체 테스트가 실패합니다. 해결책은 로그인을 setup 프로젝트에서 한 번만 수행하고, 그 결과 쿠키·스토리지를 파일로 저장해 다른 프로젝트가 시작 상태로 불러오게 하는 것입니다. dependencies로 연결하면 setup이 먼저 실행됩니다. 저장 파일에는 실제 세션 토큰이 들어가므로 .gitignore에 추가해야 합니다.

// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('이메일').fill(process.env.E2E_USER!);
  await page.getByLabel('비밀번호').fill(process.env.E2E_PASS!);
  await page.getByRole('button', { name: '로그인' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'],
  },
],

권한이 다른 사용자(관리자·일반 회원)를 테스트해야 한다면 역할별로 저장 파일을 따로 만들고, 로그인 자체를 검증하는 테스트만 test.use({ storageState: { cookies: [], origins: [] } })로 빈 상태에서 시작하게 합니다.

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: 22
        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');
  
  // 허용할 다른 픽셀 개수 (비율로 지정하려면 maxDiffPixelRatio: 0~1)
  await expect(page).toHaveScreenshot('homepage.png', {
    maxDiffPixels: 100
  });
});

처음 실행하면 비교할 기준 이미지가 없으므로 A snapshot doesn't exist at .../homepage-chromium-linux.png, writing actual.이라는 메시지와 함께 테스트가 실패하고 기준 이미지가 만들어집니다. 파일 이름에 브라우저와 OS가 붙는 것은 같은 페이지라도 플랫폼마다 폰트 렌더링과 안티앨리어싱이 달라 픽셀이 어긋나기 때문입니다. 제가 이 기능을 처음 도입할 때 가장 흔히 겪는 문제도 이것인데, macOS에서 만든 기준 이미지를 커밋하면 Linux CI에서는 -linux 이미지를 찾지 못하거나 미세하게 다른 결과로 계속 실패합니다. 기준 이미지는 CI와 같은 Playwright Docker 이미지 안에서 npx playwright test --update-snapshots로 만드는 것이 가장 확실합니다. 또 현재 시각, 광고, 랜덤 추천 목록처럼 매번 바뀌는 영역은 mask: [page.getByTestId('clock')] 옵션으로 가리거나 page.clock으로 시간을 고정해야 합니다. 이런 준비 없이 허용 픽셀 수만 계속 늘리면 결국 실제 레이아웃 깨짐까지 통과시키는 테스트가 됩니다.

반응형 테스트

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 코어의 50%만큼 워커 사용
  workers: '50%',
  
  // 또는 고정 숫자
  workers: 4,
  
  // 테스트 간 격리
  fullyParallel: true,
});

fullyParallel: false(기본값)일 때 Playwright는 파일 단위로 워커에 테스트를 나눠 주고, 한 파일 안의 테스트는 같은 워커에서 순서대로 실행합니다. fullyParallel: true로 바꾸면 파일 안의 개별 테스트까지 흩어지므로 더 빨라지지만, 테스트끼리 같은 계정이나 같은 DB 레코드를 건드리고 있었다면 이때 숨어 있던 충돌이 드러납니다. 워커 수를 늘렸더니 갑자기 실패가 늘었다면 Playwright보다 테스트 데이터의 격리를 먼저 의심해야 합니다. 워커마다 다른 사용자를 쓰도록 testInfo.workerIndex로 계정을 나누거나, 테스트가 필요한 데이터를 API로 직접 만들고 지우게 하는 방식이 일반적입니다.

test.describe.serial은 앞 테스트가 실패하면 나머지를 건너뛰고, 재시도할 때도 그룹 전체를 처음부터 다시 실행합니다. 순서 의존성을 명시하는 탈출구일 뿐이라 공식 문서도 가능하면 피하라고 권하며, 대부분은 앞 단계의 상태를 fixture나 API 호출로 준비하는 방식으로 바꿀 수 있습니다.

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

샤드마다 HTML 리포트가 따로 생기므로, 여러 머신의 결과를 하나로 보려면 각 샤드에서 --reporter=blob으로 결과를 남기고 마지막 단계에서 npx playwright merge-reports로 합칩니다. 샤딩은 전체 실행 시간을 줄여 주지만 머신마다 브라우저 설치와 앱 빌드 비용이 반복되므로, 테스트가 몇 분 안에 끝나는 규모라면 워커 수 조정만으로 충분한 경우가 많습니다.

테스트 그룹화

// 순차 실행이 필요한 테스트
test.describe.serial('순차 실행 그룹', () => {
  test('첫 번째 테스트', async ({ page }) => {
    // ...
  });
  
  test('두 번째 테스트', async ({ page }) => {
    // 첫 번째 테스트 완료 후 실행
  });
});

// 병렬 실행 강제 (describe.parallel은 사용 중단 예정 → configure 권장)
test.describe.configure({ mode: 'parallel' });
test.describe('병렬 실행 그룹', () => {
  test('테스트 A', async ({ page }) => {
    // ...
  });
  
  test('테스트 B', async ({ page }) => {
    // 동시 실행
  });
});

실전 사용 사례

E-Commerce 체크아웃 플로우

test('전체 구매 플로우', async ({ page }) => {
  // 1. 상품 페이지 이동
  await page.goto('/products/laptop');
  await expect(page.getByRole('heading', { level: 1 })).toContainText('노트북');
  
  // 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. 주문번호 확인 (값이 채워질 때까지 재시도)
  await expect(page.getByTestId('order-number')).toHaveText(/ORD-\d+/);
});

무한 스크롤 테스트

test('무한 스크롤 리스트', async ({ page }) => {
  await page.goto('/posts');
  
  const items = page.getByTestId('post-item');
  await expect(items.first()).toBeVisible();
  const maxScrolls = 5;

  for (let i = 0; i < maxScrolls; i++) {
    // 현재 아이템 수
    const currentCount = await items.count();

    // 페이지 끝까지 스크롤
    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));

    // 새 아이템이 붙을 때까지 조건 기반 대기 (고정 sleep 대신 poll)
    await expect.poll(() => items.count()).toBeGreaterThan(currentCount);
  }
  
  // 최소 아이템 수 확인
  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());
});

값을 꺼내서 비교하면 자동 대기가 사라진다

Locator를 고르는 순서(Role → Label·Text → TestId → CSS)와 strict mode는 앞에서 다뤘으니, 여기서는 그 기준을 지켜도 여전히 flaky해지는 경우 하나만 짚습니다. await expect(locator).toHaveText(...)처럼 locator를 expect에 넘기는 단언은 조건이 맞을 때까지 기본 5초 동안 재시도합니다. 반면 await locator.textContent()로 값을 먼저 꺼낸 뒤 expect(value).toBe(...)로 비교하면, 요소가 DOM에 붙을 때까지만 기다리고 텍스트는 그 순간의 값을 한 번 읽고 끝납니다. 주문 완료 페이지가 뜬 직후 주문 번호가 비동기로 채워지는 화면이라면, 로컬에서는 통과하고 느린 CI에서만 Expected pattern: /ORD-\d+/ Received string: "" 같은 실패가 납니다.

// ❌ 한 번만 읽음: 아직 비어 있으면 그대로 실패
const orderNumber = await page.getByTestId('order-number').textContent();
expect(orderNumber).toMatch(/ORD-\d+/);

// ✅ 텍스트가 패턴과 맞을 때까지 재시도
await expect(page.getByTestId('order-number')).toHaveText(/ORD-\d+/);

// 값 자체가 이후 단계에 필요하면, 먼저 단언으로 기다린 뒤 꺼냅니다
const orderId = await page.getByTestId('order-number').innerText();

count()와 isVisible()은 아예 기다리지 않고 호출 시점의 값을 돌려줍니다. 그래서 if (await locator.isVisible())로 분기하는 코드는 렌더링 타이밍에 따라 다른 경로를 탑니다. 숫자나 계산된 값을 기다려야 할 때는 위 무한 스크롤 예제처럼 expect.poll()로 감싸면 재시도가 붙습니다.

실전 사례: 전자상거래 체크아웃 테스트

실무에서 가장 중요한 테스트 시나리오 중 하나는 결제 프로세스입니다. 여러 단계를 거쳐야 하며, 네트워크 요청이 많고, 타이밍 이슈가 발생하기 쉽습니다. Playwright의 자동 대기와 강력한 assertion으로 안정적인 테스트를 작성할 수 있습니다.

장바구니에서 결제까지 전체 플로우

test('체크아웃 전체 플로우', async ({ page }) => {
  // 1. 로그인
  await page.goto('/login');
  await page.getByLabel('이메일').fill('[email protected]');
  await page.getByLabel('비밀번호').fill('password123');
  await page.getByRole('button', { name: '로그인' }).click();
  await expect(page).toHaveURL('/');

  // 2. 상품 검색 및 장바구니 담기
  await page.getByPlaceholder('검색').fill('노트북');
  await page.getByRole('button', { name: '검색' }).click();
  
  // 첫 번째 상품 클릭
  await page.getByRole('link', { name: /노트북/ }).first().click();
  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);
  
  // 수량 변경
  await page.getByLabel('수량').fill('2');
  await expect(page.getByTestId('subtotal')).toContainText('3,000,000원');

  // 4. 결제 진행
  await page.getByRole('button', { name: '결제하기' }).click();
  
  // 배송 정보 입력
  await page.getByLabel('받는 사람').fill('홍길동');
  await page.getByLabel('연락처').fill('010-1234-5678');
  await page.getByLabel('주소').fill('서울시 강남구');
  
  // 결제 수단 선택
  await page.getByRole('radio', { name: '신용카드' }).check();
  
  // 최종 결제
  await page.getByRole('button', { name: '결제 완료' }).click();
  
  // 결제 성공 확인
  await expect(page).toHaveURL(/\/order\/success/);
  await expect(page.getByText('주문이 완료되었습니다')).toBeVisible();
  
  // 주문 번호 확인
  await expect(page.getByTestId('order-number')).toHaveText(/ORD-\d+/);
});

이 테스트는 실제 사용자 플로우를 그대로 재현하며, 각 단계마다 적절한 검증을 포함합니다. Playwright의 자동 대기 덕분에 waitForTimeout 같은 취약한 대기 없이 안정적으로 동작합니다.

다만 이런 긴 시나리오 테스트는 실패했을 때 원인을 좁히기 어렵고, 앞 단계(로그인, 검색)의 문제가 뒤 단계 테스트까지 모두 실패로 만듭니다. 그래서 실무에서는 로그인은 앞의 storageState로 건너뛰고, 장바구니에 상품을 담는 준비는 API 호출로 끝낸 뒤, UI 테스트는 “장바구니에서 결제 완료까지”처럼 검증하고 싶은 구간만 다루는 식으로 나누는 편이 유지보수가 쉽습니다. 실제 결제 대행사 화면은 테스트 환경의 샌드박스 모드를 쓰거나 page.route로 결제 API 응답을 모킹해, 외부 서비스의 장애가 우리 테스트의 실패로 번지지 않게 합니다. 또 E2E 테스트는 느리고 비싸므로 모든 분기를 여기서 검증하려 하지 말고, 금액 계산 같은 로직은 단위 테스트로 내리고 E2E는 “핵심 흐름이 끝까지 이어지는가”를 확인하는 데 집중하는 것이 좋습니다.

트러블슈팅

Flaky 테스트 해결

Flaky 테스트는 CI/CD에서 가장 큰 골칫거리입니다. 때로는 성공하고 때로는 실패하는 테스트는 신뢰성을 떨어뜨립니다. 대부분의 Flaky 테스트는 타이밍 문제에서 비롯됩니다.

고정된 시간(2초, 3초 등)을 기다리는 것은 나쁜 패턴입니다. 네트워크가 느리면 실패하며, 빠르면 불필요하게 기다립니다. 대신 특정 조건(요소가 보임, 텍스트 포함 등)을 기다려야 합니다.

// ❌ 문제: 타이밍 이슈
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개');
});

Playwright의 Locator는 기본적으로 자동 대기를 하므로 대부분의 경우 명시적 대기가 필요 없습니다. 위의 waitForSelector도 사실 생략해도 됩니다. toContainText가 이미 조건을 기다리기 때문입니다. page.fill('#search', ...), page.click('#submit') 같은 page 메서드는 Locator 이전의 옛 API로, 새 코드에서는 page.locator('#search').fill(...)이나 getByRole 계열을 쓰는 편이 권장됩니다.

CI의 재시도(retries: 2)도 조심해서 봐야 합니다. 재시도 끝에 통과한 테스트는 HTML 리포트에 “flaky”로 따로 표시되는데, 이 표시를 무시하면 재시도가 불안정한 테스트를 가려 주는 덮개가 되어 버립니다. 설정의 trace: 'on-first-retry'는 바로 이 경우를 위한 것입니다. 첫 재시도에서 trace를 남기므로, flaky로 표시된 테스트의 trace를 Trace Viewer로 열어 실패한 시도의 DOM 스냅샷과 네트워크 요청을 비교하면 원인을 대부분 찾을 수 있습니다.

메모리 누수 방지

// Context를 명시적으로 관리
test('컨텍스트 관리', async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();
  
  try {
    await page.goto('/');
    // 테스트 로직...
  } finally {
    await context.close(); // 명시적 정리
  }
});

CI에서만 브라우저를 찾지 못할 때

browserType.launch: Executable doesn't exist at /ms-playwright/chromium-1091/chrome-linux/chrome

이 오류는 설치된 @playwright/test 버전과 브라우저 바이너리 버전이 맞지 않을 때 납니다. Playwright 버전마다 요구하는 브라우저 빌드 번호가 정해져 있기 때문입니다. 위 Dockerfile처럼 mcr.microsoft.com/playwright:v1.40.0-jammy 이미지를 쓰면서 package.json의 Playwright를 다른 버전으로 올리면 이미지에 들어 있는 브라우저와 어긋납니다. 이미지 태그를 package.json의 버전과 함께 올리거나, CI에서 npx playwright install --with-deps로 현재 버전에 맞는 브라우저를 설치해야 합니다. GitHub Actions에서는 ~/.cache/ms-playwright를 Playwright 버전을 키로 캐시하면 매번 내려받는 시간을 줄일 수 있습니다.

CI 환경 최적화

// playwright.config.ts
export default defineConfig({
  // CI에서 재시도
  retries: process.env.CI ? 2 : 0,
  
  // CI에서 워커 제한
  workers: process.env.CI ? 1 : undefined,
  
  // headless 기본값이 true이므로 로컬에서 창을 보고 싶을 때만 false
  // (일시적으로 보려면 설정 대신 npx playwright test --headed)
  use: {
    headless: process.env.CI ? true : false,
  },
});

관련 리소스


자주 묻는 질문 (FAQ)

Q. 가끔만 실패하는 Flaky 테스트는 어떻게 고치나요?

A. 대부분의 Flaky 테스트는 waitForTimeout(2000) 같은 고정 대기에서 비롯되므로, 요소가 보이거나 특정 텍스트가 나타나는 조건을 기다리도록 바꿔야 합니다. Playwright의 Locator와 expect(locator).toContainText() 같은 웹 퍼스트 단언은 자동으로 재시도하며 기다리므로 명시적 대기가 거의 필요 없습니다. 원인이 불분명하다면 Trace Viewer로 실패한 실행의 스냅샷과 네트워크 기록을 확인하는 것이 빠릅니다.


같이 보면 좋은 글