리스트로 돌아가기
Playwright로 시작하는 E2E 테스트, 실제 브라우저에서 테스트하기 thumbnail

Playwright로 시작하는 E2E 테스트, 실제 브라우저에서 테스트하기

Playwright를 사용해서 실제 브라우저에서 사용자 시나리오를 테스트하는 방법을 실무 관점에서 알아봤어요. 설정부터 페이지 객체 모델까지 한 번에 정리했습니다.

2026-01-13 00:00

Playwright로 시작하는 E2E 테스트, 실제 브라우저에서 테스트하기

안녕하세요! 포테코입니다.

이전 글들에서 테스트 전략과 Jest, React Testing Library를 사용한 단위 테스트에 대해 알아봤어요. 이번 글에서는 Playwright를 사용해서 실제 브라우저에서 사용자 시나리오를 테스트하는 E2E 테스트를 작성하는 방법을 실무 관점에서 다뤄볼게요.

E2E 테스트는 전체 애플리케이션의 흐름을 확인할 수 있어서, 실제 사용자가 경험하는 것과 가장 가까운 테스트예요.

E2E 테스트는 실제 브라우저에서 실행되기 때문에 느리지만, 사용자 시나리오를 정확하게 검증할 수 있어요. 핵심 사용자 플로우에만 집중하는 것이 효율적입니다.

Playwright란?

Playwright는 Microsoft에서 개발한 E2E 테스트 도구예요. 실제 브라우저에서 사용자 시나리오를 테스트할 수 있습니다.

Playwright의 특징

여러 브라우저 지원

  • Chrome (Chromium)
  • Firefox
  • Safari (WebKit)
  • Edge

안정적인 테스트

  • 자동 대기 기능으로 불안정한 테스트 방지
  • 네트워크 요청 모킹 가능
  • 스크린샷 및 비디오 녹화 지원

강력한 기능

  • 다중 브라우저/다중 컨텍스트 테스트
  • 모바일 디바이스 에뮬레이션
  • 네트워크 조건 시뮬레이션

Playwright의 역할

E2E 테스트는 단위 테스트나 통합 테스트와 달리 전체 애플리케이션의 흐름을 확인해요. 사용자가 실제로 브라우저에서 하는 행동을 시뮬레이션합니다.

E2E 테스트가 적합한 경우:

  • 핵심 사용자 플로우 (로그인, 구매, 결제 등)
  • 크로스 브라우저 호환성 확인
  • 실제 네트워크 요청과 응답 확인
  • 전체 애플리케이션 통합 확인

Playwright 설정

1. 패키지 설치

pnpm add -D @playwright/test
npx playwright install

2. 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 ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

3. package.json 스크립트 추가

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:debug": "playwright test --debug"
  }
}

기본 E2E 테스트 작성하기

홈페이지 테스트

가장 간단한 E2E 테스트부터 시작해볼게요.

// e2e/homepage.spec.ts
import { test, expect } from '@playwright/test'
 
test.describe('홈페이지', () => {
  test('홈페이지가 정상적으로 로드됩니다', async ({ page }) => {
    await page.goto('/')
    
    await expect(page).toHaveTitle(/Poteco Studio/)
    await expect(page.locator('h1')).toBeVisible()
  })
 
  test('네비게이션 링크가 작동합니다', async ({ page }) => {
    await page.goto('/')
    
    await page.click('text=Journal')
    await expect(page).toHaveURL(/.*journal/)
    
    await page.click('text=About')
    await expect(page).toHaveURL(/.*about/)
  })
})

폼 제출 테스트

사용자가 폼을 제출하는 시나리오를 테스트해볼게요.

// e2e/contact-form.spec.ts
import { test, expect } from '@playwright/test'
 
test.describe('연락처 폼', () => {
  test('폼 제출이 성공적으로 완료됩니다', async ({ page }) => {
    await page.goto('/contact')
    
    await page.fill('input[name="name"]', '홍길동')
    await page.fill('input[name="email"]', 'hong@example.com')
    await page.fill('textarea[name="message"]', '테스트 메시지')
    
    await page.click('button[type="submit"]')
    
    await expect(page.locator('text=전송되었습니다')).toBeVisible()
  })
 
  test('잘못된 이메일일 때 에러를 표시합니다', async ({ page }) => {
    await page.goto('/contact')
    
    await page.fill('input[name="email"]', 'invalid-email')
    await page.click('button[type="submit"]')
    
    await expect(page.locator('text=올바른 이메일')).toBeVisible()
  })
})

