Files
claude-code/docs/outline-output/design/14-testing-strategy.md
2026-06-15 16:51:29 +08:00

13 KiB
Raw Blame History

第十四章:测试策略 —— 为什么 mock 必须从底层 HTTP 开始

Bun 的 mock.module 是进程全局的,一个测试文件的 mock 会让整个进程中毒

mock.module 不是 Jest 的 jest.mock

大多数从 Jest/Vitest 迁移到 bun:test 的开发者会自然地假设 mock.modulejest.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

import { realpathSync } from 'fs'

bootstrap/state.ts 在模块加载时调用 realpathSync 去解析当前工作目录(state.ts:266),同时用 randomUUID 生成 session IDstate.ts:326)。这俩都是真正的 I/O 副作用——在测试进程里,工作目录可能不存在,或者你不想要真实的 session ID。

log.tsdebug.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.tsapi.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:4launchSkillStore.test.ts:8 也用了同样的策略注释。这不是临时约定,而是整个项目的统一规范。

setupAxiosMock为什么它不是普通的 shared mock

打开 tests/mocks/axios.ts:61-121setupAxiosMock() 的实现很有意思。它不是普通的 "返回一组 stub 函数"——它注册了一个 mock.module('axios', ...),但这个 mock 只在 handle.useStubs 为 true 时生效

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"。

这种设计的巧妙之处在于:不需要恢复 mockafterAll(() => { axiosHandle.useStubs = false }) 就足够了——mock 仍然存在,但所有请求都 fall through 到真实 axios。后续测试文件如果也调用 setupAxiosMock()Bun 的 last-write-wins 会用新 mock 替换旧的(但这正是预期的行为——每个测试文件拿到自己的 handle

如果不这么做会怎样?如果 setupAxiosMockafterAll 里调用 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/promisesmkdirwriteFile,但 node:fs/promises 有几十个导出readFile、readdir、unlink、chmod...)。如果只 mock 这两个,同进程里其他测试的 readFile 调用全部会崩溃。

解决方案:在 mock factory 内部用 require() 拿到真实的 fs/promises 模块,然后 spread 它

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 把它们解析到同一个模块 IDmock.module 仍然会污染。这是 Bun 模块解析的特性——路径别名(src/*)和相对路径可能指向同一个文件。

为什么不切换到 Vitest 或 Jest

看到这里你可能在想:既然 bun:test 的 mock 这么坑,为什么不用 Vitest 的 vi.mockper-file 隔离)或 Jest 的 jest.mock(同样 per-file 隔离)?

答案是 运行时一致性。这个项目在 Bun 运行时上构建(build.tsBun.build()scripts/dev.tsbun -d 注入 MACRO测试需要在相同运行时执行才能覆盖 bun:bundlebun:ffi、Bun 特有的 import.meta 行为。Vitest 底层用的是 ViteNode.js无法还原这些运行时特性。

bun:testmock.module 是 process-global 这一事实,是 "用 Bun 的测试框架就得接受 Bun 的约束" 的又一个例证——跟第一章Code Splitting 生存需求、第三章performanceShim JSC 补丁)的叙事主线一致:每一个看似奇怪的决定背后都有一个具体的运行时约束

共享 mock 的维护纪律

回到 tests/mocks/ 目录。打开任一 mock 文件你会看到统一的模式factory 函数 + 注释说明为什么要 mock。stateMocktests/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 了新版本的,导致不可预测的测试行为。

延伸阅读