本节摘要:进入全书高潮之一的工程保障舱。本节看金字塔的底座与中段: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.ts、stryker.config.mjs,精读整理。
⚠️ 注意:MSW(mock service worker)与下一节的 mock-LLM 是两种不同层次的 mock:MSW 拦的是浏览器发出的 HTTP(前端独立测试用,连后端都不用装);mock-LLM 替换的是后端调用的 LLM(整个真实技术栈都在跑,只有大模型是假的)。一个 mock 网络,一个 mock 智能,别混为一谈。
阅读完本节,你应当能够:
__tests__/ 如何镜像 src/ 结构,测试为什么与源码分家。dev:mock 为什么能做到"无后端全功能开发"。src/mocks/ 的 handler 分文件组织与 handlers.ts 的聚合方式。先看 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" }
三个细节值得停一下:
npm test 先跑 make-i18n——先从 translation.json 生成语言包与 I18nKey 枚举(第 8 章第 03 节详述),保证测试引用的翻译键永远存在。测试基建自己也有依赖管线。@vitest/coverage-v8——测试框架与覆盖率引擎同代钉扎。单测文件的布局采用"根目录 __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 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,不必等三分钟后端启动完成。
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 版本)。
以 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, };
四个值得抄走的做法:
DirectConversationInfo、OpenHandsEvent)——mock 响应必须过类型检查,后端契约一变,mock 同步报错,mock 不会腐烂;src/fixtures/ 单独管理——"表格演示会话""画布演示会话"是完整的预录事件序列,既是 mock 素材也是开发时的可视化样例;http、delay、HttpResponse、passthrough 全用 MSW 原语——能模拟网络延迟(分页 500ms)、能透传未匹配请求;CONVERSATION_EVENTS 是个可变字典,测试可以往里塞数据再查询,模拟"创建会话后列表出现新行"这类因果。⚠️ 注意:MSW 的 handler 描述的是
/api/...请求,这与第 7 章的 CI 守卫并不冲突——守卫排除的正是mocks/目录。纪律管产品代码,mock 层是测试基建,两者用目录边界划清。
行覆盖率 100% 只说明"每行代码都被执行过",不说明"执行时有人检查结果"。经典反例:
if (input === "yes") { flag = true; } else { flag = true; } // 测试走过了两个分支,覆盖率 100%,但 else 分支写错了没人发现
变异测试(mutation testing)补这个洞:工具系统性地修改产品代码(把 === 换成 !==、把 true 换成 false、删掉一行、改算术运算符),每次修改产生一个"变异体"(mutant),然后跑相关测试:
存活变异体占比越低,"变异分数"(mutation score)越高,测试套件真正咬人的能力越强。
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.ts、declaration.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/工具函数/组件渲染,快而多
vitest run 内联完成。它同时是开发环境(dev:mock),一份 handler 两种用途。分层的本质是成本与置信度的兑换率:越往上越像真实用户、越贵、越少跑;越往下越快、越多跑。Agent Canvas 把这套兑换做到了极致——下一节会看到它甚至把"LLM 调用"也纳入了可替换层,让 e2e 层便宜到每个 PR 都能跑全链路。
__tests__/ 镜像 src/ 结构,另有约 50 个贴身守卫测试与源码同目录;npm test 前置 make-i18n 保证翻译键存在。VITE_MOCK_API=true(npm run dev:mock)即得无后端的全功能前端。src/mocks/ 约 3.6K 行,13 组 handler 按后端服务一文件,聚合进 handlers.ts 总表;每组配 reset/seed 函数管理 mock 状态;响应类型 import 产品代码类型,契约一变 mock 同步报错。src/fixtures/ 存完整预录事件序列(table-demo、canvas-demo),mock 与可视化样例共用。===→!== 等变异,看测试能否杀死;变异分数度量测试有效性,补覆盖率盲区;配置排除测试/类型声明/生成物;diff 与 incremental 变体把成本压进日常 CI。下一节:第 8 章 · 02 mock-LLM e2e 与测试矩阵 ★——金字塔顶端的主角:Playwright 四套配置如何做到"不花一个 token 跑通 agent 全链路",以及发布前人工把关的 OS × 安装方式 × Agent 冒烟矩阵。