mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
198 lines
13 KiB
Markdown
198 lines
13 KiB
Markdown
# 第十四章:测试策略 —— 为什么 mock 必须从底层 HTTP 开始
|
||
|
||
> Bun 的 mock.module 是进程全局的,一个测试文件的 mock 会让整个进程中毒
|
||
|
||
## mock.module 不是 Jest 的 jest.mock
|
||
|
||
大多数从 Jest/Vitest 迁移到 `bun:test` 的开发者会自然地假设 `mock.module` 和 `jest.mock` 一样——per-file 隔离,每个测试文件有自己独立的 mock 命名空间。Bun 打破了这个假设。
|
||
|
||
打开 `tests/mocks/axios.ts:1`,文件顶部的注释直接点出了这个问题的本质:
|
||
|
||
```
|
||
Each call to `setupAxiosMock()` registers its own `mock.module('axios', ...)`
|
||
that only knows about the handle returned to that call. No shared state between
|
||
test files — eliminates cross-file mock pollution.
|
||
```
|
||
|
||
这句话暗示了一个残酷的事实:`mock.module` 在 Bun 中是 **process-global last-write-wins**。你在测试文件 A 里调用 `mock.module('src/utils/log.ts', fakeLog)`,同进程里任何后续 `require('src/utils/log.ts')` 或 `import ... from 'src/utils/log.ts'` 都会拿到 `fakeLog`——无论调用方用的是什么路径字符串,无论它写在哪个文件里。`require()` 和 `import()` 共享同一张模块注册表。
|
||
|
||
这意味着:如果你在 `launchSchedule.test.ts` 里 mock 了 `triggersApi.ts`(上层业务模块),同目录的 `api.test.ts`(回归测试)再 `import('../triggersApi.js')` 时拿到的已经是 mock 版本——它本来要测试的"真实 HTTP 方法/URL/错误处理逻辑"全部消失了。
|
||
|
||
这就是 CLAUDE.md 里那条铁律的来源:
|
||
|
||
> **不要 mock 被测模块的上层业务模块。**
|
||
|
||
## 副作用链:为什么 log.ts 和 debug.ts 是必须 mock 的根
|
||
|
||
测试中 mock 的唯一合法动机是"被 mock 的模块有副作用,阻止它在测试环境正常加载"。
|
||
|
||
打开 `src/bootstrap/state.ts:7`,你会看到文件顶部有两个 import:
|
||
|
||
```ts
|
||
import { realpathSync } from 'fs'
|
||
```
|
||
|
||
`bootstrap/state.ts` 在模块加载时调用 `realpathSync` 去解析当前工作目录(`state.ts:266`),同时用 `randomUUID` 生成 session ID(`state.ts:326`)。这俩都是真正的 I/O 副作用——在测试进程里,工作目录可能不存在,或者你不想要真实的 session ID。
|
||
|
||
`log.ts` 和 `debug.ts` 都依赖 `bootstrap/state.ts`。打开 `tests/mocks/log.ts:4`,注释写得一清二楚:
|
||
|
||
```
|
||
Cuts the bootstrap/state.ts dependency chain (module-level realpathSync + randomUUID).
|
||
Must be called via mock.module("src/utils/log.ts", logMock) BEFORE any import that
|
||
transitively depends on log.ts.
|
||
```
|
||
|
||
所以依赖链是这样的:
|
||
|
||
```
|
||
log.ts → bootstrap/state.ts → realpathSync (I/O 副作用)
|
||
debug.ts → bootstrap/state.ts → randomUUID (I/O 副作用)
|
||
```
|
||
|
||
必须 mock `log.ts` / `debug.ts` 才能安全地导入任何依赖它们的模块。但这引出了一个问题:为什么不直接 mock `bootstrap/state.ts` 呢?
|
||
|
||
打开 `tests/mocks/state.ts:1`,答案是:**两者都 mock 了**。`stateMock` 存在,但 `log.ts` / `debug.ts` 的共享 mock 优先被使用,因为它们更轻量——大多数测试只需要 "log 别崩溃",不需要一个完整的 90 行 state mock。
|
||
|
||
`logMock` 本身只有 23 行(`tests/mocks/log.ts:10-24`),把所有导出替换成 noop。`debugMock` 也只有 25 行(`tests/mocks/debug.ts:10-25`),所有函数返回 false/null/noop。两者都是 **factory 函数**(`export function logMock() { return { ... } }`),因为 `mock.module` 要求每次调用返回一个新对象——这是 Bun 的约束,不是设计选择。
|
||
|
||
如果不这么做会怎样?如果某个测试文件直接 mock `bootstrap/state.ts` 而其他文件通过 `log.ts` 间接依赖它,后者的 mock 会被前者的 `mock.module` 覆盖(last-write-wins)。共享 mock 文件确保了 "log 在所有测试里都是同一个 mock"。
|
||
|
||
## launch*.test.ts 和 api.test.ts 的共生关系
|
||
|
||
打开 `src/commands/schedule/__tests__/` 目录,你会看到两个文件并排:
|
||
|
||
- `launchSchedule.test.ts` — 集成测试,测 `callSchedule()` 的完整调用链
|
||
- `api.test.ts` — 回归测试,测 `triggersApi.ts` 的 HTTP 方法/URL/重试逻辑
|
||
|
||
`api.test.ts` 的测试目标很具体(`api.test.ts:6` 的注释):
|
||
|
||
```
|
||
Key invariants under test:
|
||
- updateTrigger MUST use POST, not PATCH
|
||
- All CRUD endpoints hit /v1/code/triggers (not /v1/agents)
|
||
- 401/403/404/429/5xx classified correctly
|
||
- withRetry retries only 5xx, not 4xx
|
||
```
|
||
|
||
这些不变量测试的是 `triggersApi.ts` **真实的 HTTP 行为**。如果你在 `launchSchedule.test.ts` 里 mock 了 `triggersApi.ts`,`api.test.ts` 导入的 `triggersApi` 就变成了一个空壳——POST/PATCH 区分、URL 路径、错误分类逻辑全丢了。
|
||
|
||
所以铁律是:**`launch*.test.ts` mock axios(底层 HTTP 层),`api.test.ts` 让真实的 `triggersApi` 跑在 mock 的 axios 之上**。两个测试文件共享同一个 `setupAxiosMock()` 基础设施,但互不干扰。
|
||
|
||
打开 `launchSchedule.test.ts:1-9`,策略声明很明确:
|
||
|
||
```
|
||
Strategy per feedback_mock_dependency_not_subject:
|
||
- DO NOT mock triggersApi.ts itself (would pollute api.test.ts)
|
||
- Mock axios (the underlying HTTP layer) to control API responses
|
||
- Mock auth dependencies so real triggersApi functions can build headers
|
||
- Let real triggersApi functions run real code paths
|
||
```
|
||
|
||
`launchVault.test.ts:4` 和 `launchSkillStore.test.ts:8` 也用了同样的策略注释。这不是临时约定,而是整个项目的统一规范。
|
||
|
||
## setupAxiosMock:为什么它不是普通的 shared mock
|
||
|
||
打开 `tests/mocks/axios.ts:61-121`,`setupAxiosMock()` 的实现很有意思。它不是普通的 "返回一组 stub 函数"——它注册了一个 `mock.module('axios', ...)`,但这个 mock **只在 handle.useStubs 为 true 时生效**:
|
||
|
||
```ts
|
||
export function setupAxiosMock(): AxiosMockHandle {
|
||
const handle: AxiosMockHandle = { useStubs: false, stubs: {} }
|
||
|
||
mock.module('axios', () => {
|
||
const route = (method: keyof AxiosMethodStubs): AnyFn => {
|
||
const realFn = _realDefault[method] as AnyFn | undefined
|
||
return (...args: unknown[]) => {
|
||
if (handle.useStubs) {
|
||
const stub = handle.stubs[method] as AnyFn | undefined
|
||
if (stub) return stub(...args)
|
||
}
|
||
if (typeof realFn === 'function') return realFn(...args)
|
||
throw new Error(`axios.${method} is not available on real axios`)
|
||
}
|
||
}
|
||
// ...
|
||
})
|
||
```
|
||
|
||
注意第 30 行:`const _realAxios = require('axios')`。它在 mock 注册**之前**就拿到了真实的 axios 模块引用。这意味着即使 mock 激活后,`route` 函数内部仍然可以 fall through 到真实的 axios 方法。`useStubs` 开关控制的是 "用 stub 还是用真实的 axios"。
|
||
|
||
这种设计的巧妙之处在于:**不需要恢复 mock**。`afterAll(() => { axiosHandle.useStubs = false })` 就足够了——mock 仍然存在,但所有请求都 fall through 到真实 axios。后续测试文件如果也调用 `setupAxiosMock()`,Bun 的 last-write-wins 会用新 mock 替换旧的(但这正是预期的行为——每个测试文件拿到自己的 handle)。
|
||
|
||
如果不这么做会怎样?如果 `setupAxiosMock` 在 `afterAll` 里调用 `mock.module('axios', () => realAxios)` 来恢复,那么第二个测试文件的 `setupAxiosMock()` 注册的 mock 会在第一个文件的 `afterAll` 执行后被**覆盖回真实 axios**。这种时序依赖正是 Bun 的 process-global mock 带来的根本问题——`useStubs` 开关巧妙地绕开了它。
|
||
|
||
## node:fs/promises 的 require() 逃逸技巧
|
||
|
||
`launchSkillStore.test.ts:87-114` 展示了一个更极端的防御措施。它需要 mock `node:fs/promises` 的 `mkdir` 和 `writeFile`,但 `node:fs/promises` 有几十个导出(readFile、readdir、unlink、chmod...)。如果只 mock 这两个,同进程里其他测试的 `readFile` 调用全部会崩溃。
|
||
|
||
解决方案:**在 mock factory 内部用 `require()` 拿到真实的 fs/promises 模块,然后 spread 它**:
|
||
|
||
```ts
|
||
mock.module('node:fs/promises', () => {
|
||
const real = require('node:fs/promises') as Record<string, unknown>
|
||
return {
|
||
...real,
|
||
mkdir: (...args: unknown[]) =>
|
||
useSkillStoreFsStubs
|
||
? mkdirMock(...args)
|
||
: (real.mkdir as (...a: unknown[]) => Promise<unknown>)(...args),
|
||
writeFile: (...args: unknown[]) =>
|
||
useSkillStoreFsStubs
|
||
? writeFileMock(...args)
|
||
: (real.writeFile as (...a: unknown[]) => Promise<unknown>)(...args),
|
||
}
|
||
})
|
||
```
|
||
|
||
注释(第 88-91 行)解释了为什么这是必要的:
|
||
|
||
```
|
||
Bun's mock.module is global per-process and last-write-wins. Replacing
|
||
node:fs/promises with only mkdir + writeFile breaks every other test in
|
||
the same `bun test` run that imports readFile / readdir / unlink / chmod /
|
||
etc.
|
||
```
|
||
|
||
注意 `require('node:fs/promises')` 写在 factory 函数**内部**——`mock.module` 的 factory 是惰性求值的,每次模块被 require/import 时才执行。这意味着 `require()` 在 factory 内部能绕过 mock 注册表,拿到真正的原始模块。
|
||
|
||
如果没有这个技巧,要么每次 `bun test` 只跑一个文件(丧失并行效率),要么为 `node:fs/promises` 维护一个包含所有导出的巨型 mock(维护噩梦)。
|
||
|
||
## 排查 mock 污染的四步法
|
||
|
||
CLAUDE.md 里记录的排查方法值得逐条拆解:
|
||
|
||
**第 1 步:单独运行确认通过。** `bun test path/to/suspect.test.ts`。如果单独跑就失败,问题不在 mock 污染,在测试本身。
|
||
|
||
**第 2 步:同目录一起跑定位污染源。** `bun test path/to/__tests__/`。如果同目录的文件一起跑时 `api.test.ts` 开始失败,而单独跑时通过,说明同目录某个文件在 mock 被测模块的上层。
|
||
|
||
**第 3 步:console.error milestone 追踪顺序。** 在两个文件头部各加 `console.error('[filename] milestone')`。因为 Bun 的测试文件执行顺序不是严格的字母序,你不能假设 `api.test.ts` 一定在 `launchSchedule.test.ts` 之后执行。实际的执行顺序取决于 `bun test` 的内部文件遍历策略。
|
||
|
||
**第 4 步:检查 specifier 解析。** 即使两个测试文件写的是不同的路径字符串(一个写 `'../triggersApi.js'`,另一个写 `'src/commands/schedule/triggersApi.js'`),如果 Bun 把它们解析到同一个模块 ID,`mock.module` 仍然会污染。这是 Bun 模块解析的特性——路径别名(`src/*`)和相对路径可能指向同一个文件。
|
||
|
||
## 为什么不切换到 Vitest 或 Jest
|
||
|
||
看到这里你可能在想:既然 `bun:test` 的 mock 这么坑,为什么不用 Vitest 的 `vi.mock`(per-file 隔离)或 Jest 的 `jest.mock`(同样 per-file 隔离)?
|
||
|
||
答案是 **运行时一致性**。这个项目在 Bun 运行时上构建(`build.ts` 用 `Bun.build()`,`scripts/dev.ts` 用 `bun -d` 注入 MACRO),测试需要在相同运行时执行才能覆盖 `bun:bundle`、`bun:ffi`、Bun 特有的 `import.meta` 行为。Vitest 底层用的是 Vite(Node.js),无法还原这些运行时特性。
|
||
|
||
`bun:test` 的 `mock.module` 是 process-global 这一事实,是 "用 Bun 的测试框架就得接受 Bun 的约束" 的又一个例证——跟第一章(Code Splitting 生存需求)、第三章(performanceShim JSC 补丁)的叙事主线一致:**每一个看似奇怪的决定背后都有一个具体的运行时约束**。
|
||
|
||
## 共享 mock 的维护纪律
|
||
|
||
回到 `tests/mocks/` 目录。打开任一 mock 文件,你会看到统一的模式:factory 函数 + 注释说明为什么要 mock。`stateMock`(`tests/mocks/state.ts`)是最重量级的,90 行,覆盖了 `bootstrap/state.ts` 的所有导出。但它不是默认使用的——只有直接测试 state 相关逻辑时才引入。
|
||
|
||
核心原则:**mock 的表面应该和被 mock 模块的导出表保持同步**。源文件新增导出时,如果某个测试因此报错,应该更新 `tests/mocks/` 下的对应文件——而不是在测试文件内联 mock。这样所有依赖同一个 mock 的测试文件都自动受益。
|
||
|
||
CLAUDE.md 把这条写成了硬规则:
|
||
|
||
```
|
||
源文件导出变更时只需更新 tests/mocks/ 下的对应文件,不需要逐个修改测试。
|
||
```
|
||
|
||
如果没有这条规则和共享 mock 机制,每个测试文件都会内联自己的 log mock / debug mock / state mock。一旦 `log.ts` 新增一个导出,你需要在几十个文件里同步修改。这不仅是维护噩梦,还容易出现版本漂移——有的测试 mock 了旧版本的导出表,有的 mock 了新版本的,导致不可预测的测试行为。
|
||
|
||
## 延伸阅读
|
||
|
||
- 想看依赖 `bootstrap/state.ts` 模块级副作用的根本原因(为什么 `realpathSync` 和 `randomUUID` 在 import 时执行),见 [第十一章:三层状态管理](./11-state-management.md)
|
||
- 想看 `bun:test` 的 process-global mock 如何影响了 `node:fs/promises` 的测试隔离(require 逃逸技巧),见 [第一章:Code Splitting 不是优化,是生存需求](./01-code-splitting.md) 中关于 Bun 运行时约束的讨论
|
||
- 想看 `setupAxiosMock` 的 mock 开关机制与 `triggersApi.ts` 中 `withRetry` 重试逻辑的交互,见 [第九章:Usage 字段映射与模型映射的优先级链](./09-usage-mapping.md) 中关于 429/5xx 错误分类的部分
|