Files
2026-07-13 21:36:47 +08:00

29 KiB
Raw Permalink Blame History

name, description, metadata
name description metadata
when-to-mock-vs-real-services 何时在 Playwright 测试中使用模拟(Mock)与真实服务
type
reference

何时模拟(Mock)与使用真实服务

使用时机:在决定是否要在 Playwright 测试中模拟 API 调用、拦截网络请求或使用真实服务时参考。 前置条件core/locators.mdcore/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 维护工作流:

  1. 针对已知良好的 staging 环境录制 HAR 文件。
  2. .har 文件提交到版本控制(它们是 JSON 格式,可查看 diff)。
  3. 每月重新录制一次,或在 API 发生变化时重新录制。在 CI 中添加提醒或日历事件。
  4. 在专用的测试文件中使用 update: true 来刷新录制内容。
  5. 将 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 端点都需要更新白名单。任何后端变更都会导致测试失败。 默认允许所有流量。仅选择性地模拟你需要的第三方服务。

相关