--- name: when-to-mock-vs-real-services description: 何时在 Playwright 测试中使用模拟(Mock)与真实服务 metadata: type: reference --- # 何时模拟(Mock)与使用真实服务 > **使用时机**:在决定是否要在 Playwright 测试中模拟 API 调用、拦截网络请求或使用真实服务时参考。 > **前置条件**:[core/locators.md](locators.md)、[core/assertions-and-waiting.md](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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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", }) ``` ```typescript // 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** ```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", }) ``` ```javascript // 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** ```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({ 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" ``` ```typescript // 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** ```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 } ``` ```javascript // 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** ```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** ```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** ```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** ```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](network-mocking.md) —— 详细的网络拦截模式与 API - [core/api-testing.md](api-testing.md) —— 使用 `request` 上下文直接测试你的 API - [core/authentication.md](authentication.md) —— 何时模拟认证与测试真实登录流程 - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) —— 构建可复用的模拟 fixtures - [core/configuration.md](configuration.md) —— `webServer`、`baseURL` 和项目配置 - [ci/ci-github-actions.md](../ci/ci-github-actions.md) —— 不同测试层级的 CI 设置