第 8 章 · 01 测试金字塔:单测/MSW/变异 ★


第 8 章 · 01 测试金字塔:单测/MSW/变异 ★

本节摘要:进入全书高潮之一的工程保障舱。本节看金字塔的底座与中段:602 个单测文件(Vitest 4 + Testing Library,根 __tests__/ 目录按 src 结构镜像组织)、MSW mock 开发(src/mocks/ 约 3.6K 行 mock 处理器,在 Service Worker 层拦截 HTTP 请求返回假数据——后端没起也能全功能开发前端,npm run dev:mock 一条命令)、handler 按服务分文件的组织方式(conversations/settings/git/mcp 等 13 组,聚合进 handlers.ts)、以及 Stryker 变异测试(npm run test:mutation,往源码里注入"把 === 改成 !=="这类变异,看测试套件能否杀死每个变异——衡量的是测试的有效性而非覆盖率)。最后串讲单测 → MSW 集成 → e2e 的金字塔分层逻辑。

内容来源:原项目 package.json(scripts 与依赖)、根 __tests__/ 目录结构、src/mocks/handlers.ts 与各 handler 文件、src/mocks/conversation-handlers.tsstryker.config.mjs,精读整理。

⚠️ 注意:MSW(mock service worker)与下一节的 mock-LLM 是两种不同层次的 mock:MSW 拦的是浏览器发出的 HTTP(前端独立测试用,连后端都不用装);mock-LLM 替换的是后端调用的 LLM(整个真实技术栈都在跑,只有大模型是假的)。一个 mock 网络,一个 mock 智能,别混为一谈。

学习目标

阅读完本节,你应当能够:

  1. 说清 602 个单测文件的目录组织:根 __tests__/ 如何镜像 src/ 结构,测试为什么与源码分家。
  2. 解释 MSW 的工作原理:Service Worker 网络层拦截,以及 dev:mock 为什么能做到"无后端全功能开发"。
  3. 描述 src/mocks/ 的 handler 分文件组织与 handlers.ts 的聚合方式。
  4. 区分"覆盖率"与"变异分数":Stryker 怎么注入变异、什么样的测试能杀死变异。
  5. 画出测试金字塔三层,并说明各层负责 catch 什么类型的缺陷。

一、底座:602 个单测文件的组织学

先看 package.json 里测试相关的 scripts 与依赖,这是整个保障舱的入口:

"scripts": { "dev": "node --env-file-if-exists=.env scripts/dev-with-automation.mjs", "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev", "test": "npm run make-i18n && vitest run", "test:coverage": "npm run make-i18n && vitest run --coverage", "test:mutation": "npm run make-i18n && stryker run", "test:mutation:diff": "npm run make-i18n && node scripts/stryker-diff.mjs", "test:mutation:incremental": "npm run make-i18n && stryker run --incremental" }, "devDependencies": { "@vitest/coverage-v8": "4.1.10", "vitest": "4.1.10" }

三个细节值得停一下:

  1. npm test 先跑 make-i18n——先从 translation.json 生成语言包与 I18nKey 枚举(第 8 章第 03 节详述),保证测试引用的翻译键永远存在。测试基建自己也有依赖管线。
  2. Vitest 4.1.10 + @vitest/coverage-v8——测试框架与覆盖率引擎同代钉扎。
  3. 变异测试有三个变体——全量、diff(只测本次改动相关的变异)、incremental(增量缓存),说明变异测试不是摆设,是日常在跑的例行工具。

单测文件的布局采用"根目录 __tests__/ 镜像 src/"的策略:

__tests__/ api/ ← 对应 src/api/(含 backend-registry、agent-server-adapter 的测试) components/ ← 对应 src/components/ hooks/ stores/ services/ contexts/ routes/ ... github-workflows/ scripts/ bin/ build-websocket-url.test.ts ... src/ api/ components/ hooks/ ... (另有约 50 个 *.test.ts 与源码同目录,如 no-direct-agent-server-calls.test.ts)

