Playwright E2E 테스트 완벽 가이드 — Cypress를 대체하는 최신 테스팅 도구

PlaywrightE2E테스트테스팅자동화QA프론트엔드

Playwright는 Microsoft가 만든 E2E 테스트 프레임워크입니다. Cypress의 대안으로 급부상하며 2026년 현재 가장 인기 있는 E2E 테스트 도구입니다.


Playwright vs Cypress

항목PlaywrightCypress
브라우저Chrome, Firefox, Safari, EdgeChrome, Firefox, Edge
실행 속도⭐⭐⭐⭐⭐⭐⭐⭐
병렬 실행내장 (무료)유료
모바일에뮬레이션 지원제한적
네트워크완벽한 제어좋음
언어JS/TS, Python, C#, JavaJS/TS
무료 대시보드✅ (HTML 리포트)500 테스트/월

설치 및 초기 설정

설치

# 프로젝트에 설치
npm init playwright@latest

# 또는 수동 설치
npm install -D @playwright/test
npx playwright install

설정 파일

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  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,
  },
})

기본 테스트 작성

첫 번째 테스트

// tests/example.spec.ts
import { test, expect } from '@playwright/test'

test('홈페이지가 정상적으로 로드된다', async ({ page }) => {
  await page.goto('/')

  // 제목 확인
  await expect(page).toHaveTitle(/My App/)

  // 요소 확인
  await expect(page.getByRole('heading', { name: '환영합니다' })).toBeVisible()
})

test('네비게이션이 작동한다', async ({ page }) => {
  await page.goto('/')

  // 링크 클릭
  await page.getByRole('link', { name: '소개' }).click()

  // URL 확인
  await expect(page).toHaveURL('/about')
})

Locator 전략

// 1. Role (권장)
page.getByRole('button', { name: '제출' })
page.getByRole('heading', { level: 1 })
page.getByRole('link', { name: /자세히/ })

// 2. Text
page.getByText('환영합니다')
page.getByText(/환영/, { exact: false })

// 3. Label (폼 요소)
page.getByLabel('이메일')
page.getByPlaceholder('이메일을 입력하세요')

// 4. Test ID (DOM 구조 변경에 강함)
page.getByTestId('submit-button')

// 5. CSS/XPath (최후의 수단)
page.locator('.submit-btn')
page.locator('//button[@type="submit"]')

액션

// 클릭
await page.getByRole('button').click()
await page.getByRole('button').dblclick()
await page.getByRole('button').click({ button: 'right' })

// 입력
await page.getByLabel('이메일').fill('test@example.com')
await page.getByLabel('검색').type('검색어', { delay: 100 })

// 선택
await page.getByLabel('국가').selectOption('KR')
await page.getByRole('checkbox').check()
await page.getByRole('radio', { name: '옵션 A' }).check()

// 파일 업로드
await page.getByLabel('파일').setInputFiles('test.pdf')

// 드래그 앤 드롭
await page.getByText('항목 1').dragTo(page.getByText('영역'))

// 키보드
await page.keyboard.press('Enter')
await page.keyboard.press('Control+A')

고급 기능

네트워크 인터셉트

test('API 응답을 모킹한다', async ({ page }) => {
  // API 응답 모킹
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: '테스트 사용자' },
      ]),
    })
  })

  await page.goto('/users')
  await expect(page.getByText('테스트 사용자')).toBeVisible()
})

test('느린 네트워크를 시뮬레이션한다', async ({ page }) => {
  // 응답 지연
  await page.route('**/api/**', async (route) => {
    await new Promise(resolve => setTimeout(resolve, 3000))
    await route.continue()
  })

  await page.goto('/dashboard')
  await expect(page.getByText('로딩 중...')).toBeVisible()
})

test('네트워크 요청을 검증한다', async ({ page }) => {
  const requestPromise = page.waitForRequest('**/api/submit')

  await page.goto('/form')
  await page.getByLabel('이름').fill('홍길동')
  await page.getByRole('button', { name: '제출' }).click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({ name: '홍길동' })
})

인증 상태 재사용

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'

const authFile = 'playwright/.auth/user.json'

