29 KiB
name, description, metadata
| name | description | metadata | ||
|---|---|---|---|---|
| when-to-mock-vs-real-services | 何时在 Playwright 测试中使用模拟(Mock)与真实服务 |
|
何时模拟(Mock)与使用真实服务
使用时机:在决定是否要在 Playwright 测试中模拟 API 调用、拦截网络请求或使用真实服务时参考。 前置条件:core/locators.md、core/assertions-and-waiting.md
快速回答
在边界处模拟,端到端测试你的技术栈。 模拟你未拥有的第三方服务(Stripe、SendGrid、OAuth 提供商、分析工具)。绝不模拟你自己的前后端通信。你的测试应该证明 YOUR 代码能正常工作,而不是证明第三方 API 仍在运行。
决策流程图
这个服务是你代码库的一部分吗(你自己的 API、你自己的后端)?
├── 是 → 不要模拟。测试真实的集成。
│ ├── 它慢吗?→ 优化服务本身,而不是测试。
│ └── 它不稳定吗?→ 修复服务。不稳定的基础设施是一个 bug。
└── 否 → 这是第三方服务。
├── 它免费、快速且可靠吗?(这种情况很少见)
│ └── 考虑在 CI 中使用真实服务。如果受速率限制则模拟。
├── 它按调用次数收费吗?(Stripe、Twilio、SendGrid)
│ └── 始终模拟。
├── 它受速率限制吗?(OAuth、社交 API)
│ └── 始终模拟。
├── 它慢或不可靠吗?
│ └── 始终模拟。
└── 它是复杂的多步骤流程吗?(OAuth 重定向跳转)
└── 使用 HAR 录制模拟。定期更新。
决策矩阵
| 场景 | 模拟? | 原因 | 策略 |
|---|---|---|---|
| 你自己的 REST/GraphQL API | 绝不 | 这 IS 你在测试的集成 | 针对 staging 或本地开发环境调用真实 API |
| 你的数据库(通过你的 API) | 绝不 | 数据往返正是端到端测试的全部意义 | 通过 API 或 fixtures 注入测试数据,绝不模拟数据库 |
| 认证(你自己的认证系统) | 通常不 | 认证 bug 是致命的;测试真实流程 | 在大多数测试中使用 storageState 跳过登录,但保留少量真实的登录测试 |
| Stripe / 支付网关 | 始终 | 需要花钱、受速率限制、在 CI 中不稳定 | 使用 route.fulfill() 返回预期的响应 |
| SendGrid / 邮件服务 | 始终 | 副作用(真实邮件发送)、没有 UI 可断言 | 模拟 API 调用,验证请求载荷 |
| OAuth 提供商(Google、GitHub) | 始终 | 重定向频繁、受速率限制、存在验证码 | 模拟令牌交换,测试你的回调处理函数 |
| 分析工具(Segment、Mixpanel) | 始终 | 即发即弃、无 UI 影响、拖慢测试速度 | 使用 route.abort() 或 route.fulfill() |
| 地图 / 地理编码 API | 始终 | 受速率限制、需付费、速度慢 | 使用静态响应进行模拟 |
| 功能开关(LaunchDarkly 等) | 通常 | 确定性控制测试条件 | 模拟以强制启用特定开关状态 |
| CDN / 静态资源 | 绝不 | 已经很快,是你基础设施的一部分 | 让它们正常加载 |
| 不稳定的外部依赖 | CI:模拟,本地:真实 | 保持 CI 绿色通过,同时在本地捕获真实问题 | 基于环境条件性地模拟 |
| 慢速外部依赖 | 开发:模拟,夜间:真实 | 开发时快速反馈,夜间全量集成 | 在配置中分离测试项目 |
模拟策略
完全模拟(route.fulfill)
使用时机:你想完全替换第三方 API 的响应。这是最常见的模拟策略。
TypeScript
import { test, expect } from "@playwright/test"
test("使用模拟支付 API 的结账流程", async ({ page }) => {
// 模拟 Stripe 支付意图创建
await page.route("**/api/create-payment-intent", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
clientSecret: "pi_mock_secret_123",
amount: 9900,
currency: "usd",
}),
})
})
// 模拟 Stripe 确认
await page.route("**/api/confirm-payment", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "succeeded",
receiptUrl: "https://receipt.example.com/123",
}),
})
})
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay $99.00" }).click()
await expect(page.getByText("Payment successful")).toBeVisible()
})
test("优雅处理支付失败", async ({ page }) => {
// 模拟银行卡被拒的响应
await page.route("**/api/confirm-payment", (route) => {
route.fulfill({
status: 402,
contentType: "application/json",
body: JSON.stringify({
error: { code: "card_declined", message: "Your card was declined." },
}),
})
})
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay $99.00" }).click()
await expect(page.getByRole("alert")).toContainText("card was declined")
})
JavaScript
const { test, expect } = require("@playwright/test")
test("使用模拟支付 API 的结账流程", async ({ page }) => {
await page.route("**/api/create-payment-intent", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
clientSecret: "pi_mock_secret_123",
amount: 9900,
currency: "usd",
}),
})
})
await page.route("**/api/confirm-payment", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "succeeded",
receiptUrl: "https://receipt.example.com/123",
}),
})
})
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay $99.00" }).click()
await expect(page.getByText("Payment successful")).toBeVisible()
})
test("优雅处理支付失败", async ({ page }) => {
await page.route("**/api/confirm-payment", (route) => {
route.fulfill({
status: 402,
contentType: "application/json",
body: JSON.stringify({
error: { code: "card_declined", message: "Your card was declined." },
}),
})
})
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay $99.00" }).click()
await expect(page.getByRole("alert")).toContainText("card was declined")
})
部分模拟(修改响应)
使用时机:你想让真实的 API 调用发生,但需要调整响应——注入错误状态、添加边界情况数据或覆盖单个字段。
TypeScript
import { test, expect } from "@playwright/test"
test("库存不足时显示警告", async ({ page }) => {
// 让真实 API 调用通过,但覆盖库存数量
await page.route("**/api/products/*", async (route) => {
const response = await route.fetch() // 转发到真实服务器
const body = await response.json()
// 仅修改我们关心的字段
body.stockCount = 2
body.lowStockWarning = true
await route.fulfill({
response, // 保留请求头、状态码
body: JSON.stringify(body),
})
})
await page.goto("/products/running-shoes")
await expect(page.getByText("Only 2 left in stock")).toBeVisible()
await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()
})
test("向真实 API 响应中注入额外项", async ({ page }) => {
await page.route("**/api/notifications", async (route) => {
const response = await route.fetch()
const body = await response.json()
// 在真实返回的数据末尾追加一个测试通知
body.notifications.push({
id: "test-notif",
message: "Your export is ready",
type: "success",
read: false,
})
await route.fulfill({
response,
body: JSON.stringify(body),
})
})
await page.goto("/dashboard")
await expect(page.getByText("Your export is ready")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("库存不足时显示警告", async ({ page }) => {
await page.route("**/api/products/*", async (route) => {
const response = await route.fetch()
const body = await response.json()
body.stockCount = 2
body.lowStockWarning = true
await route.fulfill({
response,
body: JSON.stringify(body),
})
})
await page.goto("/products/running-shoes")
await expect(page.getByText("Only 2 left in stock")).toBeVisible()
await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()
})
test("向真实 API 响应中注入额外项", async ({ page }) => {
await page.route("**/api/notifications", async (route) => {
const response = await route.fetch()
const body = await response.json()
body.notifications.push({
id: "test-notif",
message: "Your export is ready",
type: "success",
read: false,
})
await route.fulfill({
response,
body: JSON.stringify(body),
})
})
await page.goto("/dashboard")
await expect(page.getByText("Your export is ready")).toBeVisible()
})
录制与回放(HAR 文件)
使用时机:涉及多个端点的复杂 API 序列(OAuth 流程、多步骤向导、仪表盘数据加载)。从真实会话录制一次,然后确定性回放。定期更新录制文件,确保模拟不会与实际情况脱节。
录制 HAR 文件:
TypeScript
import { test } from "@playwright/test"
// 录制 HAR —— 运行一次,然后将 .har 文件提交到代码库
test("录制仪表盘的 API 流量", async ({ page }) => {
await page.routeFromHAR("tests/fixtures/dashboard.har", {
url: "**/api/**",
update: true, // 录制模式:转发请求并保存响应
})
await page.goto("/dashboard")
// 与页面交互以捕获所有相关的 API 调用
await page.getByRole("tab", { name: "Analytics" }).click()
await page.getByRole("tab", { name: "Users" }).click()
await page.getByRole("button", { name: "Load more" }).click()
// 页面关闭时 HAR 文件会自动保存
})
回放 HAR 文件:
TypeScript
import { test, expect } from "@playwright/test"
test("仪表盘使用录制数据加载", async ({ page }) => {
// 回放模式:从 HAR 文件中提供响应
await page.routeFromHAR("tests/fixtures/dashboard.har", {
url: "**/api/**",
update: false, // 回放模式(默认值)
})
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Analytics" })).toBeVisible()
await expect(page.getByTestId("revenue-chart")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
// 录制
test("录制仪表盘的 API 流量", async ({ page }) => {
await page.routeFromHAR("tests/fixtures/dashboard.har", {
url: "**/api/**",
update: true,
})
await page.goto("/dashboard")
await page.getByRole("tab", { name: "Analytics" }).click()
await page.getByRole("tab", { name: "Users" }).click()
await page.getByRole("button", { name: "Load more" }).click()
})
// 回放
test("仪表盘使用录制数据加载", async ({ page }) => {
await page.routeFromHAR("tests/fixtures/dashboard.har", {
url: "**/api/**",
update: false,
})
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Analytics" })).toBeVisible()
await expect(page.getByTestId("revenue-chart")).toBeVisible()
})
HAR 维护工作流:
- 针对已知良好的 staging 环境录制 HAR 文件。
- 将
.har文件提交到版本控制(它们是 JSON 格式,可查看 diff)。 - 每月重新录制一次,或在 API 发生变化时重新录制。在 CI 中添加提醒或日历事件。
- 在专用的测试文件中使用
update: true来刷新录制内容。 - 将 HAR 限定到特定的 URL 模式(
url: '**/api/v2/**'),使不相关的请求仍能命中真实服务器。
阻止不必要的请求
使用时机:第三方脚本(分析工具、广告、聊天组件)拖慢测试速度且没有增加任何价值。直接阻止它们。
TypeScript
import { test, expect } from "@playwright/test"
test.beforeEach(async ({ page }) => {
// 阻止分析工具和跟踪代码——它们拖慢测试速度且不增加测试覆盖率
await page.route("**/{google-analytics,segment,hotjar,intercom}.{com,io}/**", (route) => {
route.abort()
})
// 在不需要图片的测试中阻止所有图片请求
// await page.route('**/*.{png,jpg,jpeg,gif,svg,webp}', (route) => route.abort());
})
test("没有第三方脚本时页面快速加载", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test.beforeEach(async ({ page }) => {
await page.route("**/{google-analytics,segment,hotjar,intercom}.{com,io}/**", (route) => {
route.abort()
})
})
test("没有第三方脚本时页面快速加载", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
真实服务策略
针对 Staging 环境
使用时机:你有一个与生产环境一致的共享 staging 环境。最适合获得集成信心。
TypeScript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
baseURL: process.env.CI ? "https://staging.yourapp.com" : "http://localhost:3000",
},
projects: [
{
name: "integration",
testMatch: "**/*.integration.spec.ts",
use: { baseURL: "https://staging.yourapp.com" },
},
{
name: "e2e",
testMatch: "**/*.e2e.spec.ts",
use: { baseURL: "http://localhost:3000" },
},
],
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
use: {
baseURL: process.env.CI ? "https://staging.yourapp.com" : "http://localhost:3000",
},
projects: [
{
name: "integration",
testMatch: "**/*.integration.spec.js",
use: { baseURL: "https://staging.yourapp.com" },
},
{
name: "e2e",
testMatch: "**/*.e2e.spec.js",
use: { baseURL: "http://localhost:3000" },
},
],
})
针对本地开发服务器
使用时机:最快的反馈循环。在本地运行你的后端并针对它进行测试。
TypeScript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI, // 在 CI 中启动新实例,在本地复用已有实例
timeout: 30_000,
},
use: {
baseURL: "http://localhost:3000",
},
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
use: {
baseURL: "http://localhost:3000",
},
})
针对测试容器
使用时机:你需要一个完全隔离的环境,包含数据库、缓存和服务。最适合可重现的 CI 运行。
TypeScript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
webServer: {
command: "docker compose -f docker-compose.test.yml up --wait",
url: "http://localhost:3000/health",
reuseExistingServer: !process.env.CI,
timeout: 120_000, // 容器启动需要更长时间
},
use: {
baseURL: "http://localhost:3000",
},
// 全局拆卸以停止容器
globalTeardown: "./tests/global-teardown.ts",
})
// tests/global-teardown.ts
import { execSync } from "child_process"
export default function globalTeardown() {
if (process.env.CI) {
execSync("docker compose -f docker-compose.test.yml down -v")
}
}
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
webServer: {
command: "docker compose -f docker-compose.test.yml up --wait",
url: "http://localhost:3000/health",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
use: {
baseURL: "http://localhost:3000",
},
globalTeardown: "./tests/global-teardown.js",
})
// tests/global-teardown.js
const { execSync } = require("child_process")
module.exports = function globalTeardown() {
if (process.env.CI) {
execSync("docker compose -f docker-compose.test.yml down -v")
}
}
混合方法
最强大的测试套件将真实服务与模拟服务结合使用。原则是:模拟你不拥有的东西,运行你拥有的东西。
基于 Fixture 的模拟控制
创建一个 fixture,让单个测试可以选择性地模拟特定服务,同时保持其他所有服务为真实调用。
TypeScript
// tests/fixtures/mock-fixtures.ts
import { test as base } from "@playwright/test"
type MockOptions = {
mockPayments: boolean
mockEmail: boolean
mockAnalytics: boolean
}
export const test = base.extend<MockOptions>({
mockPayments: [true, { option: true }], // 默认:模拟支付
mockEmail: [true, { option: true }], // 默认:模拟邮件
mockAnalytics: [true, { option: true }], // 默认:模拟分析工具
page: async ({ page, mockPayments, mockEmail, mockAnalytics }, use) => {
if (mockPayments) {
await page.route("**/api/payments/**", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "succeeded", id: "pay_mock_123" }),
})
})
}
if (mockEmail) {
await page.route("**/api/send-email", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ messageId: "msg_mock_456" }),
})
})
}
if (mockAnalytics) {
await page.route("**/{segment,google-analytics,mixpanel}.**/**", (route) => {
route.abort()
})
}
await use(page)
},
})
export { expect } from "@playwright/test"
// tests/checkout.spec.ts
import { test, expect } from "./fixtures/mock-fixtures"
// 使用默认值:支付被模拟,邮件被模拟,分析工具被阻止
test("结账发送确认邮件", async ({ page }) => {
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay $99.00" }).click()
await expect(page.getByText("Confirmation email sent")).toBeVisible()
})
// 覆盖:使用真实支付 API 进行测试(夜间集成测试)
test.describe("夜间集成", () => {
test.use({ mockPayments: false })
test("针对 Stripe 测试模式的真实支付流程", async ({ page }) => {
await page.goto("/checkout")
// 这将命中真实的 Stripe 测试 API
await page.getByRole("button", { name: "Pay $99.00" }).click()
await expect(page.getByText("Payment successful")).toBeVisible()
})
})
JavaScript
// tests/fixtures/mock-fixtures.js
const { test: base } = require("@playwright/test")
const test = base.extend({
mockPayments: [true, { option: true }],
mockEmail: [true, { option: true }],
mockAnalytics: [true, { option: true }],
page: async ({ page, mockPayments, mockEmail, mockAnalytics }, use) => {
if (mockPayments) {
await page.route("**/api/payments/**", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "succeeded", id: "pay_mock_123" }),
})
})
}
if (mockEmail) {
await page.route("**/api/send-email", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ messageId: "msg_mock_456" }),
})
})
}
if (mockAnalytics) {
await page.route("**/{segment,google-analytics,mixpanel}.**/**", (route) => {
route.abort()
})
}
await use(page)
},
})
module.exports = { test, expect: require("@playwright/test").expect }
// tests/checkout.spec.js
const { test, expect } = require("./fixtures/mock-fixtures")
test("结账发送确认邮件", async ({ page }) => {
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay $99.00" }).click()
await expect(page.getByText("Confirmation email sent")).toBeVisible()
})
test.describe("夜间集成", () => {
test.use({ mockPayments: false })
test("针对 Stripe 测试模式的真实支付流程", async ({ page }) => {
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay $99.00" }).click()
await expect(page.getByText("Payment successful")).toBeVisible()
})
})
基于环境的模拟
按环境拆分测试项目,以便在每次 CI 推送中运行模拟测试,在夜间运行全量集成测试。
TypeScript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
projects: [
{
name: "fast-ci",
testMatch: "**/*.spec.ts",
use: {
baseURL: "http://localhost:3000",
// 所有外部服务通过 fixtures 模拟
},
},
{
name: "nightly-integration",
testMatch: "**/*.integration.spec.ts",
use: {
baseURL: "https://staging.yourapp.com",
// 真实服务,更长的超时时间
},
timeout: 120_000,
},
],
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
projects: [
{
name: "fast-ci",
testMatch: "**/*.spec.js",
use: {
baseURL: "http://localhost:3000",
},
},
{
name: "nightly-integration",
testMatch: "**/*.integration.spec.js",
use: {
baseURL: "https://staging.yourapp.com",
},
timeout: 120_000,
},
],
})
验证模拟的准确性
模拟的响应会随着时间的推移与真实 API 发生偏差。要防范这种情况。
TypeScript
import { test, expect } from "@playwright/test"
// 每周或 API 发生变化时运行——验证模拟与实际情况一致
test.describe("模拟契约验证", () => {
test("支付模拟与真实 Stripe 测试 API 的结构一致", async ({ request }) => {
// 命中真实 API
const realResponse = await request.post("/api/create-payment-intent", {
data: { amount: 9900, currency: "usd" },
})
const realBody = await realResponse.json()
// 验证模拟具有相同的结构
const mockBody = {
clientSecret: "pi_mock_secret_123",
amount: 9900,
currency: "usd",
}
// 两者存在相同的键
expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort())
// 每个键具有相同的类型
for (const key of Object.keys(mockBody)) {
expect(typeof mockBody[key]).toBe(typeof realBody[key])
}
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("模拟契约验证", () => {
test("支付模拟与真实 Stripe 测试 API 的结构一致", async ({ request }) => {
const realResponse = await request.post("/api/create-payment-intent", {
data: { amount: 9900, currency: "usd" },
})
const realBody = await realResponse.json()
const mockBody = {
clientSecret: "pi_mock_secret_123",
amount: 9900,
currency: "usd",
}
expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort())
for (const key of Object.keys(mockBody)) {
expect(typeof mockBody[key]).toBe(typeof realBody[key])
}
})
})
反模式
| 不要这样做 | 问题 | 改为这样做 |
|---|---|---|
模拟自己的 API(当你拥有 /api/users 端点时,使用 page.route('**/api/users', ...)) |
你在测试一个虚构的场景。你的前端和后端可能完全不兼容。 | 命中你的真实 API。只模拟你 API 后方的第三方服务。 |
| 为了速度模拟所有东西 | 测试通过,应用崩溃。你的集成测试覆盖率为零。 | 只模拟外部边界。优化你自己的服务以提升测试速度。 |
| 从不模拟任何东西 | 测试慢、不稳定,且当 Stripe 宕机时失败。你在测试第三方服务的可用性,而不是你的代码。 | 模拟第三方服务。你的 CI 不应该依赖别人的基础设施。 |
| 使用与真实 API 不匹配的过期模拟 | 模拟返回 { status: "ok" } 但真实 API 返回 { status: "success", data: {...} }。测试通过,生产环境崩溃。 |
定期运行契约验证测试。每月重新录制 HAR 文件。 |
| 在错误的层级模拟(拦截你自己的前端 HTTP 客户端) | 绕过了请求/响应序列化、请求头、错误处理。 | 使用 page.route() 在网络层级进行模拟。这样可以测试你的完整 HTTP 客户端代码。 |
| 在数十个测试文件中复制粘贴模拟响应 | 一次 API 变更需要更新 40 个文件。模拟逐渐产生偏差。 | 将模拟集中到 fixtures 或辅助文件中。单一事实来源。 |
使用 page.evaluate() 来 stub fetch/XMLHttpRequest |
脆弱,无法在页面导航后存活,遗漏 service worker。 | 使用 page.route(),它在网络层进行拦截。 |
| 阻止所有网络请求然后白名单 | 极度脆弱。每个新的 API 端点都需要更新白名单。任何后端变更都会导致测试失败。 | 默认允许所有流量。仅选择性地模拟你需要的第三方服务。 |
相关
- core/network-mocking.md —— 详细的网络拦截模式与 API
- core/api-testing.md —— 使用
request上下文直接测试你的 API - core/authentication.md —— 何时模拟认证与测试真实登录流程
- core/fixtures-and-hooks.md —— 构建可复用的模拟 fixtures
- core/configuration.md ——
webServer、baseURL和项目配置 - ci/ci-github-actions.md —— 不同测试层级的 CI 设置