Next.js E2E 테스트: Playwright 자동화 테스트 실전 가이드

버그 목록에 빨간색 ‘긴급’ 태그가 붙었습니다. 결제 흐름이 또 망가졌습니다. 테스트 환경에서는 멀쩡했지만 운영 환경에서는 작동하지 않았습니다.
지난주 배포 때 페이지 30개가 넘는 곳을 직접 눌러 보고, 폼도 10개 이상 작성하고, 브라우저 세 개를 오갔습니다. 그런데 모바일에서 맨 아래까지 스크롤해야 나타나는 버튼 하나를 놓쳤습니다. 곧 제품 매니저의 메시지가 떴습니다. “사용자가 쿠폰을 쓸 수 없다고 합니다.”
더는 수동으로 테스트할 수 없었습니다. 사람 손에 의존하는 테스트는 언젠가 나와 팀 모두를 지치게 만듭니다.
도구를 고르면서 Cypress, Selenium, Puppeteer, Playwright를 두루 살펴봤고 결국 Playwright를 선택했습니다. 여러 브라우저를 지원하고 설정도 Cypress보다 간단했습니다. 설치한 첫 주에 수동 테스트로는 한 번도 찾지 못했던 버그 다섯 개를 잡았습니다. Firefox에서만 발생하는 스타일 문제도 있었고, 비동기 API의 race condition으로 생긴 문제도 있었습니다.
처음에는 시행착오도 많았습니다. 설정 파일을 여러 번 고쳤고 테스트 케이스도 두 번 다시 작성했습니다. 하지만 지금은 전체 흐름이 안정됐고 CI/CD도 완전히 자동화했습니다. 커밋할 때마다 테스트가 실행되며 운영 버그는 절반 이하로 줄었습니다.
Playwright를 선택한 이유(vs Cypress)
Cypress를 사용해 본 사람이라면 설정이 간단하고 문서가 친절하며 커뮤니티도 활발하다는 사실을 잘 알 것입니다. 그런데도 제가 Cypress를 선택하지 않은 이유는 무엇일까요?
주로 세 가지가 걸렸습니다.
멀티 브라우저 지원이 약합니다. Cypress의 Firefox와 Safari 지원은 오랫동안 아쉬웠고 테스트도 주로 Chromium에서 실행됩니다. 별문제 아닌 것처럼 들리지만 저는 이 때문에 크게 데인 적이 있습니다. 결제 페이지가 Chrome에서는 완벽하게 작동했지만 Safari에서는 흰 화면만 나왔고, 원인은 Safari가 지원하지 않는 CSS 속성이었습니다. Playwright는 Chromium, Firefox, WebKit 세 엔진을 기본 지원하므로 테스트 세트 하나로 주요 브라우저를 커버할 수 있습니다.
테스트 속도가 빠릅니다. Playwright의 병렬 실행 능력은 훨씬 강력합니다. Cypress에서 테스트 50개를 직렬 실행하면 10분 넘게 걸리지만, Playwright에서 worker 8개로 병렬 실행하면 같은 테스트를 5분 만에 끝낼 수 있습니다. CI/CD에서는 1분에도 비용이 들기 때문에 이 차이는 꽤 큽니다.
API 설계가 자연스럽습니다. 솔직히 Cypress에서 Playwright로 처음 옮겼을 때는 조금 낯설었습니다. Cypress의 체이닝 문법은 쓰는 맛이 있습니다. 하지만 Playwright의 async/await를 한동안 사용해 보니 현대 JavaScript 작성 방식에 더 잘 맞고 Next.js Server Components 스타일과도 더 일관적이었습니다.
Cypress가 나쁜 도구라는 뜻은 아닙니다. 프로젝트가 Chrome만 테스트하면 되고 팀이 테스트에 익숙하지 않다면 Cypress가 확실히 더 쉽게 시작할 수 있습니다. 디버깅 도구가 훌륭하고 Time Travel 기능으로 테스트의 각 단계를 바로 볼 수 있어 초보자에게 친절합니다.
하지만 제 요구사항에는 Playwright가 더 적합했습니다.
- 크로스 브라우저 테스트가 필요함
- Next.js/React 경험이 있고
async/await에 익숙함 - CI 환경에서 빠른 피드백이 필요함
- API Routes와 SSR 페이지를 테스트하고 싶음
도구 선택에는 절대적인 정답이 없으며 상황에 따라 달라집니다. 프로젝트가 아직 작고 팀원의 테스트 경험이 적다면 Cypress로 빠르게 시작하세요. 이미 어느 정도 규모가 있고 자동화 테스트에 장기적으로 투자하려면 Playwright가 더 나은 선택입니다.
Next.js + Playwright 설정 실전
Playwright 설치는 매우 간단합니다. 다음 명령이면 충분합니다.
npm init playwright@latest
# 또는 pnpm 사용
pnpm create playwright
설치 과정에서 몇 가지 질문이 나오며 다음처럼 선택하는 것을 권합니다.
- TypeScript? Yes(타입 힌트가 시행착오를 줄여 주므로 강력히 권장)
- 테스트 디렉터리? tests(기본값이면 충분함)
- GitHub Actions? Yes(뒤에서 CI/CD에 사용)
설치가 끝나면 프로젝트에 다음 파일이 추가됩니다.
your-nextjs-project/
├── tests/ # 테스트 케이스 디렉터리
│ └── example.spec.ts
├── playwright.config.ts # Playwright 설정
└── .github/
└── workflows/
└── playwright.yml # CI 설정
설정 파일에서 겪은 시행착오
초기 playwright.config.ts는 일반 웹 프로젝트용이라 Next.js 프로젝트에서는 일부 조정이 필요합니다. 다음은 제가 6개월 동안 사용한 뒤 가장 안정적이라고 판단한 설정입니다.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// 测试目录
testDir: './tests',
// 全局超时:单个测试 30 秒
timeout: 30 * 1000,
// 全局期望超时:元素查找 5 秒
expect: {
timeout: 5000,
},
// 失败时重试次数(CI 环境建议开启)
retries: process.env.CI ? 2 : 0,
// 并行 worker 数量(我的机器是 8 核,所以设 4)
workers: process.env.CI ? 2 : 4,
// 测试报告
reporter: [
['html'], // 生成 HTML 报告
['list'], // 终端输出列表
process.env.CI ? ['github'] : ['list'], // CI 环境用 GitHub 格式
],
// 启动 Next.js 开发服务器
webServer: {
command: 'npm run dev',
port: 3000,
timeout: 120 * 1000, // Next.js 首次启动可能要编译,给足时间
reuseExistingServer: !process.env.CI, // 本地开发复用服务器,省时间
},
// 测试项目(多浏览器配置)
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'] },
},
],
// 全局配置
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // 失败时记录 trace,方便调试
screenshot: 'only-on-failure', // 失败时截图
video: 'retain-on-failure', // 失败时录屏
},
});
자주 빠지는 함정
-
webServer.timeout은 충분히 길어야 합니다. 처음에는 30초로 설정했지만 Next.js의 첫 콜드 스타트에는 컴파일이 필요해 자주 시간 초과가 났습니다. 지금은 120초로 설정해 안정적으로 실행합니다. -
로컬 개발에서는
reuseExistingServer: true를 설정하세요. 그렇지 않으면 테스트를 실행할 때마다 Next.js를 재시작해야 해서 오래 기다리게 됩니다. -
workers를 너무 많이 설정하지 마세요. 예전에 CPU 코어 수와 똑같이 설정했더니 테스트 중 컴퓨터가 멈추곤 했습니다. 지금은 코어 수의 절반으로 설정해 속도와 안정성을 모두 확보했습니다. -
모바일 테스트는 선택 사항입니다. Next.js 프로젝트가 반응형이라면 Mobile Chrome 테스트로 모바일 전용 버그를 찾을 수 있습니다. 다만 테스트 시간이 두 배가 될 수 있으므로 필요에 따라 결정하세요.
설정을 마쳤으면 공식 예제 테스트를 실행합니다.
npx playwright test
초록색 passed가 표시되면 환경 구성이 끝난 것입니다. 이제 실제 테스트 케이스를 작성할 수 있습니다.
페이지 상호작용 테스트 모범 사례(Page Object Model)
처음 테스트를 작성할 때는 모든 코드를 파일 하나에 넣었습니다. 로그인 페이지 하나를 테스트하는 데 100줄이 넘었고 page.locator, page.fill, page.click이 곳곳에 흩어져 있었습니다. 나중에 버튼 선택자 하나를 바꾸려니 테스트 파일 10개 이상을 모두 고쳐야 했습니다.
그 뒤 **Page Object Model(POM)**을 도입하자 코드가 몇 배나 깔끔해졌습니다. 간단히 말하면 페이지 조작을 클래스로 캡슐화하고 테스트 케이스에서는 요소를 직접 다루는 대신 메서드만 호출하는 방식입니다.
POM을 사용하지 않은 테스트(나쁜 예)
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test('用户登录', async ({ page }) => {
await page.goto('/login');
// 直接操作元素,代码重复
await page.locator('input[name="email"]').fill('[email protected]');
await page.locator('input[name="password"]').fill('password123');
await page.locator('button[type="submit"]').click();
await expect(page.locator('h1')).toContainText('Dashboard');
});
test('登录失败提示', async ({ page }) => {
await page.goto('/login');
// 又来一遍同样的操作...
await page.locator('input[name="email"]').fill('[email protected]');
await page.locator('input[name="password"]').fill('wrongpass');
await page.locator('button[type="submit"]').click();
await expect(page.locator('.error')).toBeVisible();
});
문제가 보이나요? 디자이너가 input[name="email"]을 input[id="email"]로 바꾸면 모든 테스트를 수정해야 합니다.
POM으로 리팩터링한 뒤(권장 방식)
먼저 Page Object를 만듭니다.
// tests/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
readonly dashboardTitle: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('input[name="email"]');
this.passwordInput = page.locator('input[name="password"]');
this.submitButton = page.locator('button[type="submit"]');
this.errorMessage = page.locator('.error');
this.dashboardTitle = page.locator('h1');
}
// 封装登录操作
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
// 封装导航操作
async goto() {
await this.page.goto('/login');
}
// 封装验证逻辑
async expectLoginSuccess() {
await this.dashboardTitle.waitFor();
await expect(this.dashboardTitle).toContainText('Dashboard');
}
async expectLoginError() {
await expect(this.errorMessage).toBeVisible();
}
}
테스트 케이스가 매우 간결해집니다.
// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('用户登录', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
await loginPage.expectLoginSuccess();
});
test('登录失败提示', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrongpass');
await loginPage.expectLoginError();
});
훨씬 낫지 않나요? 이제 선택자를 바꿀 때는 LoginPage.ts 하나만 수정하면 됩니다. 테스트 코드도 자연어처럼 읽혀 새 팀원도 바로 이해할 수 있습니다.
실제 프로젝트의 디렉터리 구조
tests/
├── pages/ # Page Objects
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
├── fixtures/ # 테스트 데이터와 헬퍼
│ └── testData.ts
├── auth.spec.ts # 인증 관련 테스트
├── checkout.spec.ts # 결제 흐름 테스트
└── dashboard.spec.ts # Dashboard 테스트
직접 겪은 시행착오와 조언
-
지나치게 캡슐화하지 마세요. 모든 페이지에 Page Object가 필요한 것은 아닙니다. 한 번만 테스트할 페이지라면 테스트 안에 바로 작성해도 됩니다. POM 자체가 목적이 되어서는 안 됩니다.
-
메서드 이름에 의미를 담으세요.
async fillLoginForm()이async fillForm()보다 이해하기 쉽습니다. 6개월 뒤 코드를 다시 볼 때 과거의 자신에게 고마워질 것입니다. -
대기 로직을 Page Object 안에 넣으세요. Playwright의 자동 대기는 영리하지만 때로는 수동
waitFor()가 필요합니다. 이 로직을 Page Object에 숨기면 테스트 케이스가 더 깔끔해집니다. -
테스트 데이터를 별도로 관리하세요. 사용자 이름과 비밀번호 같은 테스트 데이터는
fixtures/testData.ts에 모아 두면 관리하기 쉽습니다.
// tests/fixtures/testData.ts
export const testUsers = {
validUser: {
email: '[email protected]',
password: 'password123'
},
invalidUser: {
email: '[email protected]',
password: 'wrongpass'
}
};
테스트에서는 다음처럼 가져다 씁니다.
import { testUsers } from './fixtures/testData';
await loginPage.login(testUsers.validUser.email, testUsers.validUser.password);
이 패턴을 적용하니 코드 유지보수 비용이 눈에 띄게 줄었습니다. 지금은 Page Object 정의, 몇 줄의 테스트 케이스 작성, 완료라는 흐름으로 테스트를 만듭니다.
API Routes E2E 테스트
Next.js의 API Routes도 애플리케이션의 일부이므로 당연히 테스트해야 합니다. 예전에는 Postman으로 API를 하나씩 수동 테스트했지만 이제는 브라우저를 열지 않고도 Playwright에서 바로 테스트합니다.
Playwright는 HTTP 요청을 직접 보낼 수 있는 request 객체를 제공하며 Next.js API Routes 테스트에 특히 잘 맞습니다.
기본 API 테스트
사용자 목록을 가져오는 API를 테스트하는 간단한 예제부터 보겠습니다.
// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';
test.describe('用户 API 测试', () => {
test('GET /api/users - 获取用户列表', async ({ request }) => {
const response = await request.get('/api/users');
// 验证状态码
expect(response.status()).toBe(200);
// 验证响应格式
const users = await response.json();
expect(Array.isArray(users)).toBeTruthy();
expect(users.length).toBeGreaterThan(0);
// 验证数据结构
expect(users[0]).toHaveProperty('id');
expect(users[0]).toHaveProperty('email');
expect(users[0]).toHaveProperty('name');
});
test('POST /api/users - 创建用户', async ({ request }) => {
const newUser = {
email: '[email protected]',
name: 'Test User',
password: 'password123'
};
const response = await request.post('/api/users', {
data: newUser
});
expect(response.status()).toBe(201);
const createdUser = await response.json();
expect(createdUser.email).toBe(newUser.email);
expect(createdUser).not.toHaveProperty('password'); // 密码不应该返回
});
test('POST /api/users - 邮箱重复应返回错误', async ({ request }) => {
const duplicateUser = {
email: '[email protected]',
name: 'Duplicate User',
password: 'password123'
};
const response = await request.post('/api/users', {
data: duplicateUser
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.message).toContain('邮箱已存在');
});
});
인증이 필요한 API 테스트
실제 프로젝트의 많은 API는 로그인해야 접근할 수 있습니다. 먼저 token을 얻은 뒤 요청 헤더에 담아야 합니다.
// tests/api/auth.spec.ts
import { test, expect } from '@playwright/test';
let authToken: string;
test.describe('需要认证的 API', () => {
// 所有测试前先登录获取 token
test.beforeAll(async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: '[email protected]',
password: 'password123'
}
});
const { token } = await response.json();
authToken = token;
});
test('GET /api/profile - 获取用户资料', async ({ request }) => {
const response = await request.get('/api/profile', {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
expect(response.status()).toBe(200);
const profile = await response.json();
expect(profile.email).toBe('[email protected]');
});
test('未登录访问应返回 401', async ({ request }) => {
const response = await request.get('/api/profile');
expect(response.status()).toBe(401);
});
});
혼합 테스트: 페이지 + API
가장 강력한 방식은 페이지 테스트와 API 테스트를 결합하는 것입니다. 예를 들어 게시물 작성 기능의 전체 흐름은 다음처럼 테스트합니다.
// tests/posts.spec.ts
import { test, expect } from '@playwright/test';
test('发布文章完整流程', async ({ page, request }) => {
// 1. 先通过页面登录
await page.goto('/login');
await page.fill('input[name="email"]', '[email protected]');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// 2. 进入文章编辑页
await page.goto('/posts/new');
await page.fill('input[name="title"]', '测试文章标题');
await page.fill('textarea[name="content"]', '这是测试内容');
await page.click('button:has-text("发布")');
// 3. 等待跳转到文章详情页
await page.waitForURL(/\/posts\/\d+/);
// 4. 通过 API 验证文章确实创建成功
const url = page.url();
const postId = url.split('/').pop();
const response = await request.get(`/api/posts/${postId}`);
expect(response.status()).toBe(200);
const post = await response.json();
expect(post.title).toBe('测试文章标题');
expect(post.content).toBe('这是测试内容');
expect(post.status).toBe('published');
});
이 방식은 프론트엔드 상호작용을 테스트하는 동시에 백엔드 데이터까지 검증합니다. 저는 이 테스트 덕분에 화면에는 ‘게시 성공’이라고 표시되지만 데이터베이스의 게시물 상태는 draft로 남는 숨은 버그를 발견했습니다. 상태 업데이트 로직이 잘못된 것이 원인이었습니다.
실전 조언
-
API 테스트에는 경계 조건을 포함하세요. 정상 흐름뿐 아니라 필수 매개변수 누락, 잘못된 타입, 권한 부족도 테스트해야 합니다.
-
테스트 데이터를 정리하세요. API 테스트는 데이터베이스에 데이터를 기록하므로
afterAll에서 지워야 합니다. 저는 일반적으로 테스트 전용 데이터베이스를 사용하고 정기적으로 비웁니다.
test.afterAll(async ({ request }) => {
await request.delete('/api/test/cleanup');
});
-
외부 서비스를 Mock하세요. API가 결제나 문자 같은 외부 서비스를 호출한다면 테스트에서는 반드시 Mock해야 합니다. 그렇지 않으면 테스트할 때마다 비용이 발생합니다.
-
응답 시간을 확인하세요. Playwright에서는 요청 시간을 측정할 수 있으므로 API가 충분히 빠른지 검증하는 assertion을 추가합니다.
const start = Date.now();
await request.get('/api/users');
const duration = Date.now() - start;
expect(duration).toBeLessThan(1000); // 接口响应应该在 1 秒内
API 테스트와 페이지 테스트를 잘 결합하면 시나리오의 약 90%를 커버할 수 있습니다. 나머지 10%는 단위 테스트로 보완합니다.
GitHub Actions CI/CD 통합
테스트를 작성했다면 다음 단계는 CI/CD에 연결하는 것입니다. 커밋할 때마다 테스트가 자동으로 실행되면 정말 큰 도움이 됩니다. 자신 있게 커밋했는데 CI가 끝난 뒤 다른 기능을 망가뜨렸다는 사실을 발견한 적이 한두 번이 아닙니다.
다행히 npm init playwright가 GitHub Actions 설정 파일을 생성합니다. 다만 기본 설정은 단순하므로 실제 상황에 맞게 조정하는 편이 좋습니다.
기본 CI 설정
처음 생성되는 .github/workflows/playwright.yml을 살펴보겠습니다.
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- 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@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
이 설정도 작동하지만 몇 가지 문제가 있습니다.
- 실행할 때마다 브라우저를 설치해 느립니다.
- 테스트 데이터베이스가 없어 API 테스트가 실패합니다.
- 보고서를 내려받아야만 볼 수 있어 불편합니다.
실제 운영용 설정
다음은 제가 실제로 사용하는 설정으로 캐시, 데이터베이스, 보고서 배포를 추가했습니다.
name: E2E Tests
on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
services:
# 测试数据库(PostgreSQL)
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v3
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run database migrations
run: npm run db:migrate
- name: Run Playwright tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
# 如果是主分支,把报告部署到 GitHub Pages
- name: Deploy report to GitHub Pages
if: always() && github.ref == 'refs/heads/main'
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./playwright-report
설정 핵심 설명
-
서비스 컨테이너(services): PostgreSQL을 테스트 데이터베이스로 사용해 API 테스트가 정상 실행되도록 했습니다. MySQL을 사용한다면
mysql:8로 바꾸면 됩니다. -
브라우저 캐시:
actions/cache가 Playwright 브라우저 파일을 캐시합니다. 첫 실행은 느리지만 이후에는 빨라집니다. CI에서 브라우저 세 개를 모두 실행하면 너무 느리므로 저는chromium만 설치합니다. -
데이터베이스 마이그레이션: 테스트 전에
npm run db:migrate로 테이블을 만듭니다.package.json에 다음 script를 설정해야 합니다.
{
"scripts": {
"db:migrate": "prisma migrate deploy"
}
}
- 보고서 배포: main 브랜치의 테스트 보고서를 GitHub Pages에 자동 배포하므로 팀원이 파일을 내려받지 않고 온라인에서 볼 수 있습니다.
환경 변수 설정
테스트 환경에는 API key나 secret이 필요할 수 있습니다. GitHub 저장소의 Settings → Secrets에서 다음과 같이 설정합니다.
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_KEY }}
실패했을 때 디버깅하기
CI가 실패하면 어떻게 해야 할까요? Playwright에는 유용한 기능이 몇 가지 있습니다.
-
trace 확인: 설정 파일에
trace: 'on-first-retry'를 지정했으므로 실패 시 trace 파일이 생성됩니다. 내려받은 뒤npx playwright show-trace trace.zip으로 전체 테스트 과정을 재생할 수 있습니다. -
스크린샷과 동영상 확인: 실패 시 자동으로 스크린샷을 찍고 녹화하므로 당시 페이지 상태를 바로 볼 수 있습니다.
-
로컬에서 CI 환경 재현:
act도구를 사용하면 GitHub Actions를 로컬에서 실행해 더 빠르게 디버깅할 수 있습니다.
# act 설치
brew install act # macOS
# 또는
choco install act # Windows
# workflow 실행
act -j test
직접 겪은 문제
-
시간 제한을 합리적으로 설정하세요. 처음에는 30분으로 설정했는데 테스트 하나가 멈추면서 CI 시간을 많이 낭비했습니다. 지금은 60분으로 설정하고 특히 느린 테스트를 따로 모니터링합니다.
-
병렬 실행 수를 너무 늘리지 마세요. CI 머신 성능은 보통 수준이므로 worker를 너무 많이 열면 오히려 느려집니다. 저는 worker 2개를 사용합니다.
-
실패 재시도 횟수를 과도하게 늘리지 마세요.
retries: 2는 간헐적인 네트워크 흔들림에 대응하기 위한 값입니다. 테스트 자체에 문제가 있다면 아무리 재시도해도 해결되지 않고 CI 시간만 늘어납니다.
CI/CD를 연결한 뒤 코드 품질이 크게 좋아졌습니다. 이제 모든 PR은 초록색 체크가 있어야 병합할 수 있어 팀 전체가 테스트를 중요하게 여기게 됐습니다.
테스트 커버리지와 보고서 생성
테스트가 끝나면 결과를 확인해야 합니다. Playwright의 테스트 보고서는 정보가 풍부하고 직관적입니다.
HTML 보고서(가장 자주 사용)
테스트 실행 후 다음 명령을 실행합니다.
npx playwright show-report
모든 테스트 결과를 보여 주는 로컬 웹페이지가 자동으로 열립니다. 보고서에는 다음 정보가 포함됩니다.
- 각 테스트의 성공/실패 상태
- 실행 시간
- 실패한 테스트의 스크린샷과 동영상
- 전체 과정을 재생할 수 있는 Trace 파일
제가 가장 좋아하는 기능은 Trace Viewer입니다. 실패한 테스트를 클릭하면 네트워크 요청, DOM 스냅샷, 콘솔 로그를 포함해 테스트 실행의 모든 단계를 볼 수 있습니다. 타임머신처럼 문제 지점을 정확히 찾게 해 줍니다.
테스트 커버리지
E2E 테스트의 커버리지는 코드 커버리지가 아니라 기능 커버리지라는 점에서 조금 다릅니다. 저는 어떤 기능에 테스트가 있는지 다음과 같은 표로 관리합니다.
## 测试覆盖清单
### 用户认证
- [x] 登录(正常流程)
- [x] 登录失败(错误密码)
- [x] 注册
- [x] 找回密码
- [ ] 第三方登录(Google)
### 商品管理
- [x] 添加商品
- [x] 编辑商品
- [x] 删除商品
- [ ] 批量导入
### 订单流程
- [x] 加购物车
- [x] 结算
- [x] 支付(模拟环境)
- [ ] 退款流程
이 체크리스트는 프로젝트의 tests/README.md에 두고 새 기능을 추가할 때마다 갱신합니다. 어떤 기능이 아직 테스트되지 않았는지 한눈에 알 수 있습니다.
코드 커버리지(선택 사항)
정말 코드 커버리지를 확인하고 싶다면 Playwright에서도 가능합니다. Istanbul 또는 v8 coverage를 설정해야 합니다.
// playwright.config.ts
export default defineConfig({
use: {
// 启用代码覆盖率
trace: 'on',
// 注入覆盖率收集代码
contextOptions: {
recordVideo: {
dir: 'test-results/videos'
}
}
}
});
하지만 솔직히 E2E 테스트에서는 코드 커버리지를 거의 보지 않습니다. 단위 테스트가 이미 핵심 로직을 커버하고 있으므로 E2E 테스트에서는 기능 흐름이 정상적으로 이어지는지에 더 집중합니다.
사용자 정의 보고서
때로는 테스트 결과를 Slack이나 DingTalk로 보내 팀에 알려야 합니다. Playwright는 사용자 정의 reporter를 지원합니다.
// my-reporter.ts
import { Reporter } from '@playwright/test/reporter';
class SlackReporter implements Reporter {
onEnd(result) {
const passed = result.suites.filter(s => s.ok).length;
const failed = result.suites.length - passed;
// 发送到 Slack
fetch('https://hooks.slack.com/services/YOUR_WEBHOOK', {
method: 'POST',
body: JSON.stringify({
text: `测试完成:${passed} 通过,${failed} 失败`
})
});
}
}
export default SlackReporter;
설정 파일에서 활성화합니다.
// playwright.config.ts
export default defineConfig({
reporter: [
['html'],
['./my-reporter.ts']
]
});
테스트 추세 확인
테스트의 장기 추세, 예를 들어 성공률이나 실행 시간 변화를 보고 싶다면 Playwright Test Runner 온라인 도구를 사용할 수 있습니다. CI에서 만든 trace 파일을 업로드하면 테스트 품질을 시각적으로 분석할 수 있습니다.
하지만 저는 더 단순한 방법을 선호합니다. CI가 끝날 때마다 성공률과 실행 시간을 CSV 파일에 기록하고 Google Sheets에서 차트를 만듭니다. 간단하고 실용적입니다.
보고서를 활용하는 방식
-
로컬 개발: 터미널 출력을 바로 확인하고 실패하면
--debug모드로 다시 실행합니다.npx playwright test --debug -
PR 검토: CI의 HTML 보고서에서 실패한 테스트와 실행 시간을 중점적으로 봅니다. 특정 테스트가 계속 시간 초과된다면 코드에 문제가 있을 수 있습니다.
-
정기 검토: 매주 테스트 커버리지 체크리스트를 확인하고 빠진 테스트 케이스를 보충합니다.
테스트 보고서는 단순한 숫자가 아닙니다. 문제를 찾고 개발 프로세스를 개선하는 도구입니다.
결론
6개월 전 새벽 3시까지 수동 테스트하던 밤을 떠올리면 지금은 정말 여유로워졌습니다.
Playwright와 Next.js 조합을 사용하며 가장 크게 느낀 장점은 마음이 편해진다는 것입니다. 한 번 설정하면 이후에는 거의 손댈 일이 없습니다. 코드를 커밋할 때마다 CI가 자동으로 테스트하고, 배포할 때마다 확신을 가질 수 있습니다. 운영 버그도 크게 줄었고 제품 매니저의 긴급 연락도 줄었습니다.
Next.js 프로젝트에서 아직 수동 테스트를 하고 있다면 다음 순서로 시작해 보세요.
- 핵심 흐름부터 시작하세요. 모든 기능을 한꺼번에 커버하려 하지 말고 로그인과 결제 같은 핵심 경로부터 테스트합니다.
- Page Object Model을 사용하세요. 처음에는 번거롭게 느껴져도 장기적으로 시간을 크게 아껴 줍니다.
- CI/CD에 연결하세요. 자동화 테스트를 자동으로 실행하지 않는다면 작성 효과가 반감됩니다.
- 100% 커버리지에 집착하지 마세요. 중요한 기능부터 확실히 테스트하면 됩니다.
마지막으로 작은 깨달음 하나를 덧붙이겠습니다. E2E 테스트는 단순한 기술 도구가 아니라 팀 협업 방식이기도 합니다. 팀 전체가 코드 품질을 중요하게 여기게 하고, 테스트 자체가 가장 좋은 문서가 되어 소통 비용을 줄이며, 배포를 예측 가능한 일로 바꿉니다.
이제 저는 매일 오후 5시 30분이면 퇴근할 수 있습니다. 그렇게 아낀 시간으로 드디어 헬스장에 갈 수 있게 됐습니다. 예전에 끊어 둔 연간 회원권이 곧 만료될 뻔했습니다.
지금 테스트 작성을 시작하세요. 미래의 자신이 고마워할 것입니다.
FAQ
Playwright와 Cypress 중 무엇을 선택해야 하나요?
• Playwright: Chromium, Firefox, WebKit 크로스 브라우저 테스트, 빠른 병렬 실행, async/await 문법을 지원하며 중대형 프로젝트에 적합합니다. worker 8개 기준으로 Cypress보다 약 3배 빠릅니다.
• Cypress: Chrome 중심이며 Time Travel 디버깅이 강력하고 커뮤니티 자료가 풍부해 초보자가 시작하기 쉽습니다.
Chrome만 테스트하고 팀의 테스트 경험이 적다면 Cypress를, 크로스 브라우저 지원과 빠른 CI가 필요하고 팀이 React/Next.js에 익숙하다면 Playwright를 선택하세요.
Page Object Model을 반드시 사용해야 하나요?
프로젝트가 작거나 테스트 케이스가 10개 미만이고 해당 페이지를 한 번만 테스트한다면 테스트 안에 직접 작성해도 됩니다. 하지만 여러 테스트가 같은 페이지를 조작하거나, 여러 사람이 테스트 코드를 유지보수하거나, 프로젝트를 장기간 발전시킬 계획이라면 POM이 시행착오를 크게 줄여 줍니다. 선택자를 한 번 바꿔 보면 가치를 바로 알 수 있습니다. POM이 없으면 10개 이상의 파일을 바꿔야 하지만 POM이 있으면 파일 하나만 수정하면 됩니다.
CI 환경에서 테스트가 계속 시간 초과될 때는 어떻게 하나요?
• webServer.timeout이 너무 짧음: Next.js 콜드 스타트 시 컴파일이 필요하므로 120초로 늘립니다.
• worker 수가 너무 많음: CI 머신 성능을 고려해 2~4개로 설정합니다.
• 테스트 자체의 문제: trace 파일로 네트워크 요청이 느린지, 요소 대기가 시간 초과됐는지 확인합니다.
• 브라우저 설치가 느림: actions/cache로 브라우저 파일을 캐시합니다.
CI에서는 chromium만 실행하고 여러 브라우저 테스트는 로컬에서 수행하면 훨씬 빨라집니다.
테스트 데이터는 어떻게 관리하나요? 매번 데이터베이스를 수동으로 정리해야 하나요?
• 독립된 테스트 데이터베이스: 테스트 전용으로 사용하고 정기적으로 비워 개발 환경에 영향을 주지 않습니다.
• 각 테스트 후 정리: test.afterAll()에서 정리 API를 호출하지만 누락될 수 있습니다.
• Docker 컨테이너 사용: 테스트마다 새 데이터베이스 컨테이너를 시작하고 끝나면 폐기합니다. 가장 깨끗하지만 느립니다.
저는 첫 번째와 두 번째를 조합합니다. CI에서는 Docker 데이터베이스 컨테이너를 사용하고, 로컬에서는 독립 테스트 데이터베이스와 afterAll 정리를 함께 사용합니다.
API 테스트에서 외부 서비스를 Mock해야 하나요?
Playwright의 네트워크 가로채기로 API 응답을 Mock할 수 있습니다.
await page.route('**/api/payment', route => route.fulfill({ status: 200, body: '{"success": true}' }));
또는 Next.js API Routes에서 환경 변수를 확인해 테스트 환경에서는 모의 데이터를 바로 반환할 수 있습니다.
테스트 커버리지는 어느 정도여야 충분한가요?
우선순위는 다음과 같습니다.
• 핵심 기능(로그인, 결제, 주문): 100% 커버
• 자주 쓰는 기능(상품 탐색, 장바구니 추가): 80% 이상
• 사용 빈도가 낮은 기능(비밀번호 찾기, 환불): 50% 이상
• 부가 기능(테마, 언어 전환): 선택 사항
100% 전체 커버리지에 집착하지 말고 중요한 기능부터 잡으세요. 제 프로젝트는 핵심 흐름을 100%, 전체 기능을 60% 커버하며 이미 버그의 90%를 차단하고 있습니다.
Playwright 테스트를 작성한 뒤에도 단위 테스트가 필요한가요?
• E2E 테스트(Playwright): 기능 흐름, 사용자 상호작용, 프론트엔드와 백엔드 통합을 검증합니다. 느리지만 포괄적입니다.
• 단위 테스트(Jest/Vitest): 함수 로직, 경계 조건, 오류 처리를 검증합니다. 빠르지만 범위가 국소적입니다.
이상적인 비율은 단위 테스트 70%, E2E 테스트 30%입니다. 핵심 유틸리티 함수, hooks, 컴포넌트 로직은 단위 테스트로, 완전한 사용자 흐름은 E2E 테스트로 검증하세요. 단위 테스트는 문제 위치를 빠르게 찾게 해 주고 E2E 테스트는 기능이 실제로 작동하는지 보장합니다.
8분 읽기 · 게시일: 2026년 1월 7일 · 수정일: 2026년 9월 4일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js 단위 테스트 실전: Jest + React Testing Library 완전 설정 가이드
Next.js 15 테스트 환경을 처음부터 설정하고 Jest + React Testing Library 구성, Client/Server Components 테스트, Hook 테스트, Mock 기법과 자주 발생하는 문제 해결 방법을 전체 코드 예제와 함께 알아봅니다.
45편 중 31편
다음
Next.js 이커머스 실전: 장바구니와 Stripe 결제 완전 구현 가이드
Zustand와 Stripe로 이커머스 장바구니 및 결제 시스템을 구축하는 방법을 단계별로 설명합니다. 상태 관리 선택, Checkout Session 생성, Webhook 주문 처리 전체 흐름과 바로 사용할 수 있는 코드 예제를 제공합니다.
45편 중 33편



댓글
GitHub로 로그인하여 댓글을 남기세요