고급 E2E 테스트 패턴

페이지 객체 모델 (Page Object Model)

페이지 객체 모델을 사용하면 테스트 코드를 더 깔끔하게 유지할 수 있어요. 페이지의 요소와 동작을 클래스로 캡슐화합니다.

// e2e/pages/ContactPage.ts
import { Page, Locator } from '@playwright/test'
 
export class ContactPage {
  readonly page: Page
  readonly nameInput: Locator
  readonly emailInput: Locator
  readonly messageInput: Locator
  readonly submitButton: Locator
  readonly successMessage: Locator
 
  constructor(page: Page) {
    this.page = page
    this.nameInput = page.locator('input[name="name"]')
    this.emailInput = page.locator('input[name="email"]')
    this.messageInput = page.locator('textarea[name="message"]')
    this.submitButton = page.locator('button[type="submit"]')
    this.successMessage = page.locator('text=전송되었습니다')
  }
 
  async goto() {
    await this.page.goto('/contact')
  }
 
  async fillForm(name: string, email: string, message: string) {
    await this.nameInput.fill(name)
    await this.emailInput.fill(email)
    await this.messageInput.fill(message)
  }
 
  async submit() {
    await this.submitButton.click()
  }
}
// e2e/contact-form.spec.ts
import { test, expect } from '@playwright/test'
import { ContactPage } from './pages/ContactPage'
 
test.describe('연락처 폼', () => {
  test('폼 제출이 성공적으로 완료됩니다', async ({ page }) => {
    const contactPage = new ContactPage(page)
    await contactPage.goto()
    
    await contactPage.fillForm('홍길동', 'hong@example.com', '테스트 메시지')
    await contactPage.submit()
    
    await expect(contactPage.successMessage).toBeVisible()
  })
})

사용자 시나리오 테스트

실제 사용자가 하는 행동을 시나리오로 작성해볼게요.

// e2e/user-journey.spec.ts
import { test, expect } from '@playwright/test'
 
test.describe('사용자 여정', () => {
  test('사용자가 제품을 검색하고 구매합니다', async ({ page }) => {
    // 1. 홈페이지 방문
    await page.goto('/')
    await expect(page.locator('h1')).toBeVisible()
 
    // 2. 제품 검색
    await page.fill('input[placeholder*="검색"]', '노트북')
    await page.press('input[placeholder*="검색"]', 'Enter')
    
    // 3. 제품 목록 확인
    await expect(page.locator('text=노트북')).toBeVisible()
 
    // 4. 제품 상세 페이지 이동
    await page.click('text=노트북')
    await expect(page).toHaveURL(/.*products.*/)
 
    // 5. 장바구니에 추가
    await page.click('button:has-text("장바구니에 추가")')
    await expect(page.locator('text=장바구니에 추가되었습니다')).toBeVisible()
 
    // 6. 장바구니 페이지 이동
    await page.click('a:has-text("장바구니")')
    await expect(page).toHaveURL(/.*cart/)
 
    // 7. 구매하기
    await page.click('button:has-text("구매하기")')
    await expect(page).toHaveURL(/.*checkout/)
  })
})

네트워크 요청 모킹

실제 API를 호출하지 않고 테스트할 수 있어요. 네트워크 요청을 모킹하면 테스트가 더 빠르고 안정적이 됩니다.

// e2e/api-mocking.spec.ts
import { test, expect } from '@playwright/test'
 
test.describe('API 모킹', () => {
  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: 'John', email: 'john@example.com' }),
      })
    })
 
    await page.goto('/users/1')
    await expect(page.locator('text=John')).toBeVisible()
  })
 
  test('네트워크 오류를 시뮬레이션합니다', async ({ page }) => {
    await page.route('**/api/users/*', async (route) => {
      await route.abort('failed')
    })
 
    await page.goto('/users/1')
    await expect(page.locator('text=오류가 발생했습니다')).toBeVisible()
  })
})

크로스 브라우저 테스트

Playwright는 여러 브라우저에서 테스트를 실행할 수 있어요. playwright.config.ts에서 설정한 브라우저들이 자동으로 테스트됩니다.