两种摆放并存:大规模行为测试集中在 __tests__/,与源码同目录的则是"贴身守卫"型测试(第 7 章的 CI 守卫就是一例)。测试套件的运行配置 vitest.setup.ts 会按 VITE_MOCK_API 决定是否挂载 MSW——同一套 Vitest 既能跑纯逻辑单测,也能跑带 mock 网络的集成测试。

💡 驾驶舱要点:602 个测试文件对约几百个源文件的比值(接近 1:1)在前端项目里是罕见的投入。但比数量更重要的是测试写的位置有纪律:架构约束(守卫)、纯逻辑(hook/store)、组件行为(components)各有归属,新人找测试不用猜——镜像结构本身就是文档。

二、中段:MSW mock 开发——没有后端的前端

2.1 原理与开关

MSW(mock service worker)的思路是:在浏览器注册一个 Service Worker,它站在网络层拦截 fetch/XHR,匹配到 handler 规则就返回构造的响应,不匹配就放行到真实网络。对前端代码而言,请求真的发出去了、响应真的回来了——只是"服务器"是 worker 里的一段 TS 代码。这比在业务代码里写 if (mock) return fakeData 干净得多:mock 与产品代码零耦合。

仓库的开关是 VITE_MOCK_API 环境变量:

"dev:frontend": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev", "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev"

npm run dev:mock 起来的前端,不需要 Python、不需要 uvx、不需要 Docker——打开浏览器,会话列表、事件流、文件树、git 变更、MCP 市场、automation 面板全部可用,数据全是 mock 层喂的。前端开发者改一个确认模式 UI,不必等三分钟后端启动完成。

2.2 handler 的组织:按服务分文件,聚合进一张总表

src/mocks/ 共 20 来个文件、约 3618 行。核心是 handlers.ts 的聚合导出:

import { FILE_SERVICE_HANDLERS } from "./file-service-handlers"; import { SECRETS_HANDLERS } from "./secrets-handlers"; import { AGENT_PROFILES_HANDLERS, resetMockAgentProfiles, seedMockAgentProfiles } from "./agent-profiles-handlers"; import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers"; import { SETTINGS_HANDLERS, MOCK_DEFAULT_USER_SETTINGS, resetTestHandlersMockSettings } from "./settings-handlers"; import { CONVERSATION_HANDLERS } from "./conversation-handlers"; import { AUTH_HANDLERS } from "./auth-handlers"; import { FEEDBACK_HANDLERS } from "./feedback-handlers"; import { ANALYTICS_HANDLERS } from "./analytics-handlers"; import { AUTOMATION_HANDLERS, resetAutomationMockData } from "./automation-handlers"; import { MCP_HANDLERS } from "./mcp-handlers"; import { WORKSPACES_HANDLERS, resetMockWorkspaces } from "./workspaces-handlers"; import { CANVAS_EXTENSIONS_HANDLERS, resetCanvasExtensionsMockData } from "./canvas-extensions-handlers"; export const handlers = [ ...FILE_SERVICE_HANDLERS, ...SECRETS_HANDLERS, ...AGENT_PROFILES_HANDLERS, ...GIT_REPOSITORY_HANDLERS, ...SETTINGS_HANDLERS, ...CONVERSATION_HANDLERS, ...AUTH_HANDLERS, ...FEEDBACK_HANDLERS, ...ANALYTICS_HANDLERS, ...AUTOMATION_HANDLERS, ...MCP_HANDLERS, ...WORKSPACES_HANDLERS, ...CANVAS_EXTENSIONS_HANDLERS, ];

13 组 handler 按后端服务切分,一组一个文件,与第 3 章、第 5 章讲过的服务面对齐(files/secrets/profiles/git/settings/conversations/auth/feedback/analytics/automation/mcp/workspaces/canvas-extensions)。除了 handlers 总表,每个文件还导出配套的 resetXxx() / seedXxx() 函数——测试之间恢复 mock 状态,避免"前一个测试改了设置、后一个测试读到脏数据"(下一节 AGENTS.md 的调试指南会再遇到这个坑的 e2e 版本)。

