Next.js: E2E 测试:Playwright
最后更新:2026-08-26
E2E 测试是模拟真实用户操作的"全链路演练"——它告诉你应用在浏览器里到底好不好用。
1. 你将学到
- 配置 Playwright 并让 Next.js 开发服务器自动启动(webServer 配置)
- 使用 Page Object Model(POM)设计可维护的测试代码
- 编写关键路径测试:页面导航、表单提交、认证流程
- 利用
toHaveScreenshot()进行视觉回归测试 - 通过
page.route()拦截 API 请求实现隔离测试 - 将 Playwright 集成到 GitHub Actions CI 流水线
2. 一个测试工程师的真实故事
(1) 痛点:手动测试覆盖不全,生产事故频发
Diana 是 TaskFlow 团队的测试工程师。团队维护着一个服务于 10,000+ 用户的 SaaS 平台,每次发布前需要 3 名 QA 手动测试 200+ 用例——这需要整整两天。
上周的发布中,一个"修改项目名称"的功能在 Chrome 上一切正常,但在 Safari 上输入框无法聚焦。QA 团队只测试了 Chrome,上线后收到 50+ 投诉。
手动测试的问题很明显:
| 问题 | 影响 |
|---|---|
| 浏览器覆盖不全 | 只测 Chrome,遗漏 Safari/Firefox |
| 回归周期长 | 每次发布需要 2 天手动测试 |
| 结果不稳定 | 不同测试人员操作方式不同 |
| 无法重复验证 | bug 修复后需要重新测一遍 |
(2) Playwright E2E 的解法
Diana 引入了 Playwright,用代码替代手动操作:
test('user can create a project', async ({ page }) => {
await page.goto('/projects')
await page.getByRole('button', { name: 'New Project' }).click()
await page.getByPlaceholder('Project name').fill('E-commerce App')
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.getByText('E-commerce App')).toBeVisible()
})
(3) 收益
| 维度 | 手动测试 | Playwright E2E |
|---|---|---|
| 测试时长 | 2 天 | 15 分钟 |
| 浏览器覆盖 | Chrome 仅 | Chrome + Firefox + Safari + Edge |
| 回归周期 | 每次全量 | 每次增量 + CI 自动 |
| bug 逃逸率 | 15% | < 2% |
| 测试可重复性 | 低(人为差异) | 高(100% 一致) |
3. Playwright 环境搭建
Playwright 是一个由 Microsoft 维护的跨浏览器自动化测试框架,支持 Chromium、Firefox、WebKit。
graph TB
A[playwright.config.ts] --> B[webServer 配置]
A --> C[浏览器配置]
A --> D[测试目录]
B --> E[自动启动 npm run dev]
B --> F[等待端口 3000 可用]
C --> G[Chromium / Firefox / WebKit]
C --> H[视口 1280x720]
style A fill:#cce5ff
style B fill:#d4edda
| 配置文件 | 作用 | 关键选项 |
|---|---|---|
playwright.config.ts |
E2E 主配置 | webServer 自动管理生命周期 |
playwright/index.html |
组件测试入口 | ct.target 配置 |
.github/workflows/playwright.yml |
CI 集成 | npx playwright install --with-deps |
(1) 安装与初始化
npm init playwright@latest
# 选择:
# ✓ TypeScript
# ✓ E2E tests
# ✓ Add GitHub Actions workflow
# ✓ Install browsers (Chromium, Firefox, WebKit)
(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'],
['json', { outputFile: 'playwright-report/results.json' }]
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30000
}
})
(3) 目录结构
├── e2e/
│ ├── auth.setup.ts # 认证预处理
│ ├── models/
│ │ ├── LoginPage.ts # 登录页 POM
│ │ ├── ProjectPage.ts # 项目页 POM
│ │ └── TaskPage.ts # 任务页 POM
│ ├── navigation.spec.ts # 导航测试
│ ├── auth.spec.ts # 认证测试
│ ├── projects.spec.ts # 项目 CRUD
│ └── visual.spec.ts # 视觉回归
├── playwright.config.ts
├── playwright-report/
└── test-results/
▶ 示例:验证 Playwright 可运行
// e2e/example.spec.ts
import { test, expect } from '@playwright/test'
test('homepage has correct title', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/TaskFlow/)
await expect(page.locator('h1')).toContainText('Welcome')
})
npx playwright test --project=chromium --headed
Running 1 test using 1 worker
✓ e2e/example.spec.ts:3:1 › homepage has correct title (2.3s)
4. Page Object Model(POM)设计模式
POM 将页面交互逻辑封装到独立的类中,测试代码只关心"做什么"而不是"怎么做"。
graph TB
A[测试用例] --> B[Page Object 层]
B --> C[LoginPage]
B --> D[ProjectsPage]
B --> E[TaskPage]
C --> F[Browser API]
D --> F
E --> F
style B fill:#cce5ff
style C fill:#d4edda
style D fill:#d4edda
style E fill:#d4edda
(1) LoginPage POM 实现
// e2e/models/LoginPage.ts
import { type Page, type Locator } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByPlaceholder('输入邮箱')
this.passwordInput = page.getByPlaceholder('输入密码')
this.submitButton = page.getByRole('button', { name: '登录' })
this.errorMessage = page.getByTestId('login-error')
}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message)
}
async expectLoggedIn() {
await expect(this.page).toHaveURL(/dashboard/)
}
}
(2) ProjectsPage POM 实现
// e2e/models/ProjectsPage.ts
import { type Page, type Locator } from '@playwright/test'
export class ProjectsPage {
readonly page: Page
readonly newProjectButton: Locator
readonly projectNameInput: Locator
readonly createButton: Locator
readonly projectList: Locator
readonly searchInput: Locator
constructor(page: Page) {
this.page = page
this.newProjectButton = page.getByRole('button', { name: 'New Project' })
this.projectNameInput = page.getByPlaceholder('Project name')
this.createButton = page.getByRole('button', { name: 'Create' })
this.projectList = page.getByTestId('project-list')
this.searchInput = page.getByPlaceholder('搜索项目')
}
async goto() {
await this.page.goto('/projects')
}
async createProject(name: string) {
await this.newProjectButton.click()
await this.projectNameInput.fill(name)
await this.createButton.click()
}
async searchProject(name: string) {
await this.searchInput.fill(name)
}
async expectProjectVisible(name: string) {
await expect(this.projectList).toContainText(name)
}
async openProject(name: string) {
await this.page.getByRole('link', { name }).first().click()
}
}
▶ 示例:POM 驱动的测试
// e2e/projects.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './models/LoginPage'
import { ProjectsPage } from './models/ProjectsPage'
test.describe('Project Management', () => {
let loginPage: LoginPage
let projectsPage: ProjectsPage
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page)
projectsPage = new ProjectsPage(page)
await loginPage.goto()
await loginPage.login('alice@example.com', 'password123')
await loginPage.expectLoggedIn()
})
test('create a new project', async () => {
await projectsPage.goto()
await projectsPage.createProject('E-commerce Dashboard')
await projectsPage.expectProjectVisible('E-commerce Dashboard')
})
test('search for an existing project', async () => {
await projectsPage.goto()
await projectsPage.searchProject('E-commerce')
await projectsPage.expectProjectVisible('E-commerce Dashboard')
})
})
5. 关键路径测试
关键路径(Critical Path)是用户最常使用的业务链路,必须优先保证。
(1) 认证流程测试
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication Flow', () => {
test('redirects unauthenticated user to login', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL(/login/)
})
test('shows validation errors on empty form', async ({ page }) => {
await page.goto('/login')
await page.getByRole('button', { name: '登录' }).click()
await expect(page.getByText(/请输入邮箱/i)).toBeVisible()
await expect(page.getByText(/请输入密码/i)).toBeVisible()
})
test('successful login redirects to dashboard', async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
await page.getByPlaceholder('输入密码').fill('correct-password')
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL(/dashboard/)
await expect(page.getByText(/欢迎回来, Alice/i)).toBeVisible()
})
test('logout clears session', async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
await page.getByPlaceholder('输入密码').fill('correct-password')
await page.getByRole('button', { name: '登录' }).click()
await page.getByRole('button', { name: /退出/i }).click()
await expect(page).toHaveURL(/login/)
})
})
(2) 表单提交测试
// e2e/task-creation.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Task Creation Flow', () => {
test.beforeEach(async ({ page }) => {
// 登录并进入项目
await page.goto('/login')
await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
await page.getByPlaceholder('输入密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
await page.goto('/projects/p1')
})
test('creates a task with all fields', async ({ page }) => {
await page.getByRole('button', { name: 'Add Task' }).click()
await page.getByPlaceholder('Task title').fill('Implement user auth')
await page.getByLabel('Priority').selectOption('High')
await page.getByLabel('Assignee').selectOption('Bob')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('Implement user auth')).toBeVisible()
})
test('shows error for empty title', async ({ page }) => {
await page.getByRole('button', { name: 'Add Task' }).click()
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText(/标题不能为空/i)).toBeVisible()
})
})
(3) 导航与路由测试
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Navigation Flow', () => {
test('sidebar links navigate correctly', async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
await page.getByPlaceholder('输入密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
await page.getByRole('link', { name: 'Projects' }).click()
await expect(page).toHaveURL(/\/projects/)
await page.getByRole('link', { name: 'Dashboard' }).click()
await expect(page).toHaveURL(/\/dashboard/)
await page.getByRole('link', { name: 'Settings' }).click()
await expect(page).toHaveURL(/\/settings/)
})
test('breadcrumb shows current location', async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
await page.getByPlaceholder('输入密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
await page.goto('/projects/p1/tasks/t1')
await expect(page.getByTestId('breadcrumb')).toContainText([
/Projects/, /Project 1/, /Task 1/
])
})
})
6. 视觉回归测试
视觉回归测试捕捉 UI 样式的无意识变化。Playwright 通过 toHaveScreenshot() 实现像素级对比。
graph LR
A[测试运行] --> B[截图当前状态]
B --> C{与基准截图对比}
C -->|一致| D[测试通过]
C -->|差异 > 阈值| E[测试失败]
E --> F[生成差异报告]
style A fill:#cce5ff
style C fill:#fff3cd
style D fill:#d4edda
style E fill:#f8d7da
| 选项 | 说明 | 推荐值 |
|---|---|---|
maxDiffPixels |
最大差异像素数 | 100 |
maxDiffPixelRatio |
最大差异比例 | 0.01 |
threshold |
像素比较阈值 | 0.2 |
animations |
是否禁用动画 | 'disabled' |
stylePath |
额外 CSS 覆盖 | 隐藏随机元素 |
(1) 配置视觉测试
// playwright.config.ts 片段
export default defineConfig({
use: {
screenshot: 'only-on-failure',
viewport: { width: 1280, height: 720 }
},
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
animations: 'disabled'
}
}
})
(2) 生成基准截图
# 首次运行生成基准截图
npx playwright test --update-snapshots
# 后续运行对比基准
npx playwright test
▶ 示例:视觉回归测试
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Visual Regression', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
await page.getByPlaceholder('输入密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
})
test('dashboard page matches snapshot', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('dashboard.png', {
fullPage: true
})
})
test('project list matches snapshot', async ({ page }) => {
await page.goto('/projects')
await page.waitForSelector('[data-testid="project-list"]')
await expect(page).toHaveScreenshot('projects.png')
})
test('task detail matches snapshot', async ({ page }) => {
await page.goto('/projects/p1/tasks/t1')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('task-detail.png', {
mask: [page.locator('[data-testid="timestamp"]')]
})
})
})
7. API Mock 与请求拦截
Playwright 的 page.route() 可以在浏览器层面拦截网络请求,无需真实后端即可测试前后端交互。
(1) 路由拦截基础
// 全局拦截
await page.route('**/api/**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, title: 'Mocked Task' }])
})
})
// 特定 URL 拦截
await page.route('https://api.example.com/products*', async route => {
const response = await route.fetch() // 放行到真实 API
const body = await response.json()
body.push({ id: 999, name: 'Injected Product' })
await route.fulfill({ response, body: JSON.stringify(body) })
})
(2) API Mock 模式对比
| 模式 | 方法 | 适用场景 |
|---|---|---|
| 完全 Mock | route.fulfill() |
后端未就绪时测试前端 |
| 代理修改 | route.fetch() + 修改 |
注入测试数据/错误 |
| 代理透传 | route.fetch() + route.fulfill({ response }) |
监听请求但不改数据 |
| 请求中断 | route.abort() |
测试离线状态 |
▶ 示例:带 API Mock 的完整测试
// e2e/api-mock.spec.ts
import { test, expect } from '@playwright/test'
test.describe('API Mock Scenarios', () => {
test('shows empty state when no data', async ({ page }) => {
await page.route('**/api/projects', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
})
await page.goto('/projects')
await expect(page.getByText(/还没有项目/i)).toBeVisible()
})
test('handles 500 error gracefully', async ({ page }) => {
await page.route('**/api/projects', async route => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server error' })
})
})
await page.goto('/projects')
await expect(page.getByText(/加载失败/i)).toBeVisible()
})
test('shows loading state then data', async ({ page }) => {
// 延迟响应以测试加载态
await page.route('**/api/projects', async route => {
await new Promise(resolve => setTimeout(resolve, 1000))
await route.fulfill({
status: 200,
body: JSON.stringify([
{ id: 'p1', name: 'Mock Project', taskCount: 5 }
])
})
})
await page.goto('/projects')
await expect(page.getByTestId('loading-skeleton')).toBeVisible()
await expect(page.getByText('Mock Project')).toBeVisible({ timeout: 5000 })
})
test('network offline scenario', async ({ page }) => {
await page.route('**/api/**', async route => {
await route.abort('internetdisconnected')
})
await page.goto('/projects')
await expect(page.getByText(/网络连接失败/i)).toBeVisible()
})
})
8. CI 集成:GitHub Actions
Playwright 可以无缝集成到 GitHub Actions,每次推送自动运行 E2E 测试。
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run Playwright tests
run: npx playwright test --project=${{ matrix.browser }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 7
9. 完整示例:TaskFlow 应用 E2E 套件
// e2e/taskflow-e2e.spec.ts
// ============================================
// 综合 E2E 测试:TaskFlow 应用的完整用户流程
// ============================================
import { test, expect, type Page } from '@playwright/test'
// --- 辅助函数:设置认证状态 ---
async function setupAuth(page: Page) {
await page.goto('/login')
await page.getByPlaceholder('输入邮箱').fill('alice@taskflow.io')
await page.getByPlaceholder('输入密码').fill('Password123!')
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL(/dashboard/)
}
test.describe('TaskFlow E2E Suite', () => {
test.beforeEach(async ({ page }) => {
await setupAuth(page)
})
// --- 1. 仪表盘加载 ---
test('dashboard displays key metrics', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByTestId('total-projects')).toBeVisible()
await expect(page.getByTestId('total-tasks')).toBeVisible()
await expect(page.getByTestId('team-members')).toBeVisible()
const projectCount = await page.getByTestId('total-projects').textContent()
expect(Number(projectCount)).toBeGreaterThan(0)
})
// --- 2. 项目 CRUD 流程 ---
test('complete project lifecycle', async ({ page }) => {
// Create
await page.goto('/projects')
await page.getByRole('button', { name: 'New Project' }).click()
await page.getByPlaceholder('Project name').fill('E2E Test Project')
await page.getByPlaceholder('Description').fill('Created by Playwright')
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.getByText('E2E Test Project')).toBeVisible()
// Edit
await page.getByRole('button', { name: /编辑/i }).click()
await page.getByPlaceholder('Project name').fill('E2E Test Project v2')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('E2E Test Project v2')).toBeVisible()
// Delete
await page.getByRole('button', { name: /删除/i }).click()
await page.getByRole('button', { name: /确认/i }).click()
await expect(page.getByText('E2E Test Project v2')).not.toBeVisible()
})
// --- 3. 任务管理 ---
test('task drag and drop status change', async ({ page }) => {
await page.goto('/projects/p1')
// Create task
await page.getByRole('button', { name: 'Add Task' }).click()
await page.getByPlaceholder('Task title').fill('Setup Playwright tests')
await page.getByText('TODO').click()
await page.getByRole('button', { name: 'Save' }).click()
// Verify task appears in TODO column
await expect(page.getByTestId('column-todo'))
.toContainText('Setup Playwright tests')
})
// --- 4. 搜索功能 ---
test('search filters projects correctly', async ({ page }) => {
await page.goto('/projects')
await page.getByPlaceholder('搜索项目').fill('Marketing')
await page.waitForTimeout(300) // debounce
const items = page.getByTestId('project-item')
const count = await items.count()
for (let i = 0; i < count; i++) {
await expect(items.nth(i)).toContainText(/Marketing/i)
}
})
// --- 5. 响应式布局 ---
test.describe('Responsive Design', () => {
test('sidebar collapses on mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 })
await page.goto('/dashboard')
await expect(page.getByTestId('sidebar')).not.toBeVisible()
await page.getByRole('button', { name: /menu/i }).click()
await expect(page.getByTestId('sidebar')).toBeVisible()
})
})
// --- 6. API Mock 测试 ---
test('handles API errors gracefully', async ({ page }) => {
await page.route('**/api/dashboard/metrics', async route => {
await route.fulfill({
status: 503,
body: JSON.stringify({ error: 'Service Unavailable' })
})
})
await page.goto('/dashboard')
await expect(page.getByText(/服务暂时不可用/i)).toBeVisible()
})
})
Running 8 tests using 2 workers
✓ e2e/taskflow-e2e.spec.ts (8 tests) 34.2s
✓ chromium | 8 passed (34.2s)
✓ firefox | 8 passed (38.1s)
✓ webkit | 8 passed (41.5s)
❓ 常见问题
webServer 配置也更适合 Next.js 的 npm run dev 自动启动场景。storageState 功能:在 auth.setup.ts 中完成登录后保存 cookie/localStorage 到 storageState.json,然后在其他测试的 use 配置中引用该文件,避免每个测试都重复登录。page.route() 和 MSW 在 E2E 中如何选择?page.route() 是浏览器层面的请求拦截,无需额外依赖,适合临时 mock。MSW 需要 Service Worker 注册,适合在组件测试(Vitest)中使用。E2E 测试默认推荐 page.route() 以减少外部依赖。workers: 1 避免资源竞争;(2) 缓存浏览器二进制文件(~/.cache/ms-playwright);(3) 使用 --project=chromium 在 CI 中只跑关键浏览器;(4) 并行化:拆分测试到多个 job 运行。mask 选项在视觉测试中隐藏动态元素:toHaveScreenshot({ mask: [page.locator('[data-testid="timestamp"]')] })。对于文本断言,使用正则表达式匹配模式而非固定值。📖 小节
- Playwright 配置
webServer可让 Next.js 开发服务器随测试自动启动和停止 - Page Object Model 将页面交互封装到独立类中,测试代码更简洁可维护
- 关键路径测试覆盖认证、导航、表单提交等用户核心操作链路
toHaveScreenshot()实现像素级视觉回归对比,捕获无意的 UI 样式变化page.route()在浏览器层面拦截 API 请求,适用于隔离测试和错误场景模拟- GitHub Actions 集成 Playwright 需缓存浏览器、配置并行矩阵、上传失败报告
📝 作业
-
基础题(⭐):创建一个
LoginPagePage Object,包含goto()、login(email, password)和expectLoggedIn()三个方法,并编写一个使用该 POM 的测试用例。 -
进阶题(⭐⭐):为你的 Next.js 应用中的"创建-编辑-删除"完整 CRUD 流程编写 5 个 E2E 测试,包含表单验证错误和 API 错误场景的
page.route()mock。 -
挑战题(⭐⭐⭐):实现一个完整的视觉回归测试套件:(1) 生成 5 个关键页面的基准截图;(2) 配置
expect.toHaveScreenshot的maxDiffPixels阈值;(3) 编写 CI 脚本在 PR 中自动对比并 comment 差异报告。