4편까지 정적 테스트, 단위 테스트, 통합 테스트를 다뤘다. 이 세 가지로 코드 품질, 개별 함수의 동작, 모듈 간의 연결을 검증할 수 있었다. 하지만 이 테스트들은 모두 가짜 환경에서 실행된다. jsdom이라는 시뮬레이션 위에서 돌아가기 때문에, 실제 브라우저에서 사용자가 겪는 경험과는 차이가 있다.
E2E(End-to-End) 테스트는 이 간극을 메운다. 진짜 브라우저를 띄우고, 진짜 서버에 접속해서, 사용자가 하는 것과 똑같이 클릭하고 입력하고 페이지를 이동한다. 가장 현실적이고 강력한 테스트다.
왜 Playwright인가
E2E 테스트 도구로 대표적인 것이 Cypress와 Playwright 두 가지다.
Cypress는 오랫동안 프론트엔드 E2E의 대명사였다. 하지만 Chromium 기반 브라우저만 완벽히 지원하고, 멀티 탭이나 멀티 도메인 테스트에 제약이 있으며, 실행 속도가 느린 편이다.
Playwright는 Microsoft가 만든 도구로, Chromium, Firefox, WebKit(Safari) 세 가지 브라우저를 모두 지원한다. 테스트를 병렬로 실행할 수 있어 속도가 빠르고, auto-waiting 기능이 있어서 “요소가 나타날 때까지 기다려라” 같은 코드를 직접 쓸 필요가 거의 없다. Next.js 공식 문서에서도 E2E 테스트 도구로 Playwright를 안내하고 있다.
설치 및 설정
npm install -D @playwright/test
npx playwright install
두 번째 명령어는 테스트에 필요한 브라우저(Chromium, Firefox, WebKit)를 다운로드한다. 시간이 좀 걸릴 수 있다.
프로젝트 루트에 playwright.config.ts 파일을 생성한다.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30000,
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'html' : 'list',
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
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: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
설정 파일의 주요 항목을 짚어보자.
testDir은 E2E 테스트 파일이 위치할 폴더다. 단위/통합 테스트(src 안의 .test.ts 파일)와 분리하기 위해 별도 폴더를 쓴다.
fullyParallel을 true로 설정하면 테스트 파일들이 병렬로 실행되어 속도가 빨라진다.
retries는 CI 환경에서 실패한 테스트를 자동으로 재시도하는 횟수다. E2E 테스트는 네트워크 등 외부 요인으로 간헐적으로 실패할 수 있기 때문에, CI에서는 2회 재시도를 주는 것이 일반적이다.
screenshot을 only-on-failure로 설정하면 테스트가 실패했을 때만 스크린샷을 저장한다. 디버깅할 때 매우 유용하다.
webServer 설정이 핵심이다. 테스트를 실행하기 전에 자동으로 npm run dev를 실행해서 개발 서버를 띄워준다. 수동으로 서버를 먼저 켜지 않아도 되는 것이다.
projects에서 세 가지 브라우저를 설정했는데, 처음에는 chromium만 남기고 나머지를 주석 처리해도 된다. 세 브라우저를 모두 돌리면 시간이 3배 걸리므로, 개발 중에는 하나만 쓰고 CI에서만 전체를 돌리는 전략이 현실적이다.
package.json에 스크립트를 추가한다.
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
--ui 옵션을 붙이면 브라우저 기반 UI가 열려서 테스트 실행 과정을 시각적으로 확인할 수 있다. 처음 E2E 테스트를 작성할 때 이 모드로 보면 이해가 빠르다.
폴더 구조
E2E 테스트는 단위/통합 테스트와 성격이 다르므로 별도 폴더에 둔다.
my-project/
├── src/
│ ├── components/
│ │ ├── Counter.tsx
│ │ └── Counter.test.tsx ← 단위/통합 테스트
│ └── utils/
│ ├── formatPrice.ts
│ └── formatPrice.test.ts ← 단위 테스트
├── e2e/
│ ├── home.spec.ts ← E2E 테스트
│ ├── login.spec.ts
│ └── checkout.spec.ts
├── playwright.config.ts
└── vitest.config.ts
E2E 테스트 파일은 .spec.ts로 끝내는 것이 관례다. 단위/통합의 .test.ts와 구분하기 위해서다.
첫 번째 E2E 테스트
홈페이지에 접속해서 기본적인 요소가 보이는지 확인하는 간단한 테스트부터 시작하자.
// e2e/home.spec.ts
import { test, expect } from '@playwright/test';
test('홈페이지가 정상적으로 로드된다', async ({ page }) => {
await page.goto('/');
// 페이지 제목 확인
await expect(page).toHaveTitle(/My App/);
// 주요 요소가 보이는지 확인
await expect(page.locator('nav')).toBeVisible();
await expect(page.getByRole('link', { name: '로그인' })).toBeVisible();
});
실행해보자.
npm run test:e2e
Playwright가 자동으로 개발 서버를 띄우고, 브라우저를 열고, 테스트를 실행한다. 터미널에 결과가 표시된다.
실전: 로그인 플로우 테스트
실제 사용자 시나리오를 테스트해보자. 로그인 페이지에 접속해서 정보를 입력하고, 로그인 버튼을 누르고, 결과를 확인하는 전체 흐름이다.
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('로그인', () => {
test('올바른 정보로 로그인하면 대시보드로 이동한다', async ({ page }) => {
// 1. 로그인 페이지 접속
await page.goto('/login');
// 2. 이메일 입력
await page.getByLabel('이메일').fill('test@test.com');
// 3. 비밀번호 입력
await page.getByLabel('비밀번호').fill('1234');
// 4. 로그인 버튼 클릭
await page.getByRole('button', { name: '로그인' }).click();
// 5. 대시보드로 이동했는지 확인
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('환영합니다')).toBeVisible();
});
test('틀린 정보로 로그인하면 에러 메시지가 표시된다', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('wrong@test.com');
await page.getByLabel('비밀번호').fill('wrong');
await page.getByRole('button', { name: '로그인' }).click();
// 페이지가 이동하지 않고 에러 메시지가 표시됨
await expect(page).toHaveURL('/login');
await expect(
page.getByText('이메일 또는 비밀번호가 틀렸습니다'),
).toBeVisible();
});
test('이메일을 입력하지 않으면 유효성 검사 메시지가 표시된다', async ({
page,
}) => {
await page.goto('/login');
// 이메일을 비워두고 로그인 시도
await page.getByLabel('비밀번호').fill('1234');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByText('이메일을 입력해주세요')).toBeVisible();
});
});
코드를 읽어보면 사람이 테스트하는 과정과 거의 동일하다는 것을 알 수 있다. “페이지에 가서, 이걸 입력하고, 저걸 클릭하고, 이게 보이는지 확인한다.” 이것이 E2E 테스트의 장점이자 직관적인 이유다.
실전: 상품 검색과 장바구니
조금 더 복잡한 시나리오를 테스트해보자. 여러 페이지에 걸친 사용자 플로우다.
// e2e/shopping.spec.ts
import { test, expect } from '@playwright/test';
test('상품을 검색하고 장바구니에 담는다', async ({ page }) => {
// 홈에서 시작
await page.goto('/');
// 검색
await page.getByPlaceholder('상품을 검색하세요').fill('키보드');
await page.getByRole('button', { name: '검색' }).click();
// 검색 결과 확인
await expect(page.getByText('키보드')).toBeVisible();
// 상품 클릭
await page.getByText('기계식 키보드').click();
// 상품 상세 페이지 확인
await expect(page).toHaveURL(/\/products\/\d+/);
await expect(page.getByText('기계식 키보드')).toBeVisible();
// 장바구니에 담기
await page.getByRole('button', { name: '장바구니 담기' }).click();
// 장바구니 알림 확인
await expect(page.getByText('장바구니에 추가되었습니다')).toBeVisible();
// 장바구니 페이지로 이동
await page.getByRole('link', { name: '장바구니' }).click();
// 장바구니에 상품이 있는지 확인
await expect(page).toHaveURL('/cart');
await expect(page.getByText('기계식 키보드')).toBeVisible();
});
이 테스트 하나로 검색 기능, 상품 상세 페이지, 장바구니 추가, 페이지 네비게이션이 모두 정상 동작하는지 확인할 수 있다. 수동으로 하면 1~2분 걸리는 작업이 자동으로 몇 초 만에 끝난다.
자주 쓰는 Playwright API
테스트를 작성하면서 자주 쓰게 되는 API를 정리한다.
요소 선택
// 역할(role)로 선택 — 가장 권장되는 방식
page.getByRole('button', { name: '로그인' });
page.getByRole('link', { name: '회원가입' });
// 라벨로 선택 — 폼 요소에 적합
page.getByLabel('이메일');
// 텍스트로 선택
page.getByText('환영합니다');
// placeholder로 선택
page.getByPlaceholder('검색어를 입력하세요');
// test-id로 선택 — 위 방법으로 안 될 때 최후의 수단
page.getByTestId('submit-button');
요소를 선택할 때는 사용자가 인식하는 방식으로 선택하는 것이 원칙이다. 사용자는 CSS 클래스를 모른다. “로그인 버튼”을 클릭하는 것이지 .btn-primary를 클릭하는 것이 아니다. getByRole과 getByLabel을 우선 사용하고, 정말 안 될 때만 getByTestId를 쓴다.
동작
// 클릭
await page.getByRole('button', { name: '확인' }).click();
// 텍스트 입력
await page.getByLabel('이메일').fill('test@test.com');
// 기존 값 지우고 입력
await page.getByLabel('이메일').clear();
await page.getByLabel('이메일').fill('new@test.com');
// 키보드 입력
await page.keyboard.press('Enter');
// 페이지 이동
await page.goto('/about');
// 드롭다운 선택
await page.getByLabel('카테고리').selectOption('electronics');
검증
// 요소가 보이는지
await expect(page.getByText('환영합니다')).toBeVisible();
// 요소가 없는지
await expect(page.getByText('에러')).not.toBeVisible();
// URL 확인
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/\/products\/\d+/); // 정규식 가능
// 페이지 제목 확인
await expect(page).toHaveTitle('My App');
// 요소의 텍스트 확인
await expect(page.getByRole('heading')).toHaveText('대시보드');
// 요소 개수 확인
await expect(page.getByRole('listitem')).toHaveCount(5);
실패 디버깅
E2E 테스트가 실패하면 원인을 찾아야 한다. Playwright는 강력한 디버깅 도구를 제공한다.
스크린샷
playwright.config.ts에서 screenshot: 'only-on-failure'를 설정했으므로, 실패 시 자동으로 스크린샷이 저장된다. test-results/ 폴더에서 확인할 수 있다.
트레이스 뷰어
트레이스는 테스트 실행 과정을 타임라인으로 기록한 것이다. 매 단계의 스크린샷, DOM 스냅샷, 네트워크 요청을 모두 볼 수 있어서 “어디서 왜 실패했는지”를 정확히 파악할 수 있다.
npx playwright show-trace test-results/login-로그인-chromium/trace.zip
UI 모드
npm run test:e2e:ui
브라우저에서 테스트를 하나씩 실행하면서 각 단계를 시각적으로 확인할 수 있다. 테스트를 처음 작성하거나 실패 원인을 찾을 때 가장 유용하다.
헤드 모드
기본적으로 Playwright는 브라우저를 화면에 표시하지 않는(headless) 모드로 실행된다. 실제 브라우저 화면을 보면서 테스트가 실행되는 것을 확인하고 싶다면 헤드 모드로 실행한다.
npx playwright test --headed
어떤 시나리오에 E2E를 작성할 것인가
E2E 테스트는 강력하지만 느리고 비용이 크다. 모든 기능에 E2E를 작성하면 테스트 시간이 30분, 1시간으로 늘어나고 결국 팀이 CI 결과를 무시하게 된다.
핵심 사용자 플로우에만 집중하는 것이 원칙이다. 선정 기준은 이렇다.
“이 기능이 깨지면 매출이나 사용자 경험에 즉각적인 타격이 있는가?”에 해당하면 E2E를 작성한다. 회원가입, 로그인, 핵심 기능(쇼핑몰이라면 결제 플로우), 결제 같은 것들이다.
반대로 “이 기능이 깨져도 사용자가 즉시 알아차리지 못하거나 대안이 있는가?”에 해당하면 단위/통합 테스트로 충분하다. 프로필 수정, 다크모드 전환, 알림 설정 같은 것들이다.
일반적으로 서비스 전체에서 E2E 테스트는 5~15개 정도면 충분한 경우가 많다.
다음 편 예고
4가지 유형의 테스트를 모두 작성하는 방법을 배웠다. 하지만 로컬에서 테스트를 돌리는 것만으로는 충분하지 않다. 코드를 push할 때마다 자동으로 모든 테스트가 실행되어야 진짜 안전망이 된다. 다음 편에서는 GitHub Actions를 사용해서 전체 테스트 파이프라인을 자동화하는 방법을 다룬다.