2.3 一个 handler 长什么样

conversation-handlers.ts 的头部为例,看 mock 数据的构造方式:

import { http, delay, HttpResponse, passthrough } from "msw"; import type { DirectConversationInfo } from "#/api/agent-server-adapter"; import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; import { ExecutionStatus, type OpenHandsEvent } from "#/types/agent-server/core"; import { TABLE_DEMO_CONVERSATION_ID, TABLE_DEMO_EVENTS, } from "#/fixtures/table-demo-conversation"; import { CANVAS_DEMO_CONVERSATION_ID, CANVAS_DEMO_EVENTS, CANVAS_DEMO_FILE_PATH, CANVAS_DEMO_MARKDOWN, } from "#/fixtures/canvas-demo-conversation"; /** Map from conversation id → events returned by GET /events/search */ const CONVERSATION_EVENTS: Record<string, unknown[]> = { [TABLE_DEMO_CONVERSATION_ID]: TABLE_DEMO_EVENTS, [CANVAS_DEMO_CONVERSATION_ID]: CANVAS_DEMO_EVENTS, };

四个值得抄走的做法:

  1. 类型直接 import 产品代码的类型(DirectConversationInfoOpenHandsEvent)——mock 响应必须过类型检查,后端契约一变,mock 同步报错,mock 不会腐烂;
  2. 演示数据放 src/fixtures/ 单独管理——"表格演示会话""画布演示会话"是完整的预录事件序列,既是 mock 素材也是开发时的可视化样例;
  3. httpdelayHttpResponsepassthrough 全用 MSW 原语——能模拟网络延迟(分页 500ms)、能透传未匹配请求;
  4. mock 有状态——CONVERSATION_EVENTS 是个可变字典,测试可以往里塞数据再查询,模拟"创建会话后列表出现新行"这类因果。

⚠️ 注意:MSW 的 handler 描述的是 /api/... 请求,这与第 7 章的 CI 守卫并不冲突——守卫排除的正是 mocks/ 目录。纪律管产品代码,mock 层是测试基建,两者用目录边界划清。

三、Stryker 变异测试:你的测试真的能抓 bug 吗

3.1 覆盖率的盲区

行覆盖率 100% 只说明"每行代码都被执行过",不说明"执行时有人检查结果"。经典反例:

if (input === "yes") { flag = true; } else { flag = true; } // 测试走过了两个分支,覆盖率 100%,但 else 分支写错了没人发现

变异测试(mutation testing)补这个洞:工具系统性地修改产品代码(把 === 换成 !==、把 true 换成 false、删掉一行、改算术运算符),每次修改产生一个"变异体"(mutant),然后跑相关测试:

  • 测试挂了 → 变异体被杀死(killed),说明这段代码的改动有人盯着;
  • 测试全过 → 变异体存活(survived),说明即使这里被改坏,测试也无感——这就是测试网的漏洞。

存活变异体占比越低,"变异分数"(mutation score)越高,测试套件真正咬人的能力越强。

3.2 仓库的 Stryker 配置

stryker.config.mjs 全文只有 20 行,但每个排除项都有讲究:

/** @type {import("@stryker-mutator/api/core").PartialStrykerOptions} */ const config = { testRunner: "vitest", mutate: [ "src/**/*.{ts,tsx}", "!src/**/*.{test,spec}.{ts,tsx}", "!src/**/__tests__/**", "!src/**/*.d.ts", "!src/**/*.types.ts", "!src/**/*.{gen,generated}.{ts,tsx}", "!src/i18n/declaration.ts", "!src/{fixtures,mocks,dev}/**", ], vitest: { configFile: "vite.config.ts", related: true, }, };
  • testRunner: "vitest" + related: true:每个变异体只跑与被改文件相关的测试(基于依赖图),否则 602 个文件 × 数千变异体的组合根本跑不完;
  • 排除项的三类逻辑:测试自身(.test/.spec__tests__)不该被变异;纯类型声明(.d.ts.types.ts)没有可变异的运行时行为;生成物(*.gen.tsdeclaration.ts——第 03 节会看到它是脚本生成的 i18n 枚举)与 fixtures/mocks 是机器或数据,变异它们没有意义。

