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