setup('authenticate', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('이메일').fill('test@example.com')
  await page.getByLabel('비밀번호').fill('password')
  await page.getByRole('button', { name: '로그인' }).click()

  await expect(page.getByText('대시보드')).toBeVisible()

  // 인증 상태 저장
  await page.context().storageState({ path: authFile })
})
// playwright.config.ts
export default defineConfig({
  projects: [
    // 인증 셋업
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    // 인증된 테스트
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
})

시각적 회귀 테스트

test('스크린샷 비교', async ({ page }) => {
  await page.goto('/dashboard')

  // 전체 페이지
  await expect(page).toHaveScreenshot('dashboard.png')

  // 특정 요소
  await expect(page.getByTestId('chart')).toHaveScreenshot('chart.png')

  // 옵션
  await expect(page).toHaveScreenshot('full.png', {
    fullPage: true,
    maxDiffPixels: 100,
  })
})

테스트 구조화

Page Object Model

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test'

export class LoginPage {
  readonly page: Page
  readonly emailInput: Locator
  readonly passwordInput: Locator
  readonly submitButton: Locator

  constructor(page: Page) {
    this.page = page
    this.emailInput = page.getByLabel('이메일')
    this.passwordInput = page.getByLabel('비밀번호')
    this.submitButton = page.getByRole('button', { name: '로그인' })
  }

  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()
  }
}
// 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('test@example.com', 'password')

  await expect(page).toHaveURL('/dashboard')
})

Fixtures

// 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
}

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

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

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

test('대시보드 테스트', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto()
  await loginPage.login('test@example.com', 'password')

  await dashboardPage.checkStats()
})

CI/CD 통합

GitHub Actions

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - 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

샤딩 (병렬 실행)

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]

    steps:
      - name: Run Playwright tests
        run: npx playwright test --shard=${{ matrix.shard }}/4

디버깅

UI 모드

# 인터랙티브 UI로 테스트 실행
npx playwright test --ui

디버그 모드

# 브라우저 열고 단계별 실행
npx playwright test --debug

# 특정 테스트만
npx playwright test login.spec.ts --debug

Trace Viewer

// playwright.config.ts
use: {
  trace: 'on-first-retry', // 실패 시 트레이스
}
# 트레이스 파일 열기
npx playwright show-trace trace.zip

코드 생성

# 브라우저에서 녹화하면 코드 생성
npx playwright codegen localhost:3000

모범 사례

1. 안정적인 Locator

// ❌ 깨지기 쉬움
page.locator('.btn-primary.submit')
page.locator('div > button:nth-child(2)')

// ✅ 안정적
page.getByRole('button', { name: '제출' })
page.getByTestId('submit-button')

2. 명시적 대기

// ❌ 임의의 대기
await page.waitForTimeout(3000)

// ✅ 조건부 대기
await expect(page.getByText('완료')).toBeVisible()
await page.waitForResponse('**/api/save')

3. 독립적인 테스트

// ❌ 테스트 간 의존성
test('1. 사용자 생성', async ({ page }) => { ... })
test('2. 사용자 로그인', async ({ page }) => { ... })

// ✅ 독립적 테스트
test('새 사용자가 가입할 수 있다', async ({ page }) => {
  // 모든 셋업 포함
})

4. 테스트 격리

// 각 테스트는 새로운 컨텍스트에서 실행
test.describe('사용자 관리', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/admin/users')
  })

  test('사용자 목록을 본다', async ({ page }) => { ... })
  test('사용자를 추가한다', async ({ page }) => { ... })
})

실행 명령어

# 모든 테스트 실행
npx playwright test

# 특정 파일
npx playwright test login.spec.ts

# 특정 브라우저
npx playwright test --project=chromium

# 헤드풀 모드 (브라우저 표시)
npx playwright test --headed

# 리포트 보기
npx playwright show-report

마치며

Playwright 선택 이유:

  1. 속도: 병렬 실행이 기본
  2. 안정성: 자동 대기로 flaky 테스트 감소
  3. 크로스 브라우저: Chromium, Firefox, WebKit
  4. DX: Codegen, UI Mode, Trace Viewer
  5. 무료: 모든 기능이 오픈소스

Cypress에서 마이그레이션하거나 새 프로젝트를 시작한다면 Playwright를 강력 추천합니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.