配合 package.json 的三个入口:test:mutation 全量、test:mutation:diff 只测 PR 改动引入的变异(scripts/stryker-diff.mjs 计算变更文件集)、test:mutation:incremental 复用上次结果。把变异测试做成 diff 级,是让它进日常 CI 的关键——全量变异动辄数小时,diff 级把成本压回分钟级。

💡 驾驶舱要点:三层测试工具各有各的"度量什么":Vitest 度量执行了什么(覆盖率),Stryker 度量验证了什么(变异分数),下一节的 e2e 度量用户会遇到什么。只看覆盖率的项目,很容易长出一套"执行了全部代码但从不断言关键行为"的伪测试。Stryker 是对测试套件自身的审计。

四、金字塔分层:每层抓什么

把本节与下一节的内容放进经典测试金字塔:

▲ e2e(mock-LLM / live / docker) ← 真实栈跑通用户旅程,慢而贵,数量最少 ▲▲ MSW 集成层 ← 浏览器环境 + mock 网络,组件-服务-状态协作 ▲▲▲ 单元层(602 文件) ← hook/store/工具函数/组件渲染,快而多
  • 单元层catch:状态机漏迁、hook 依赖数组错、工具函数边界条件、组件条件渲染错误。毫秒级,每次保存即跑。
  • MSW 集成层catch:服务层与 mock 契约不匹配、React Query 缓存键冲突、加载/错误态处理、组件与服务的数据流。秒级,vitest run 内联完成。它同时是开发环境(dev:mock),一份 handler 两种用途。
  • e2e 层(下一节)catch:ingress 路由错、静态资源路径错、真实 agent-server 行为变化、跨服务时序问题。分钟级,只在 PR 与发布前跑。

分层的本质是成本与置信度的兑换率:越往上越像真实用户、越贵、越少跑;越往下越快、越多跑。Agent Canvas 把这套兑换做到了极致——下一节会看到它甚至把"LLM 调用"也纳入了可替换层,让 e2e 层便宜到每个 PR 都能跑全链路。

本节要点回顾

  1. 单测底座:602 个测试文件,Vitest 4.1.10 + Testing Library;根 __tests__/ 镜像 src/ 结构,另有约 50 个贴身守卫测试与源码同目录;npm test 前置 make-i18n 保证翻译键存在。
  2. MSW 原理:Service Worker 在网络层拦截 HTTP,产品代码零感知;VITE_MOCK_API=true(npm run dev:mock)即得无后端的全功能前端。
  3. handler 组织:src/mocks/ 约 3.6K 行,13 组 handler 按后端服务一文件,聚合进 handlers.ts 总表;每组配 reset/seed 函数管理 mock 状态;响应类型 import 产品代码类型,契约一变 mock 同步报错。
  4. 演示数据:src/fixtures/ 存完整预录事件序列(table-demo、canvas-demo),mock 与可视化样例共用。
  5. Stryker 变异测试:注入 ===!== 等变异,看测试能否杀死;变异分数度量测试有效性,补覆盖率盲区;配置排除测试/类型声明/生成物;diffincremental 变体把成本压进日常 CI。
  6. 金字塔三层:单测(快、多、逻辑缺陷)→ MSW 集成(mock 网络、数据流)→ e2e(真实栈、用户旅程);每层用递增的成本换递增的真实度。

下一节:第 8 章 · 02 mock-LLM e2e 与测试矩阵 ★——金字塔顶端的主角:Playwright 四套配置如何做到"不花一个 token 跑通 agent 全链路",以及发布前人工把关的 OS × 安装方式 × Agent 冒烟矩阵。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U