一個格式化器把 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 提問:
解釋這個格式化器如何產生每條已記錄輸出。指出丟棄欄位、保留空格、預設標籤和日期處理對應的原始碼。提出保持這些觀察結果的重構,把任何擬議行為變化單獨列出。不要把儲存的輸出直接當成需求。