改动不多,测试全绿,可只剩三个名额时,预订四个也成功了。剩余数量看起来甚至很正常:零。把测试文件的 diff 一起打开,问题才露出来:原来要求抛错的地方,现在只检查结果是不是非负数。
假设让 AI 简化一个计算剩余名额的函数,同时保留原来的接受和拒绝规则。下面的补丁和测试为教程编写,用 Node 内置测试工具就能在本地运行。
先写清楚原来的测试在保护什么
这里假定上游已经检查过 available:它是非负安全整数。函数接收预订数量,返回准确的剩余数量;请求不合法时抛出 RangeError。允许的数量是从 1 到 available 的数字类型安全整数,不做取整、字符串转换,也不接受部分预订。
export function remainingSeats(available, requested) {
if (
!Number.isSafeInteger(requested) ||
requested < 1 ||
requested > available
) {
throw new RangeError(
'requested seats must be an integer from 1 to available',
);
}
return available - requested;
}
剩三个名额时,几种结果都可以手算:
| 请求数量 | 应有行为 | 原因 |
|---|---|---|
1、2、3 |
分别返回 2、1、0 |
完整接受请求 |
0、-1、1.5 |
抛出 RangeError |
数量不能是零、负数或小数 |
4 |
抛出 RangeError |
剩余名额不足以满足整个请求 |
'2'、NaN、Infinity |
抛出 RangeError |
必须是数字类型的安全整数 |
没有剩余名额时,请求一个也要报错。注意,返回零已经表示“刚好订完”,不能再拿它表示失败。
审查真实代码时,要从需求、现有调用方和基线版本的测试里确认这些规则。旧断言也可能写错,但修改它之前,得说明究竟要改哪一种行为。这个例子的要求很明确:原来的拒绝规则要保留。
把两份 diff 中删掉的东西放在一起看
补丁把输入检查换成了截断:
export function remainingSeats(available, requested) {
- if (
- !Number.isSafeInteger(requested) ||
- requested < 1 ||
- requested > available
- ) {
- throw new RangeError(
- 'requested seats must be an integer from 1 to available',
- );
- }
- return available - requested;
+ const count = Math.min(available, Math.max(1, Math.trunc(requested)));
+ return available - count;
}
传入 (3, 4),局部变量 count 被压成 3,函数返回 0。传入 (3, 1.5),它先取整为 1,最后返回 2。普通请求仍然算对了,但原来应该报错的请求现在也能成功返回。
再看测试。三项修改都用了下面的写法:
- test('rejects more seats than available', () => {
- assert.throws(() => remainingSeats(3, 4), RangeError);
+ test('handles more seats than available', () => {
+ assert.ok(remainingSeats(3, 4) >= 0);
});
输入没变,要求的行为变了。新断言直接调用函数;如果函数还像以前那样抛错,这条测试反而会失败。悄悄接受超额请求的实现,现在可以通过。测试名也从“拒绝”改成了“处理”,看报告时更难发现规则已经被换掉。
零和小数的测试也删掉了异常断言。整个测试文件仍然有五项测试,其中两项正常预订测试完全没动。数一数测试有几条,看不出哪些行为已经没人检查了。
在真实项目里,可以把实现与测试一起展开,再读基线中的原断言:
git diff BASE HEAD -- src/remaining-seats.mjs test/remaining-seats.test.mjs
git show BASE:test/remaining-seats.test.mjs
把版本与路径换成项目里的实际值,记下这次对比的准确版本。如果工作区已经有后续修改,别把工作区里的调用方当成所选提交里的代码。下载包里的 ai-style.patch 可以直接阅读,不需要为例子创建 Git 提交。
每看到一条被删掉的检查,就试着写出一个输入:删掉 requested > available,反例就是 (3, 4)。每看到一条被删掉的断言,就说清它原来要求什么:这个请求必须在调用方继续执行前抛错。这样两边就连起来了。
打开那个一行都没改的调用方
下面的调用方由两个版本共用,补丁没有修改它。为了让例子完全在内存中运行,数量函数和同步的 save 都通过参数传入:
export function createBooking(remainingSeats, save) {
return function book(available, requested) {
const remaining = remainingSeats(available, requested);
const reservation = { seats: requested, remaining };
save(reservation);
return reservation;
};
}
这里有两个数量。remaining 来自计算函数,记录中的 seats 却仍然使用原始的 requested。旧实现要么完整接受这个数量,要么在执行到 save 之前抛错。换成补丁后,计算时用的是三个,保存时写的却是四个。
让同一个调用方接收 (3, 4),实际得到:
| 使用的实现 | 外层调用者看到的结果 | save 调用 |
|---|---|---|
| 原实现 | RangeError |
没有调用 |
| 补丁 | { seats: 4, remaining: 0 } |
调用一次,参数就是这个对象 |
这个例子观察到的是内存中错误的预订记录,以及一次保存调用。它没有连接数据库、扣款,也没有造成真实用户的预订。
在自己的项目里,搜索范围要越过“有改动的文件”列表:
rg -n 'remainingSeats|createBooking' src test
顺着 import 确认调用的是哪个实现,再看调用之后怎么处理:保存原始输入、构造响应,还是捕获某种错误?名字搜不全时,可以配合语言服务的引用查询,继续查看包装函数。代码阅读指南介绍了这些入口的用法。
保留异常检查,再单独观察有没有保存
把原来的三项拒绝测试恢复,就已经能抓住这个补丁。再补一项调用方测试,才能说明为什么不能删掉拒绝行为:
const writes = [];
const book = createBooking(remainingSeats, (row) => writes.push(row));
assert.throws(() => book(3, 4), RangeError);
assert.deepEqual(writes, []);
这里传入的是真实的原实现或补丁,只把 save 换成数组记录器。如果连数量函数也替换成“总是抛错”的桩,恰好绕过了要审查的改动。
这段紧凑的测试会在第一条断言失败后停止。下载包还把“没有尝试保存”单独写成了一项测试,方便一次运行同时看见两个问题:
const writes = [];
const book = createBooking(remainingSeats, (row) => writes.push(row));
try {
book(3, 4);
} catch {
// A separate test checks that the error is RangeError.
}
assert.deepEqual(writes, []);
这个空 catch 只用于继续检查副作用。单独保留它会太弱:只要在保存前随便抛一个错误,也能通过。完整测试另外要求抛出 RangeError,检查合法预订准确保存一次,并确认保存失败会传回调用者。所以,把所有请求都拒绝掉,或者什么也不做,同样过不了。
验证套件共 15 项:三项准确的成功返回值,八项非法输入(包括名额售罄),以及四项调用方测试。预期值来自前面写明的规则,不是把补丁里的截断公式复制进断言。
用同一组要求分别跑原实现和补丁
下载完整示例,解压后在其中的 review-ai-generated-tests 文件夹打开终端。需要 Node.js 22 或更新版本,不用安装依赖、连接网络或配置模型服务商。
node run.mjs baseline
node run.mjs proposed-original
node run.mjs proposed-weak
node run.mjs verify-baseline
node run.mjs verify-proposed
每条命令都通过 node:test 注册测试,并按真实测试结果退出。请分别执行:第二条和第五条就是要失败。不要用 && 串起来,否则遇到第一次预期失败就会停。
下面是在 macOS arm64、Node.js v22.22.1 上实际跑出的结果:
| 模式 | 实现 / 断言 | 通过 | 失败 | 退出码 |
|---|---|---|---|---|
baseline |
原实现 / 原断言 | 5 | 0 | 0 |
proposed-original |
补丁 / 原断言 | 2 | 3 | 1 |
proposed-weak |
补丁 / 弱化断言 | 5 | 0 | 0 |
verify-baseline |
原实现 / 验证套件 | 15 | 0 | 0 |
verify-proposed |
补丁 / 验证套件 | 5 | 10 | 1 |
补丁会报 Missing expected exception (RangeError).,意思是预期的异常没有发生。单独检查保存的测试还会显示:实际数组里有 { seats: 4, remaining: 0 },预期却是空数组。执行 node show-caller.mjs 可以直接打印两个版本的调用结果;这个命令只展示结果,没有断言哪一个正确。
需要一个默认全绿的检查命令时,运行 node --test run.mjs,它选择的是 verify-baseline。这里没有把“出现错误结果”写成应该通过的验证:同一套验证始终要求正确行为,补丁运行后确实失败。命令没有使用特定操作系统的 shell 语法,但这次只在 macOS 实测,没有跑 Linux、Windows 矩阵。
把这个做法用到自己的项目时,先保留基线中相关的断言,让它们跑补丁代码,再补上 diff 暗示的最小调用方反例。如果新测试在旧实现和错误补丁上都能通过,回头看输入与断言,确认它有没有区分出那条被改掉的规则。
把失败结果写成能处理的审查意见
这次审查可以这样写:
remainingSeats(3, 4)原来抛错,改为截断后返回零。未修改的createBooking随后保存{ seats: 4, remaining: 0 }。本次修改的三项测试原本要求RangeError,现在只检查结果非负,刚好放过这个行为。请保留拒绝检查,并验证超额预订不会调用save。原实现通过全部 15 项验证,补丁失败 10 项。
按当前需求,保留原来的输入校验,恢复异常断言即可。如果产品确实要允许部分预订,就要先定义实际接受的数量怎么传给调用方、用户确认的数量是什么,再一起修改实现、调用方和测试。只在辅助函数里悄悄改一个局部数量,这些问题都还没解决。
示例覆盖同步校验和对同步保存函数的调用,假定 available 已合法。并发抢名额、数据库原子性、重试和权限检查都不在这个范围内,需要到实际集成位置另外验证。
Yenpo 可以帮助你阅读实现、测试与调用方,比较已提交的版本。当前系统要求见安装指南;这套 Node 示例与审查方法不依赖 Yenpo。在 Git 对比中核对好所选版本后,可以这样问:
找出这个补丁删掉的输入接受规则,以及一起修改的断言。继续看依赖这些异常的未修改调用方。每个问题给出具体输入、应有结果和源码位置;基线里原有的问题与新回归分开说明。