目录
  1. 1. 一、核心理念
    1. 1.1. 问题
    2. 1.2. 解决方案
  2. 2. 二、三种 VCR 函数
    1. 2.1. 2.1 withVCR — 批量消息录制/回放
    2. 2.2. 2.2 withStreamingVCR — 流式响应录制/回放
    3. 2.3. 2.3 withTokenCountVCR — Token 计数录制/回放
  3. 3. 三、VCR 启动条件
  4. 4. 四、脱敏(Dehydrate)机制
    1. 4.1. 问题
    2. 4.2. dehydrateValue 脱敏规则
    3. 4.3. hydrateValue 还原规则
  5. 5. 五、UUID 唯一性保障
    1. 5.1. 问题
    2. 5.2. 解决方案
  6. 6. 六、CI 中的 Fixture 强制检查
  7. 7. 七、通用 Fixture 管理(withFixture)
  8. 8. 八、成本追踪集成
  9. 9. 九、关键设计决策
  10. 10. 十、面试要点
【Claude Code源码剖析】27-VCR 测试基础设施

源码路径:src/services/vcr.ts(406行)
VCR(Video Cassette Recorder)是 CC 的 API 调用录制/回放系统——让测试无需真实调用 Anthropic API,同时保证与真实 API 行为完全一致。


一、核心理念

问题

测试场景 挑战
单元测试跑 Agentic Loop 需要调用 Claude API,慢且昂贵
CI/CD 环境 没有 API Key 或配额,无法调用
测试一致性 API 返回随机,测试结果不可复现
开发迭代速度 每次测试等待 API 响应

解决方案

VCR 模式:首次运行时”录制”真实 API 响应存为 fixture 文件,后续复用”回放”录制内容——完全跳过网络调用。

VCR_RECORD=1(录制模式)          正常模式(回放)
↓ ↓
调用真实 Anthropic API 读取 fixture 文件
↓ ↓
保存 fixture JSON 返回缓存响应
↓ ↓
返回响应 完全跳过网络

二、三种 VCR 函数

2.1 withVCR — 批量消息录制/回放

withVCR(
messages: Message[],
f: () => Promise<(AssistantMessage | StreamEvent | SystemAPIErrorMessage)[]>
): Promise<(AssistantMessage | StreamEvent | SystemAPIErrorMessage)[]>

用途:Agentic Loop 中非流式的完整 API 响应录制。

Fixture 命名策略

// 用消息内容的 SHA1 哈希作为文件名
// 多条消息:取每条消息的前6位 hash,用"-"连接
const filename = `fixtures/${messages.map(m =>
sha1(dehydrateValue(m.content)).slice(0, 6)
).join('-')}.json`

// 例:fixtures/a3f2b1-c9d8e7-f1a2b3.json

2.2 withStreamingVCR — 流式响应录制/回放

async function* withStreamingVCR(
messages: Message[],
f: () => AsyncGenerator<StreamEvent | AssistantMessage | SystemAPIErrorMessage>
): AsyncGenerator<StreamEvent | AssistantMessage | SystemAPIErrorMessage>

用途:流式 API 响应的录制——先消费完整个 generator 缓存,再按顺序回放。

// 录制时:消费 generator 收集到 buffer
const buffer = []
for await (const message of f()) { buffer.push(message) }

// 回放时:yield* cachedBuffer(模拟流式输出)

2.3 withTokenCountVCR — Token 计数录制/回放

withTokenCountVCR(
messages: unknown[],
tools: unknown[],
f: () => Promise<number | null>
): Promise<number | null>

用途:/count_tokens API 调用的录制。

额外脱敏处理

// Token count 的 fixture hash 做了更深度的脱敏:
const dehydrated = dehydrateValue(jsonStringify({messages, tools}))
.replaceAll(cwdSlug, '[CWD_SLUG]') // 工作目录 slug 化
.replace(/UUID-pattern/g, '[UUID]') // UUID 标准化
.replace(/timestamp-pattern/g, '[TIMESTAMP]') // 时间戳标准化

这样不同机器、不同时间跑出的 hash 相同,fixture 可以在团队间共享。


三、VCR 启动条件

function shouldUseVCR(): boolean {
// 条件1:测试环境(NODE_ENV === 'test')
if (process.env.NODE_ENV === 'test') return true

// 条件2:内部用户强制开启(dogfooding)
if (process.env.USER_TYPE === 'ant' && isEnvTruthy(process.env.FORCE_VCR)) return true

return false
}

四、脱敏(Dehydrate)机制

问题

Fixture 文件的 hash 必须跨机器、跨时间保持一致,但消息中包含大量环境相关信息:

  • 工作目录绝对路径(/home/zhanglin/project
  • 配置目录(~/.claude
  • UUID(每次生成不同)
  • 时间戳
  • 动态数字(文件数量、执行时长、成本)

dehydrateValue 脱敏规则

function dehydrateValue(s: string): string {
return s
.replace(/num_files="\d+"/g, 'num_files="[NUM]"')
.replace(/duration_ms="\d+"/g, 'duration_ms="[DURATION]"')
.replace(/cost_usd="\d+"/g, 'cost_usd="[COST]"')
.replaceAll(configHome, '[CONFIG_HOME]') // ~/.claude → [CONFIG_HOME]
.replaceAll(cwd, '[CWD]') // /home/user/proj → [CWD]
.replace(/Available commands:.+/, 'Available commands: [COMMANDS]')
// 此外,含 "Files modified by user:" 的整个字符串会被替换为
// 'Files modified by user: [FILES]'(防止文件列表因环境不同导致 hash 不一致)
}

Windows 兼容性

// Windows 路径有三种形式,全部处理:
// 1. 原始反斜杠: C:\Users\proj
// 2. 正斜杠变体: C:/Users/proj
// 3. JSON 转义变体: C:\\Users\\proj

不替换所有斜杠:原注释解释了为什么:

// 不替换所有前斜杠为 path.sep,会损坏 XML-like 标签
// 如 </system-reminder> 会变成 <\system-reminder>

hydrateValue 还原规则

function hydrateValue(s: string): string {
return s
.replaceAll('[NUM]', '1')
.replaceAll('[DURATION]', '100')
.replaceAll('[CONFIG_HOME]', getClaudeConfigHomeDir())
.replaceAll('[CWD]', getCwd())
}

五、UUID 唯一性保障

问题

sessionStorage.ts 使用 UUID 对消息去重。VCR 回放时如果多次 withVCR 调用返回相同 UUID,不同请求的响应会被识别为”重复消息”丢弃。

解决方案

function mapAssistantMessage(
message: AssistantMessage,
f: (s: unknown) => unknown,
index: number,
uuid?: UUID // 回放时传 randomUUID(),录制时用确定性 UUID
): AssistantMessage {
return {
// 回放路径:randomUUID() → 每次调用 VCR 都产生全局唯一 ID
// 录制路径:`UUID-${index}` → 确定性,fixture 文件内容稳定
uuid: uuid ?? (`UUID-${index}` as unknown as UUID),
requestId: 'REQUEST_ID', // 统一化,消除差异
...
}
}

六、CI 中的 Fixture 强制检查

// CI 环境中:fixture 缺失 = 测试失败(不允许新录制)
if ((env.isCI || process.env.CI) && !isEnvTruthy(process.env.VCR_RECORD)) {
throw new Error(
`Fixture missing: ${filename}.
Re-run tests with VCR_RECORD=1, then commit the result.`
)
}

工作流

开发者本地:VCR_RECORD=1 pnpm test
↓ 生成新 fixture 文件
git add fixtures/*.json && git commit

CI 环境:pnpm test(无 VCR_RECORD)
↓ 读取 fixture,不调用 API
↓ 测试通过

七、通用 Fixture 管理(withFixture)

async function withFixture<T>(
input: unknown,
fixtureName: string, // 'token-count' 等
f: () => Promise<T>
): Promise<T> {
// 1. 计算输入的 SHA1 hash(前12位)作为文件名后缀
const hash = sha1(jsonStringify(input)).slice(0, 12)
const filename = `fixtures/${fixtureName}-${hash}.json`

// 2. 尝试读取缓存
try { return jsonParse(await readFile(filename)) }
catch (e) { if (e.code !== 'ENOENT') throw e }

// 3. CI 环境且无 VCR_RECORD → 报错
if (env.isCI && !VCR_RECORD) throw new Error(...)

// 4. 执行真实调用 + 写入 fixture
const result = await f()
await writeFile(filename, jsonStringify(result, null, 2))
return result
}

八、成本追踪集成

// 回放 fixture 时同样累计会话成本
// 保持与真实调用相同的 cost tracking 行为
function addCachedCostToTotalSessionCost(
message: AssistantMessage | StreamEvent
): void {
const costUSD = calculateUSDCost(model, usage)
addToTotalSessionCost(costUSD, usage, model)
}

即使是 VCR 回放,fixture 中记录的 model/usage 信息也会被加入总成本,保持 cost tracking 测试的完整性。


九、关键设计决策

决策 原因
SHA1 内容寻址 相同输入永远映射到相同 fixture,无需手动管理
dehydrate/hydrate 让 fixture 跨机器/时间复用,团队共享
流式 VCR 先缓冲 Generator 无法直接序列化,先收集再存储
randomUUID 在回放时 防止 sessionStorage 的 UUID 去重丢消息
CI 强制 fixture 存在 防止 CI 静默跳过测试(fixture 缺失应是显式失败)
成本继续追踪 保持 cost tracking 系统在测试中的完整可测性

十、面试要点

Q:VCR 和 Mock 有什么本质区别?

Mock 替换接口行为(自定义返回),可能与真实 API 出现漂移。VCR 录制真实 API 响应,回放时完全重现真实行为。大型团队曾因 mock 与 prod 漂移,测试全绿但上线崩溃。CC 选择 VCR 是因为”AI 输出有语义”,mock 一个假的 AI 回复没有测试价值。

Q:为什么 Token Count VCR 需要额外的 UUID/时间戳标准化?

每次测试运行,消息里带新 UUID 和当前时间戳,导致 hash 不同,fixture 每次失效。标准化后相同逻辑输入产生相同 hash,团队成员 A 录制的 fixture 可以被成员 B 在 CI 中使用。

打赏
  • 微信
  • 支付宝

评论