Lesson 26:E2E 测试 — Playwright 全流程自动化
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 3(实战篇)
- 推荐时长:120~180 分钟(首次学习)
- 先修要求:完成 L21–25,已有登录、商品页、购物车和 Vitest 配置
- 学习产出:在独立测试库上验证浏览、搜索、加购与登录,配置可重复运行的 CI
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:使用 Playwright 编写端到端测试,覆盖登录、浏览、搜索、购物车等完整用户流程,理解 Page Object Model 设计模式。
📦 本节产出:一套在独立数据库上运行的浏览、购物车与登录测试;真实付款及 Webhook 链路需另备 Stripe 测试凭据验证。
一、E2E 测试 vs 单元测试
| 单元测试 (Vitest) | E2E 测试 (Playwright) | |
|---|---|---|
| 测什么 | 单个函数或组件 | 完整的用户流程 |
| 运行环境 | 模拟的 jsdom | 真实浏览器 (Chromium/Firefox/WebKit) |
| 速度 | 极快 (毫秒) | 较慢 (秒) |
| 价值 | 确保单个单元正确 | 确保整体流程跑通 |
| 数量 | 很多 | 精选核心流程 |
二、安装与配置
npm init playwright@latest按照提示选择:
- 测试目录:
e2e - 安装浏览器:是
本课使用 Playwright 1.x;初始化后检查主版本并提交 package-lock.json,CI 使用 Node.js 24 和 npm ci。保留 L25 的 include: ['src/**/*.{test,spec}.{ts,tsx}'],避免 Vitest 收集 e2e/ 中的 Playwright 文件。
2.1 独立测试数据库与测试账号
测试服务器使用 3100 端口,每次运行创建一个临时 SQLite 数据库。不能复用 3000 端口的开发服务器,否则测试可能读写开发库。下面的入口会执行现有迁移、写入固定商品和测试账号,再启动 Playwright;结束后仅删除本次创建的临时目录。
// scripts/run-e2e.mjs
import { mkdtempSync, rmSync, openSync, closeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { spawnSync } from 'node:child_process'
const directory = mkdtempSync(join(tmpdir(), 'shopnext-e2e-'))
const databaseFile = join(directory, 'test.db')
closeSync(openSync(databaseFile, 'wx')) // 新建空库文件,避免 Prisma 6 在空库初始化时的兼容问题
const databaseUrl = `file:${databaseFile.replaceAll('\\', '/')}`
const env = {
...process.env,
DATABASE_URL: databaseUrl,
E2E_DATABASE_URL: databaseUrl,
AUTH_SECRET: randomBytes(32).toString('hex'),
AUTH_URL: 'http://localhost:3100',
AUTH_TRUST_HOST: 'true', // 仅此受控的本地测试服务
APP_URL: 'http://localhost:3100',
}
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'
function run(args) {
const result = spawnSync(npx, args, { env, stdio: 'inherit', shell: process.platform === 'win32' })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`命令失败:npx ${args.join(' ')}`)
}
try {
run(['prisma', 'generate'])
run(['prisma', 'migrate', 'deploy'])
run(['tsx', 'e2e/seed.ts'])
run(['playwright', 'test', ...process.argv.slice(2)])
} finally {
rmSync(directory, { recursive: true, force: true })
}沿用 L19 的 prisma.config.ts、dotenv 与 Prisma 6.19.x 配置,不更改 .env。上述环境变量由子进程继承,优先于 dotenv/Next.js 文件中的同名配置。确保已提交 L19、L21、L24 的所有迁移;migrate deploy 不会自动 seed,测试入口单独调用以下脚本:
// e2e/seed.ts
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
if (!process.env.E2E_DATABASE_URL || process.env.DATABASE_URL !== process.env.E2E_DATABASE_URL) {
throw new Error('只能通过 run-e2e.mjs 向本次临时测试库写入数据')
}
const prisma = new PrismaClient()
async function main() {
await prisma.product.createMany({ data: [
{ id: 'e2e-react', name: 'React 测试手册', description: '组件测试', category: 'book', price: 9900, stock: 100 },
{ id: 'e2e-keyboard', name: '测试键盘', description: '机械键盘', category: 'electronics', price: 19900, stock: 100 },
{ id: 'e2e-shirt', name: '测试衬衫', description: '棉质衣物', category: 'clothing', price: 5900, stock: 100 },
] })
await prisma.user.create({ data: {
email: '[email protected]', name: 'E2E 用户', role: 'customer',
password: await bcrypt.hash('E2e-only-password-2026!', 12),
} })
}
main().catch(error => { console.error(error); process.exitCode = 1 })
.finally(() => prisma.$disconnect())测试账号只存在于临时库,不放进生产 seed。tsx 沿用 L19 的开发依赖。在 package.json 的现有 scripts 中增加 "test:e2e": "node scripts/run-e2e.mjs"。
2.2 Playwright 配置
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
if (!process.env.E2E_DATABASE_URL || process.env.DATABASE_URL !== process.env.E2E_DATABASE_URL) {
throw new Error('请使用 npm run test:e2e,确保测试数据库隔离')
}
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
timeout: 30_000,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1, // 后续写订单的用例仍需各自准备数据,不能依赖运行顺序
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:3100',
screenshot: 'only-on-failure',
video: 'retain-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'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run build && npm run start -- -p 3100',
url: 'http://localhost:3100',
reuseExistingServer: false,
timeout: 180_000,
},
})webServer 默认继承测试进程环境变量。构建和启动都使用临时库;端口被占用时直接失败,避免误连别的服务。见 Playwright webServer。
三、编写基础测试
3.1 首页测试
// e2e/home.spec.ts
import { test, expect } from '@playwright/test'
test.describe('首页', () => {
test('应该显示 ShopNext 标题和导航', async ({ page }) => {
await page.goto('/')
// 验证主标题
await expect(page.getByText('欢迎来到 ShopNext')).toBeVisible()
// 验证导航栏链接
await expect(page.getByRole('link', { name: '商品', exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: /^购物车/ })).toBeVisible()
})
test('点击浏览商品应该导航到商品列表页', async ({ page }) => {
await page.goto('/')
await page.getByText('浏览商品').click()
await expect(page).toHaveURL('/products')
await expect(page.getByRole('heading', { level: 1, name: /^全部商品/ })).toBeVisible()
})
test('导航栏所有链接应该正常工作', async ({ page }) => {
await page.goto('/')
// 测试商品链接
await page.getByRole('link', { name: '商品', exact: true }).click()
await expect(page).toHaveURL('/products')
// 返回首页
await page.getByRole('link', { name: 'ShopNext' }).click()
await expect(page).toHaveURL('/')
})
})3.2 商品浏览流程
// e2e/products.spec.ts
import { test, expect } from '@playwright/test'
test.describe('商品浏览', () => {
test('商品列表应该展示商品卡片', async ({ page }) => {
await page.goto('/products')
// 应该能看到商品
const productCards = page.locator('[href^="/products/"]')
await expect(productCards.first()).toBeVisible()
// 至少应该有 3 个商品
const count = await productCards.count()
expect(count).toBeGreaterThanOrEqual(3)
})
test('点击商品应该进入详情页', async ({ page }) => {
await page.goto('/products')
// 记录第一个商品的名称
const firstProduct = page.locator('[href^="/products/"]').first()
const productName = await firstProduct.locator('h2').textContent()
// 点击进入详情
await firstProduct.click()
await expect(page).toHaveURL(/\/products\/[^/?]+$/)
// 详情页应该包含该商品名称
expect(productName).toBeTruthy()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(productName!.trim())
// 应该看到"加入购物车"按钮
await expect(page.getByText('加入购物车')).toBeVisible()
})
test('搜索功能应该正确筛选商品', async ({ page }) => {
await page.goto('/products')
// 在搜索框输入
await page.getByRole('textbox', { name: '搜索商品' }).fill('React')
await page.getByRole('button', { name: '搜索', exact: true }).click()
// URL 应该包含搜索参数
await expect(page).toHaveURL(/q=React/)
// 等待可见结果,不用固定 sleep 掩盖加载时间。
const cards = page.locator('main a[href^="/products/"]')
await expect(cards).toHaveCount(1)
await expect(cards.first()).toContainText('React 测试手册')
})
test('分类筛选应该正常工作', async ({ page }) => {
await page.goto('/products')
// 点击某个分类按钮
await page.getByText('📚 图书').click()
// URL 应该包含分类参数
await expect(page).toHaveURL(/category=book/)
await expect(page.locator('main a[href^="/products/"]')).toHaveCount(1)
await expect(page.getByRole('link', { name: /React 测试手册/ })).toBeVisible()
})
})四、测试完整购物流程
每个用例有独立的浏览器上下文和 localStorage。不能省略加购步骤,也不能在按钮不存在时跳过断言。
// e2e/shopping-flow.spec.ts
import { test, expect, type Page } from '@playwright/test'
async function addProduct(page: Page) {
await page.goto('/products/e2e-react')
await page.getByRole('button', { name: '加入购物车', exact: true }).click()
await expect(page.getByRole('button', { name: '已加入 1 件,再加一件' })).toBeVisible()
await page.getByRole('link', { name: /^购物车/ }).click()
await expect(page).toHaveURL('/cart')
}
test('浏览、加购并显示购物车金额', async ({ page }) => {
await addProduct(page)
await expect(page.getByRole('main')).toContainText('React 测试手册')
await expect(page.getByText('预计总计:¥99.00', { exact: true })).toBeVisible()
})
test('购物车调整数量后重新计算总计', async ({ page }) => {
await addProduct(page)
await page.getByRole('button', { name: '增加 React 测试手册 数量', exact: true }).click()
await expect(page.getByText('¥99.00 × 2', { exact: true })).toBeVisible()
await expect(page.getByText('预计总计:¥198.00', { exact: true })).toBeVisible()
})再测试 L21 的真实 Credentials 登录。该页面成功后跳到 /products:
// e2e/login.spec.ts
import { test, expect } from '@playwright/test'
test('测试用户登录后显示昵称', async ({ page }) => {
await page.goto('/login')
await page.getByRole('textbox', { name: '邮箱', exact: true }).fill('[email protected]')
await page.getByLabel('密码', { exact: true }).fill('E2e-only-password-2026!')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page).toHaveURL('/products')
await expect(page.getByText('E2E 用户', { exact: true })).toBeVisible()
})上述购物测试停在购物车,不证明建单、库存预留和收款已正确完成。扩展下单测试时,使用此账号走真实登录与 checkoutAction,断言进入 /checkout/[orderId],并检查数据库订单和库存。支付测试还需 L24 的 Stripe 测试环境与已验签 Webhook,不能把跳转成功当作付款成功。
五、🧠 深度专题:Page Object Model (POM)
当测试变多时,如果每个测试都直接写 page.goto、page.click 等底层操作,代码会变得非常冗余。如果页面 UI 改了(比如按钮文案从"加入购物车"改成"加购"),你要改几十个测试文件。
Page Object Model 把每个页面封装成一个类,集中管理页面交互逻辑:
// e2e/pages/ProductsPage.ts
import { type Page, type Locator } from '@playwright/test'
export class ProductsPage {
readonly page: Page
readonly searchInput: Locator
readonly searchButton: Locator
readonly productCards: Locator
constructor(page: Page) {
this.page = page
this.searchInput = page.getByRole('textbox', { name: '搜索商品' })
this.searchButton = page.getByRole('button', { name: '搜索', exact: true })
this.productCards = page.locator('[href^="/products/"]')
}
async goto() {
await this.page.goto('/products')
}
async search(query: string) {
await this.searchInput.fill(query)
await this.searchButton.click()
}
async clickFirstProduct() {
await this.productCards.first().click()
}
async getProductCount() {
return this.productCards.count()
}
}// e2e/pages/ProductDetailPage.ts
import { type Page, type Locator } from '@playwright/test'
export class ProductDetailPage {
readonly page: Page
readonly addToCartButton: Locator
readonly productTitle: Locator
constructor(page: Page) {
this.page = page
this.addToCartButton = page.getByRole('button', { name: '加入购物车', exact: true })
this.productTitle = page.locator('h1')
}
async addToCart() {
await this.addToCartButton.click()
}
async getTitle() {
return this.productTitle.textContent()
}
}使用 POM 的测试代码变得非常清晰:
// e2e/shopping-pom.spec.ts
import { test, expect } from '@playwright/test'
import { ProductsPage } from './pages/ProductsPage'
import { ProductDetailPage } from './pages/ProductDetailPage'
test('使用 POM 的购物流程', async ({ page }) => {
const productsPage = new ProductsPage(page)
const detailPage = new ProductDetailPage(page)
// 搜索商品
await productsPage.goto()
await productsPage.search('React')
await expect(page).toHaveURL(/q=React/)
await expect(productsPage.productCards).toHaveCount(1)
await expect(productsPage.productCards.first()).toContainText('React 测试手册')
// 进入详情
await productsPage.clickFirstProduct()
await expect(page).toHaveURL('/products/e2e-react')
await expect(detailPage.productTitle).toHaveText('React 测试手册')
const title = await detailPage.getTitle()
expect(title).toContain('React')
// 加入购物车
await detailPage.addToCart()
await expect(page.getByRole('button', { name: '已加入 1 件,再加一件' })).toBeVisible()
})六、网络拦截与 Mock
page.route() 拦截的是浏览器发出的请求,无法拦截 L22 Server Component 在 Node.js 里执行的 Prisma 查询。因此不能为 /products 虚构一个 /api/products Mock 后,声称真实列表会使用它。商品列表测试使用前面的独立测试库。Playwright 网络文档。
下面单独演示浏览器网络拦截,它不是商品列表的 E2E 测试:
// e2e/network.spec.ts
import { test, expect } from '@playwright/test'
test('拦截浏览器 fetch 请求', async ({ page }) => {
await page.route('**/api/mock-products', route => route.fulfill({
json: [{ id: 'mock-1', name: '测试商品', price: 9900 }],
}))
await page.goto('/')
// 此请求被 Playwright 响应,无需创建真实 Route Handler。
const products = await page.evaluate(async () => {
const response = await fetch('/api/mock-products')
return response.json()
})
expect(products).toEqual([{ id: 'mock-1', name: '测试商品', price: 9900 }])
})若测试真正的客户端 API 请求,应先注册 page.waitForResponse(),再触发交互,并同时断言页面结果。不要用含糊的 /products URL 匹配把文档、RSC 导航和预取响应混在一起;本课搜索测试直接等待 URL 与商品卡片断言。
七、视觉回归测试
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test('商品列表页的视觉回归', async ({ page }) => {
await page.goto('/products')
await expect(page.locator('main a[href^="/products/"]')).toHaveCount(3)
await expect(page.getByRole('link', { name: '购物车,0 件商品', exact: true })).toBeVisible()
// 缺少基准时会生成待审核截图,并让该断言失败。
// 默认按测试文件、项目和系统区分基准;审核后提交到 Git。
await expect(page).toHaveScreenshot('products-page.png', {
maxDiffPixelRatio: 0.01, // 允许 1% 的像素差异
})
})运行命令:
npm run test:e2e # 运行所有测试
npm run test:e2e -- --update-snapshots # 审核后更新基准截图
npx playwright show-report # 查看 HTML 测试报告截图基准必须在与 CI 一致的系统、浏览器版本、字体和视口下生成;macOS 的基准不能直接替代 Linux 的基准。提交之前逐张审核,不能遇到失败就自动更新。page.screenshot() 只保存图片,toHaveScreenshot() 才执行回归比较,见 Playwright 视觉比较。
八、CI/CD 集成
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm run test:run # 单元测试
- run: npx playwright install --with-deps
- run: npm run test:e2e # 临时库 → 迁移 → seed → 构建 → E2E
# 如果 E2E 失败,上传截图和视频作为调试资源
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7上述 Actions 使用当前 v7 版本,配置来自 checkout、setup-node 和 upload-artifact 官方仓库;这里使用 GitHub 托管 runner。
该工作流只运行测试,不部署站点。若要阻止失败的 PR 合并,还需把此检查配置为受保护分支的 required status check。首次 CI 前先提交该 Linux 环境的截图基准;外部支付测试需要单独配置测试凭据,不能用生产密钥补齐。
九、练习
- 补充错误密码测试:验证登录失败提示出现,且没有跳到
/products。 - 扩展 Page Object Model,为购物车页面创建
CartPage类。 - 使用
toHaveScreenshot()对商品详情页关键布局做视觉回归比较。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 配置了 Playwright 多浏览器测试环境 | E2E 测试在测试金字塔中的定位 |
| 编写了完整的购物浏览流程测试 | goto / click / fill / waitForResponse API |
| 封装了 Page Object Model | POM 模式减少重复、降低维护成本 |
| 实现了网络拦截 Mock | page.route() 拦截和伪造响应 |
| 配置了视觉回归快照 | toHaveScreenshot() 像素级对比 |
| 集成了 GitHub Actions CI | 自动化测试 + 失败截图上传 |