Last edited: February 24, 2026
프론트엔드 개발을 하다 보면 "잘 되던 기능이 갑자기 안 된다"는 상황을 자주 마주한다. 컴포넌트 하나를 수정했을 뿐인데, 전혀 관련 없어 보이는 페이지에서 버그가 발생하기도 한다.
단위 테스트는 함수나 컴포넌트 단위의 동작을 검증하지만, 사용자가 실제로 경험하는 흐름 — 로그인하고, 페이지를 이동하고, 폼을 제출하는 — 을 검증하기에는 한계가 있다. 이 간극을 채워주는 것이 바로 E2E(End-to-End) 테스트.
테스트 전략을 세울 때 가장 먼저 이해해야 할 개념은 테스트 피라미드다.
| 층위 | 도구 예시 | 속도 | 신뢰도 | 비용 |
|---|---|---|---|---|
| E2E | Playwright, Cypress | 느림 | 높음 | 높음 |
| 통합/컴포넌트 | Testing Library, Vitest | 보통 | 보통 | 보통 |
| 단위 | Vitest, Jest | 빠름 | 낮음 | 낮음 |
피라미드 아래로 갈수록 테스트 수가 많고, 위로 갈수록 적어야 한다. E2E 테스트는 핵심 사용자 흐름에 집중하는 것이 핵심이다.
E2E 테스트 도구는 여러 가지가 있지만, 2025년 현재 Playwright가 가장 강력한 선택지라고 생각한다.
| 항목 | Cypress | Playwright |
|---|---|---|
| 브라우저 지원 | Chrome 중심 | Chromium, Firefox, WebKit |
| 병렬 실행 | 유료(Cloud) | 내장 지원 |
| 멀티 탭/윈도우 | 미지원 | 지원 |
| 자동 대기 | 지원 | 지원 |
| 디버깅 도구 | Time Travel | Trace Viewer, UI Mode, Codegen |
| 언어 | JavaScript | JS, TS, Python, Java, C# |
Playwright의 가장 큰 장점은 Auto-waiting 메커니즘과 Trace Viewer다. 테스트가 실패하면 각 스텝의 스크린샷, 네트워크 요청, DOM 스냅샷을 타임라인으로 확인할 수 있어 디버깅이 압도적으로 편하다.
# Playwright 설치
npm install -D @playwright/test
# 브라우저 설치 (Chromium만 우선 설치)
npx playwright install --with-deps chromium// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['html', { outputFolder: './e2e/.report' }]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})핵심 포인트:
webServer: 테스트 전에 dev 서버를 자동으로 실행해준다trace: 'on-first-retry': 실패 시 첫 번째 재시도에서 trace를 수집한다fullyParallel: 테스트 파일 간 병렬 실행을 활성화한다SPA 앱에서 E2E 테스트의 가장 큰 난관은 인증이다. 매 테스트마다 로그인 페이지를 거치면 느리고 불안정해진다.
Playwright는 이 문제를 storageState로 해결한다.
1. Setup 프로젝트에서 1회 로그인
2. 쿠키/로컬스토리지를 JSON 파일로 저장
3. 이후 모든 테스트에서 해당 상태를 자동 복원// playwright.config.ts
projects: [
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: './e2e/.auth/user.json',
},
dependencies: ['auth-setup'],
},
]// e2e/auth.setup.ts
import { test as setup } from '@playwright/test'
setup('authenticate', async ({ page, context }) => {
// 로그인 수행
await page.goto('/login')
await page.getByLabel('이메일').fill('user@example.com')
await page.getByLabel('비밀번호').fill('password')
await page.getByRole('button', { name: '로그인' }).click()
await page.waitForURL('/dashboard')
// 인증 상태 저장
await context.storageState({ path: './e2e/.auth/user.json' })
}).auth/ 디렉토리는 반드시 .gitignore에 추가하자. 인증 토큰이 레포지토리에 커밋되면 보안 사고로 이어질 수 있다.외부 SSO를 사용하는 경우, 로그인 페이지를 직접 조작하기 어렵다. 이때는 쿠키를 직접 주입하는 방식을 사용한다:
setup('authenticate', async ({ context, page }) => {
const token = process.env.E2E_AUTH_TOKEN
await context.addCookies([{
name: 'access_token',
value: token,
domain: 'localhost',
path: '/',
sameSite: 'Lax',
}])
await page.goto('/dashboard')
await context.storageState({ path: './e2e/.auth/user.json' })
})E2E 테스트가 깨지는 가장 흔한 원인은 불안정한 요소 선택이다.
// CSS 클래스 — 스타일 변경 시 깨짐
page.locator('.btn-primary-large')
// DOM 구조 의존 — 레이아웃 변경 시 깨짐
page.locator('div > div:nth-child(3) > button')
// ID — 프레임워크가 자동 생성하면 불안정
page.locator('#radix-123')// Role 기반 — 접근성과 테스트 안정성 동시에 확보
page.getByRole('button', { name: '저장' })
page.getByRole('link', { name: /대시보드/i })
page.getByRole('heading', { name: '설정' })
// Label 기반 — 폼 요소에 최적
page.getByLabel('이메일')
page.getByLabel('기간 선택')
// Text 기반 — 상태 메시지 등
page.getByText('저장되었습니다')getByRole > getByLabel > getByText > getByPlaceholder > getByTestIdgetByRole을 우선 사용하면, 자연스럽게 접근성(Accessibility)도 함께 검증하게 된다.
npx playwright test --uiUI Mode에서 할 수 있는 것:
테스트 실패 시 자동 수집되는 trace를 열어볼 수 있다:
npx playwright show-trace trace.zipTrace에는 스크린샷, 네트워크 로그, 콘솔 출력, DOM 스냅샷이 모두 포함된다. 실패 원인을 파악하는 가장 빠른 방법이다.
npx playwright codegen http://localhost:3000브라우저에서 클릭, 입력 등을 하면 코드가 자동 생성된다. 처음 테스트를 작성할 때 좋은 시작점이 되지만, 생성된 코드는 반드시 시맨틱 Locator로 리팩토링해야 한다.
// BAD - 환경에 따라 불안정
await page.waitForTimeout(3000)
// GOOD - 조건이 충족되면 즉시 통과
await expect(page.getByText('로딩 완료')).toBeVisible()Playwright의 assertion은 자동으로 재시도한다 (기본 5초). waitForTimeout은 거의 필요 없다.
// BAD - 테스트 순서에 의존
test('아이템 생성', async ({ page }) => { /* ... */ })
test('생성된 아이템 수정', async ({ page }) => { /* 위 테스트에 의존! */ })
// GOOD - 각 테스트가 독립적
test('아이템 수정', async ({ page }) => {
// 테스트에 필요한 상태를 직접 설정
await page.goto('/items/123/edit')
// ...
})E2E 테스트는 비용이 높다. 유틸 함수의 엣지 케이스는 단위 테스트로, 컴포넌트의 상태 변화는 컴포넌트 테스트로 검증하고, E2E는 핵심 사용자 흐름에만 집중하자.
Playwright의 Fixture 시스템은 테스트에 필요한 공통 컨텍스트를 주입하는 강력한 메커니즘이다.
// e2e/fixtures/base.ts
import { test as base } from '@playwright/test'
export const test = base.extend<{
entityId: string
}>({
entityId: ['gcp', { option: true }],
})
export { expect } from '@playwright/test'// e2e/dashboard.spec.ts
import { test, expect } from './fixtures/base'
test('대시보드 로드', async ({ page, entityId }) => {
await page.goto(`/${entityId}/dashboard`)
await expect(page).toHaveURL(new RegExp(`/${entityId}/dashboard`))
})이렇게 하면 멀티 테넌트 앱에서 엔티티별 테스트를 일관되게 작성할 수 있다.
E2E 테스트는 도입 비용이 있지만, 한 번 안정적으로 구축하면 리팩토링과 기능 추가에 대한 자신감을 크게 높여준다.
시작할 때 추천하는 접근법:
storageState로 인증을 한 번에 해결한다getByRole 중심의 안정적인 Locator를 사용한다완벽한 커버리지를 목표로 하기보다, 깨지지 않는 소수의 테스트로 시작하는 것이 핵심이다.