用遗漏用例检验已经通过的智能体测试

使用 AI 辅助编写与复核。

本文目录

当智能体同时编写代码和测试时,全绿结果只回答了一个有限的问题:在那次运行中,这个实现是否满足了那些断言?判断变更时,还要问:这些断言能否拒绝一种看似合理、却违反需求的实现?

先写下一条可以独立于代码阅读的规则。然后选择一个输入,让正确实现与一种看似合理的错误实现必然给出不同结果。与泛泛要求“提高覆盖率”相比,这个遗漏用例更有用。

先写规则,再读测试

虚构需求 LIST-3 要把变更文件列表格式化为显示文本:

  • 输入是非空显示标签字符串组成的数组,标签中不含回车或换行。
  • 每个输入项精确产生一行:短横线、空格、未经修改的标签,再加 LF。
  • 保留给定顺序和重复标签。不要排序、去重或丢弃后面的项目。
  • 空输入产生空字符串,没有标题,也没有空行。

这些是原创教学需求,不是从某个仓库或文件名约定中推断出的规则。任意文件系统名称和无效输入类型,都不在这个示例的输入契约内。

下面是正确的小型实现:

export function correct(files) {
  return files.map(name => `- ${name}\n`).join('');
}

两个不同的错误替代实现如下:

export function firstOnly(files) {
  return files.length ? `- ${files[0]}\n` : '';
}

export function sortedUnique(files) {
  return [...new Set(files)].sort().map(name => `- ${name}\n`).join('');
}

firstOnly 丢掉了后面所有条目。sortedUnique 保留了一个看起来像样的列表,却改变了顺序,并移除了重复条目。任何测试执行之前,根据 LIST-3 就能看出它们的问题。

对三个实现应用同样的弱检查

在这段代码中,format 表示当前被审查的实现。本地运行器会把相同的四项检查应用到每个实现。

import assert from 'node:assert/strict';

// W1: empty output has no malformed bullet lines.
assert.ok(format([]).split('\n').filter(Boolean)
  .every(line => line.startsWith('- ')));

// W2: one familiar item works.
assert.equal(format(['src/app.js']), '- src/app.js\n');

// W3: a multi-item result contains the first familiar name.
assert.ok(format(['src/app.js', 'src/view.js'])
  .includes('src/app.js'));

// W4: a stub returns its hard-coded response.
const stub = () => '- src/app.js\n';
assert.equal(stub(['src/app.js']), '- src/app.js\n');

对筛选后的空列表,W1 是空真:没有任何一行能够违反该谓词。它不能确立精确的空输出要求。W2 从未检查后面的项目。W3 只检查某个名称是否存在,没有检查完整且有序的结果。W4 根本没有调用 format;一个在别处有用的桩测试,不能仅凭返回自身预期值,就为这个格式化函数提供证明。

Node 的带版本标签的断言文档 介绍了严格断言模式和相等性检查。严格比较可以执行你给定的预期字符串要求;它不能决定那个预期字符串是否表达了 LIST-3。

这些弱检查实际使用 Node.js v24.18.0 运行过,没有网络请求,也没有安装测试依赖:

实现 W1 W2 W3 W4 弱检查结果
correct 通过 通过 通过 通过 4/4
firstOnly 通过 通过 通过 通过 4/4
sortedUnique 通过 通过 通过 通过 4/4

全绿结果本身是准确的。但如果把它解释成“保留了顺序和重复项”的证据,这种解释就不准确。

从需求推导遗漏用例

运行更强的检查之前,先根据 LIST-3 写出字面预期字符串。表中的 \n 表示一个 LF 字符,并不是两个输出字符。

用例 完整输入 独立指定的预期输出
C0 [] ""
C1 ["src/app.js"] "- src/app.js\n"
C2 ["src/z.js", "src/a.js"] "- src/z.js\n- src/a.js\n"
C3 ["src/a.js", "src/a.js"] "- src/a.js\n- src/a.js\n"
C4 ["notes/Read Me.md", "界面/按钮.js"] "- notes/Read Me.md\n- 界面/按钮.js\n"
C5 ["docs/z.md", "src/app.js", "docs/a.md"] "- docs/z.md\n- src/app.js\n- docs/a.md\n"

