Last edited: February 24, 2026
Playwright를 설치하고, 설정 파일을 작성하고, 첫 번째 예제 테스트를 실행하는 것까지는 어렵지 않다. 진짜 어려운 건 그 다음이다.
"어떤 시나리오를 E2E로 테스트해야 하지?"
이 질문에 답하지 못하면 두 가지 극단에 빠지게 된다:
이 글에서는 E2E 테스트 시나리오를 체계적으로 선정하는 기준을 정리한다.
E2E 테스트의 핵심은 사용자 관점이다. 코드가 아닌, 사용자가 서비스에서 수행하는 업무 흐름을 기준으로 시나리오를 정한다.
GOOD: "대시보드에 진입하면 주요 지표가 표시된다"
GOOD: "목록에서 항목을 클릭하면 상세 정보가 나타난다"
GOOD: "폼을 작성하고 저장하면 목록에 반영된다"
BAD: "API가 200을 반환한다" (→ API 테스트 영역)
BAD: "버튼 색상이 파란색이다" (→ 시각적 회귀 테스트 영역)
BAD: "formatDate 함수가 올바른 포맷을 반환한다" (→ 단위 테스트 영역)flowchart TD
A["테스트하려는 것이 무엇인가?"] --> B{"여러 페이지에 걸친\n사용자 흐름인가?"}
B -->|YES| C["E2E 테스트"]
B -->|NO| D{"실제 브라우저 라우팅\n+ API가 필요한가?"}
D -->|YES| C
D -->|NO| E{"단일 컴포넌트의\n렌더링/인터랙션인가?"}
E -->|YES| F["컴포넌트 테스트"]
E -->|NO| G["단위 테스트"]아래 기준 중 2개 이상 해당되면 E2E 테스트 작성을 고려하자.
| 기준 | 설명 | 예시 |
|---|---|---|
| 크리티컬 패스 | 이 기능이 깨지면 서비스를 사용할 수 없다 | 로그인, 메인 페이지 진입, 핵심 데이터 조회 |
| 멀티 페이지 흐름 | 2개 이상 페이지를 거치는 워크플로우 | 목록 → 상세 → 수정 → 저장 |
| 라우팅/네비게이션 | URL 변경, 리다이렉트, 가드 동작 | 잘못된 URL 접근 시 리다이렉트 |
| 인증/권한 | 인증 상태에 따른 접근 제어 | 미인증 시 로그인 페이지 리다이렉트, 권한별 메뉴 |
| 기준 | 설명 | 예시 |
|---|---|---|
| 폼 제출 흐름 | 입력 → 유효성 검증 → API 호출 → 결과 확인 | 사용자 등록, 설정 변경, 데이터 입력 |
| 테이블 인터랙션 | 필터, 정렬, 페이지네이션의 실제 동작 | 데이터 테이블 필터링 후 결과 갱신 |
| 조건부 UI | 사용자 역할, 설정에 따라 달라지는 UI | 관리자만 보이는 메뉴, 권한별 버튼 표시 |
| 에러/엣지 케이스 | 사용자가 흔히 마주하는 에러 상황 | 404 페이지, 빈 데이터 상태, 네트워크 에러 |
| 기준 | 설명 | 예시 |
|---|---|---|
| 시각적 상태 | 로딩/빈 상태/에러 상태 UI 표시 | 스켈레톤 표시 후 데이터 렌더링 |
| 반응형/레이아웃 | 사이드바 접기/펼치기, 반응형 동작 | 모바일 뷰포트에서 네비게이션 |
| 접근성 | 키보드 네비게이션, 포커스 관리 | Tab 키로 메뉴 탐색, 모달 포커스 트랩 |
E2E 테스트 시나리오는 크게 5가지 유형으로 분류할 수 있다.
"모든 페이지가 최소한으로 동작하는가?"
가장 기본적이면서 가장 가성비가 높은 테스트. 모든 주요 페이지에 접속하여 에러 없이 로드되는지만 확인한다.
const pages = [
{ path: '/dashboard', name: '대시보드' },
{ path: '/users', name: '사용자 관리' },
{ path: '/settings', name: '설정' },
]
for (const { path, name } of pages) {
test(`${name} 페이지가 로드된다`, async ({ page }) => {
await page.goto(path)
await expect(page).toHaveURL(new RegExp(path))
// 최소한의 콘텐츠 존재 확인
await expect(page.getByRole('navigation')).toBeVisible()
})
}"핵심 업무 흐름이 정상 동작하는가?"
사용자가 가장 빈번하게 수행하는 시나리오를 테스트한다. 에러 케이스는 제외하고 정상 동작만 검증한다.
test('데이터 목록에서 항목을 클릭하면 상세 정보가 표시된다', async ({ page }) => {
await page.goto('/items')
// 테이블 로드 확인
await expect(page.getByRole('table')).toBeVisible()
// 첫 번째 행 클릭
await page.getByRole('row').nth(1).click()
// 상세 패널 표시 확인
await expect(page.getByRole('complementary')).toBeVisible()
await expect(page.getByRole('heading', { level: 2 })).toBeVisible()
})"페이지 이동과 URL 라우팅이 정상 동작하는가?"
SPA에서 라우팅은 클라이언트가 담당하므로, 잘못된 라우팅이 발생해도 서버 에러가 나지 않아 발견이 어렵다.
test.describe('네비게이션', () => {
test('사이드바에서 설정 페이지로 이동할 수 있다', async ({ page }) => {
await page.goto('/dashboard')
await page.getByRole('link', { name: /설정/i }).click()
await expect(page).toHaveURL(/\/settings/)
})
test('존재하지 않는 URL로 접근하면 404 페이지가 표시된다', async ({ page }) => {
await page.goto('/nonexistent-page')
await expect(page.getByText(/찾을 수 없/i)).toBeVisible()
})
test('인증 없이 보호된 페이지에 접근하면 리다이렉트된다', async ({ browser }) => {
// 인증 없는 새 컨텍스트
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/dashboard')
await expect(page).toHaveURL(/\/login/)
await context.close()
})
})"폼 입력 → 유효성 검증 → 제출 → 결과 확인이 동작하는가?"
test('새 항목을 등록할 수 있다', async ({ page }) => {
await page.goto('/items/new')
// 폼 입력
await page.getByLabel('이름').fill('테스트 항목')
await page.getByLabel('카테고리').selectOption('A')
// 제출
await page.getByRole('button', { name: '저장' }).click()
// 결과 확인
await expect(page.getByText('저장되었습니다')).toBeVisible()
await expect(page).toHaveURL(/\/items/)
})"접근 제어와 권한 체계가 올바르게 동작하는가?"
멀티 테넌트 앱이나 권한 기반 앱에서 특히 중요하다.
test.describe('Feature Guard', () => {
test('관리자 메뉴가 일반 사용자에게 숨겨진다', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByRole('link', { name: /관리자 설정/i })).not.toBeVisible()
})
test('권한 없는 페이지에 직접 접근하면 접근 거부된다', async ({ page }) => {
await page.goto('/admin/settings')
await expect(page).not.toHaveURL(/\/admin\/settings/)
})
})시나리오를 나열했다면, 이제 어떤 순서로 구현할지 정해야 한다.
| 등급 | 의미 | 기준 | 목표 커버리지 |
|---|---|---|---|
| P0 | 필수 | 서비스 핵심 동작, 깨지면 업무 불가 | 100% |
| P1 | 중요 | 주요 사용자 흐름, 회귀 위험 높음 | 80% 이상 |
| P2 | 권장 | 편의 기능, UX 품질 | 여유 시 |
flowchart LR
A["Phase 1\nSmoke Test"] --> B["Phase 2\n네비게이션 + 가드"]
B --> C["Phase 3\nHappy Path"]
C --> D["Phase 4\n폼 + 인터랙션"]Phase 1: Smoke Test
Phase 2: 네비게이션 & 가드
Phase 3: Happy Path
Phase 4: 폼 & 인터랙션
무엇을 테스트할지 정하는 것만큼, 무엇을 테스트하지 않을지 아는 것도 중요하다.
| 시나리오 | 이유 | 대안 |
|---|---|---|
| 유틸 함수의 엣지 케이스 | E2E로는 경우의 수를 커버하기 어렵고 느림 | 단위 테스트 (Vitest/Jest) |
| 컴포넌트의 props 조합 | N개의 조합을 브라우저에서 확인하는 건 비효율 | 컴포넌트 테스트 (Testing Library) |
| API 응답의 스키마 검증 | 프론트엔드 E2E가 아닌 API 테스트 영역 | API 테스트, 계약 테스트 |
| CSS 픽셀 단위 검증 | 환경마다 렌더링이 미세하게 다름 | 시각적 회귀 테스트 (Chromatic 등) |
| 동시성/레이스 컨디션 | E2E에서 재현이 어렵고 불안정 | 단위 테스트 + 코드 리뷰 |
1. 하나의 테스트에 너무 많은 검증
// BAD: 하나의 테스트가 모든 것을 검증
test('대시보드 전체 검증', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByText('환영합니다')).toBeVisible()
await expect(page.getByRole('table')).toBeVisible()
await page.getByRole('button', { name: '필터' }).click()
await page.getByLabel('기간').selectOption('monthly')
await page.getByRole('link', { name: '설정' }).click()
await expect(page).toHaveURL(/settings/)
// ... 50줄 더
})
// GOOD: 하나의 흐름 당 하나의 테스트
test('대시보드가 로드된다', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: /대시보드/i })).toBeVisible()
})
test('필터를 변경하면 데이터가 갱신된다', async ({ page }) => {
await page.goto('/dashboard')
await page.getByLabel('기간').selectOption('monthly')
await expect(page.getByRole('table')).toBeVisible()
})2. 테스트 간 상태 의존
// BAD: 두 번째 테스트가 첫 번째 테스트의 결과에 의존
test('항목 생성', async ({ page }) => { /* ... */ })
test('생성된 항목 삭제', async ({ page }) => { /* 위에서 생성된 항목이 있어야 함! */ })
// GOOD: 각 테스트가 독립적
test('항목 삭제', async ({ page }) => {
// 테스트에 필요한 상태를 직접 설정
await page.goto('/items/known-id')
// ...
})3. 불안정한 Locator 사용
// BAD: 구현 세부사항에 의존
page.locator('.MuiButton-containedPrimary')
page.locator('div:nth-child(3) > span')
// GOOD: 사용자 관점의 Locator
page.getByRole('button', { name: '저장' })
page.getByLabel('이메일')새 테스트 파일을 만들 때 참고할 수 있는 구조:
import { test, expect } from '@playwright/test'
test.describe('[기능명]', () => {
// Smoke: 페이지 로드 확인 (P0)
test('[기능] 페이지가 정상적으로 로드된다', async ({ page }) => {
await page.goto('/[feature-path]')
await expect(page).toHaveURL(/[feature-path]/)
await expect(
page.getByRole('heading', { name: /[페이지 제목]/i })
).toBeVisible()
})
// Happy Path: 핵심 인터랙션 (P1)
test('[동작]하면 [결과]한다', async ({ page }) => {
await page.goto('/[feature-path]')
await page.getByRole('button', { name: /[동작]/i }).click()
await expect(page.getByText('[기대 결과]')).toBeVisible()
})
// Edge Case: 예외 상황 (P2)
test('데이터가 없을 때 빈 상태 메시지가 표시된다', async ({ page }) => {
await page.goto('/[feature-path]?empty=true')
await expect(page.getByText(/데이터가 없습니다/i)).toBeVisible()
})
})// GOOD: 행동과 결과를 명확히
test('날짜 필터를 변경하면 테이블이 갱신된다', ...)
test('존재하지 않는 페이지에 접근하면 404가 표시된다', ...)
test('검색어를 입력하면 실시간으로 결과가 필터링된다', ...)
// BAD: 모호한 이름
test('테스트 1', ...)
test('필터 동작', ...)
test('페이지 테스트', ...)e2e/
├── auth.setup.ts ← 인증 설정 (1회 실행)
├── fixtures/
│ └── base.ts ← 커스텀 fixture
├── helpers/
│ └── auth.ts ← 인증 헬퍼
├── smoke.spec.ts ← 전체 페이지 Smoke Test
├── navigation.spec.ts ← 네비게이션 + 라우팅
├── dashboard.spec.ts ← 대시보드 기능
├── user-management.spec.ts← 사용자 관리 기능
└── settings.spec.ts ← 설정 기능기능별로 파일을 분리하되, 하나의 파일이 너무 커지면 흐름별로 다시 분리한다:
# 파일이 커지면
user-management.spec.ts
↓
# 흐름별로 분리
user-management-list.spec.ts
user-management-form.spec.ts
user-management-permission.spec.tsE2E 테스트 시나리오 선정은 "이 기능이 깨졌을 때 사용자가 얼마나 큰 영향을 받는가?"라는 질문으로 귀결된다.
정리하면: