一个格式化器把 build:cache:hit 变成了以 cache 结尾的记录;换一个输入,输出里又多出两处双空格。代码没有测试,也没人告诉你这是不是故意的。在让 AI 简化它之前,需要先有办法察觉这些结果是否变了。

假设这个格式化器把 label:detail 字符串变成带日期的记录。下载完整的行为刻画测试示例,用 Node.js 22 或更新版本运行。本次记录使用 22.22.1,无需第三方依赖。

先观察几个输入,再考虑更好的实现

解压后进入 characterization-tests-before-ai-refactoring 目录,运行:

node observe.mjs original

命令以 JSON 打印十二组观察,每组包含输入字符串、传入的时刻、传入的环境值和输出。先看普通输入,再一次改一个细节。下面取出其中两组,只保留输入和输出:

[
  {
    "text": "build:cache:hit",
    "output": "2026-10-13 | BUILD | cache"
  },
  {
    "text": " build : ready ",
    "output": "2026-10-13 | BUILD  |  ready"
  }
]

第二条输出的最后一个 | 两侧各有两个空格,查看时先保留它们。其他观察还显示:build 和 build: 都以 - 结尾;标签为空时,采用传入的 NOTE_LABEL,该值缺失或为空时再用 note。

再打开 original.mjs:

export function formatNote(text, runtime = systemRuntime) {
  const fields = text.trim().split(':');
  const fallback = runtime.readEnv('NOTE_LABEL') || 'note';
  const label = (fields[0] || fallback).toUpperCase();
  const detail = fields[1] || '-';
  const day = runtime.now().toISOString().slice(0, 10);
  return `${day} | ${label} | ${detail}`;
}

fields[1] 解释了为什么丢掉 hit:后面的字段根本没有被使用。最开始的 trim() 作用于整个字符串,所以 build 后面和 ready 前面的空格仍在;渲染时又加上分隔符两边的空格。

先拿到具体结果,才知道该追问什么。Michael Feathers 在 Unit Conversations 中也描述了这样的过程:调用现有代码、查看响应,再把有用的交互保存成测试。

把日期和环境值变成明确输入

输出里带着今天的日期,直接保存就可能在明天失败。开发者自己的 NOTE_LABEL 也会改变空标签的结果。这些值应该进入测试输入。

示例已经提供一个很小的替换入口:可选的 runtime 参数。省略它时使用系统时钟和指定的进程环境变量;本文所有观察及测试命令都会传入 runtime.mjs 中的这个实现:

export function fixedRuntime({ at, env }) {
  return {
    now: () => new Date(at),
    readEnv: (name) => env[name],
  };
}

大多数用例使用 2026-10-13T12:00:00.000Z 和 { NOTE_LABEL: 'daily' }。其中一组传入 2026-10-13T23:30:00-07:00,输出日期却是 2026-10-14,因为 toISOString() 按 UTC 格式化。环境值缺失和为空也各有用例。

这种能替换依赖行为的位置通常称为 seam,也就是接缝。Fowler 的 Legacy Seam 讨论了如何隔开依赖,让测试选择它的行为。下载的示例已经有这个参数;真实代码如果把依赖藏在内部,应先单独引入最小的替换入口并审查,保持默认路径取值方式不变,再整理解析逻辑。

这里的测试没有全局替换时钟,也没有修改进程环境。另一次验证把子进程设为 TZ=Pacific/Kiritimati 并传入不同的 NOTE_LABEL,原版和重构版仍通过,说明这些观察由明确传入的值决定。默认适配器如何接入真实应用,仍需单独做集成检查。

保存实际结果,也保存尚有疑问的部分

cases.mjs 保存探索时使用的输入。运行并核对原版结果后,我们把它们保存进 characterized.json,包括那些意外的空格和被丢弃的字段。

行为刻画测试回答的是所选行为有没有改变,不证明记录下来的行为正确。例如,这里保留输出 cache,仍然可以另问:应用到底应不应该保留 cache:hit?

run.mjs 读取已保存的 JSON,把各个实现的输出与它比较:

for (const item of fixtures) {
  test(item.name, () => {
    const actual = versions[mode](item.text, fixedRuntime(item));
    assert.equal(actual, item.output);
  });
}

运行断言时不会再调用原版函数来生成期望值,因此修改原版实现也不会悄悄改写答案。observe.mjs 只负责打印,同样不会覆盖基线文件。

用原版对照这份记录:

node run.mjs original

十二项全部通过,覆盖普通输入、缺失与空详情、后续字段、内部空格、空输入、默认标签取值以及 UTC 日期。它们是选出的字符串输入;Unicode 大小写、非法日期、非字符串参数、副作用顺序和应用中的真实调用方,还没有在这里被刻画。

重构时先保持已记录的结果

保持行为的版本把解析和渲染提取为有名字的函数。解析函数继续采用原来的字段选择:

function parseFields(text) {
  const [label, detail] = text.trim().split(':');
  return { label, detail };
}

function render(day, label, detail) {
  return `${day} | ${label} | ${detail}`;
}

refactored.mjs 使用这两个函数,并保留默认值规则、转大写与 UTC 日期换算。运行:

node run.mjs refactored

十二条保存的观察全部通过,完整观察输出也与原版逐字节相同。这支持“本次重构保持了这些输入的结果”,而没有抹掉那些尚未检查的情况。

此时如果让 AI 帮忙,把源码和刻画用例一起交给它,要求保持现有输出字符串,并单独指出它认为应该改变的行为。只说“整理一下”,会给空格和分隔符的处理留下许多未说清楚的选择。

让看起来合理的整理明确失败

accidental.mjs 中的假设性整理使用了一个看起来更方便的解析器:

function parseFields(text) {
  const [label, ...rest] = text
    .trim()
    .split(':')
    .map((field) => field.trim());
  return { label, detail: rest.join(':') };
}

它分别清理字段空白,再把后续字段拼回去。分别运行三个版本:

node run.mjs original
node run.mjs refactored
node run.mjs accidental
版本 通过 失败 退出码
original 12 0 0
refactored 12 0 0
accidental 10 2 1,预期失败

失败的恰好是最先观察到的两个意外结果:build:cache:hit 现在以 cache:hit 结尾,带空格的输入现在得到 2026-10-13 | BUILD | ready。这两处变化没有被重新生成基线的操作藏起来。

可以运行 node observe.mjs accidental 查看新输出。需要在预期失败之后继续操作时,应分别执行命令;用 && 串联会在退出码 1 处停止。默认的 node --test run.mjs 只验证 original。

红色结果说明这次整理改变了已记录行为。新输出是否更合适,是接下来要单独决定的事。

单独决定一处行为变化

DECISION.md 记录一个拟议变化:对额外冒号的情况,保留 cache:hit,而不只保留 cache。它写明旧输出和拟议输出,没有把这个提案放进保持行为的重构。

真实应用中,应先查看代表性输入和这个字符串的消费者,再决定是否接受。观察记录无法告诉你用户是否需要被丢弃的部分,或者有人是否依赖较短的形式。如果决定采用新规则,再用单独的补丁修改实现,为相关预期变化注明理由,并补充新规则的回归用例;该补丁保持空白和日期规则不变。

此时可以继续做代码变更影响分析:观察指出差异,调用方及其测试帮助判断后果。如果补丁已经包含测试,还可以把修改过的断言与实现一起审,确认它们仍检查约定的行为。

用观察结果安排下一轮阅读

如果还找不到格式化值的来源,可以先沿着一个值读懂陌生代码库。定位格式化器后,把当前实现、已记录用例和拟议重构放在一起读。

可以选择向 AI 提问:

解释这个格式化器如何产生每条已记录输出。指出丢弃字段、保留空格、默认标签和日期处理对应的源码。提出保持这些观察结果的重构,把任何拟议行为变化单独列出。不要把保存的输出直接当成需求。