C2 是这里最小而有用的挑战。它包含后续项目,而且初始顺序会被排序改变。C3 单独要求保留重复项;C4 检查带空格和非 ASCII 文本的标签能否原样保留。C5 则检查更长的序列。

完整数据文件 B84-02-cases.json 存放了这些字面预期。每个用例改进后的检查是:

for (const c of cases) {
  assert.equal(format(c.input), c.expected);
}

不要通过调用被测实现来生成 c.expected,也不要在不核对规则的情况下,把实现的返回值直接复制进示例数据。这样会抹掉你原本试图揭示的、来自独立依据的差异。

对照预测与实际执行

预测结果是根据需求和三个函数写出的。运行用例后,得到同样的矩阵:

实现 C0 C1 C2 C3 C4 C5 实际结果
correct 通过 通过 通过 通过 通过 通过 6/6
firstOnly 通过 通过 失败 失败 失败 失败 2/6
sortedUnique 通过 通过 失败 失败 通过 失败 3/6

对 C2,firstOnly 实际返回 "- src/z.js\n";sortedUnique 返回 "- src/a.js\n- src/z.js\n"。它们分别与同一个预期字符串的不同部分矛盾。

本地运行器为每个实现单独启动进程。两个错误实现面对改进后的测试套件时,都以退出码 1 结束。它们的断言失败保留在配套运行日志中,没有为了让整包材料看起来全部通过而被移除。完整示例的执行结果记录了运行时、用例、退出码和日志文件名。这些执行检查的是所展示的函数与固定用例,不是客户项目,也不构成穷尽性的正确性结论。

同样质疑新增断言

假设智能体提出新增以下测试:

assert.equal(
  format(['src/z.js', 'src/a.js']),
  '- src/a.js\n- src/z.js\n'
);

它期望排序,而 LIST-3 禁止排序。在这个用例中,实际执行会拒绝 correct,却接受 sortedUnique。因此,新增失败本身不足以成为修改实现的理由:测试的预期值也需要需求依据。

如果 LIST-3 仍是双方约定的需求,就移除或修正那条断言。如果用户确实想要排序输出,就记录变更后的需求,并有意更新相关示例。不要为了让测试全绿,就悄悄改变规则。

复制这份已通过测试套件的审查请求

按 LIST-3 审查虚构的变更文件列表格式化函数,不要以实现
当前的输出为标准。使用 B84-02-cases.json,以及
format-implementations.mjs 中的三个完整函数。

先说明现有每条弱断言能确立哪项需求。解释为什么针对空结果的
every() 断言是空真,为什么单项输入不能展示后续项目是否保留,
以及为什么只检查桩的断言没有运行目标函数。
保留合理的检查,不要把它们全部删掉。

执行之前,根据 LIST-3 为 C0-C5 推导字面输出。
对 correct、firstOnly 和 sortedUnique 应用相同的精确输出断言。
返回实现与用例的矩阵、分歧处的实际输出、命令、运行时,
以及保留的原始失败。尚未运行的预测必须如实标注。

独立检查拟议的排序输出断言。它与当前要求保留顺序的需求矛盾。
不要因为某个实现目前会产生该结果,就编辑 LIST-3 或认可
那个预期值。

工作只限这些新的教学文件。不安装依赖、不修改项目测试套件、
不读取客户代码,也不进行生产执行。返回有明确范围的测试建议;
独立复核和接受仍是后续单独步骤。不要从两个错误替代实现和
六个具名用例中,推断穷尽覆盖或一般性的变异测试得分。

现在,一次有用的审查可以指出缺少的区别:“展示第二个项目仍处于原来的位置。”这是一项具体的下一步检查,预期答案来自需求,而不是智能体当前的实现。