// e2e/cross-browser.spec.ts
import { test, expect } from '@playwright/test'
 
test.describe('크로스 브라우저 테스트', () => {
  test('모든 브라우저에서 홈페이지가 정상적으로 로드됩니다', async ({ page, browserName }) => {
    await page.goto('/')
    
    // 브라우저별로 다른 동작이 필요하면 browserName을 사용
    console.log(`Testing on ${browserName}`)
    
    await expect(page.locator('h1')).toBeVisible()
  })
})

스크린샷 및 비디오

Playwright는 테스트 실행 중 스크린샷과 비디오를 자동으로 저장할 수 있어요.

// playwright.config.ts
export default defineConfig({
  use: {
    screenshot: 'only-on-failure', // 실패 시에만 스크린샷 저장
    video: 'retain-on-failure', // 실패 시에만 비디오 저장
  },
})

수동으로 스크린샷을 찍을 수도 있어요.

test('스크린샷을 저장합니다', async ({ page }) => {
  await page.goto('/')
  await page.screenshot({ path: 'screenshot.png' })
})

디버깅

UI 모드

Playwright의 UI 모드를 사용하면 테스트를 시각적으로 디버깅할 수 있어요.

pnpm test:e2e:ui

디버그 모드

디버그 모드를 사용하면 테스트 실행을 일시 정지하고 단계별로 확인할 수 있어요.

pnpm test:e2e:debug

코드에서 일시 정지

테스트 코드에서 page.pause()를 사용하면 해당 지점에서 일시 정지할 수 있어요.

test('디버깅', async ({ page }) => {
  await page.goto('/')
  await page.pause() // 여기서 일시 정지
  // ...
})

CI/CD 통합

GitHub Actions에서 Playwright 테스트를 실행할 수 있어요.

# .github/workflows/e2e.yml
name: E2E Tests
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'pnpm'
      
      - name: Install pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 9
      
      - name: Install dependencies
        run: pnpm install
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps
      
      - name: Run E2E tests
        run: pnpm test:e2e
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/

베스트 프랙티스

1. 핵심 플로우에만 집중

모든 것을 E2E 테스트로 작성하지 마세요. 핵심 사용자 플로우에만 집중하는 것이 좋아요.

2. 테스트를 독립적으로 작성

각 테스트는 독립적으로 실행될 수 있어야 해요. 다른 테스트에 의존하면 안 됩니다.

3. 안정적인 셀렉터 사용

ID나 data-testid 같은 안정적인 셀렉터를 사용하는 것이 좋아요. CSS 클래스는 변경될 수 있어요.

// ❌ 나쁜 예: CSS 클래스 사용
await page.click('.button-primary')
 
// ✅ 좋은 예: data-testid 사용
await page.click('[data-testid="submit-button"]')

4. 명시적 대기 사용

Playwright는 자동으로 대기하지만, 명시적으로 대기하는 것이 더 안정적일 수 있어요.

// ✅ 좋은 예: 명시적 대기
await page.waitForSelector('[data-testid="content"]')
await expect(page.locator('[data-testid="content"]')).toBeVisible()

마무리

Playwright를 사용하면 실제 브라우저에서 사용자 시나리오를 정확하게 테스트할 수 있어요. E2E 테스트는 느리지만, 전체 애플리케이션의 통합을 확인할 수 있어서 정말 유용합니다.

E2E 테스트는 핵심 사용자 플로우에만 집중하는 것이 좋아요. 모든 것을 E2E로 테스트하면 유지보수가 어려워지고, 테스트 실행 시간도 길어집니다. 단위 테스트와 통합 테스트로 대부분을 커버하고, 중요한 플로우만 E2E로 테스트하는 것이 효율적입니다.

다음 단계

  1. 핵심 플로우 식별: 가장 중요한 사용자 플로우를 먼저 식별해요
  2. 페이지 객체 모델 적용: 테스트 코드를 더 깔끔하게 유지해요
  3. CI/CD 통합: 자동으로 E2E 테스트를 실행하도록 설정해요

테스트 전략 시리즈를 통해 단위 테스트부터 E2E 테스트까지 전체적인 테스트 전략을 알아봤어요. 앞으로도 테스트 관련 실전 팁과 패턴을 계속 공유할 예정이니 기대해 주세요!