让 AI 写测试是我用得最顺手的一件事:格式规整、边界想得比我全、一分钟能出十条用例。但用得久了会发现一个陷阱——测试全绿,功能还是坏的。这篇讲三种"假绿灯"和我的识别方法。
假绿灯之一:断言恒真
AI 生成断言时,最常写出的形式是"我断言它不报错":
func TestParseConfig(t *testing.T) {
got, err := ParseConfig("testdata/config.yaml")
if err != nil {
t.Fatalf("should not error: %v", err) // 只验证了不报错
}
_ = got // 结果根本没检查
}
这个测试能过,但把 ParseConfig 改成"永远返回空结构体"也能过。它测的是"函数不会崩",不是"函数做对了"。
识别方法很土:问自己一句「把实现改错,这个测试会不会红?」。如果不会,它就没有保护价值。
我现在的做法是在提示词里直接约束:
每个测试必须断言具体返回值或副作用(字段值、条数、状态变化),不允许只断言
err == nil。
假绿灯之二:把实现抄进断言
第二种更隐蔽——AI 会把被测函数的逻辑"重写一遍"放进测试里当期望值:
// 被测代码
func Discount(u User) int {
if u.Vip && u.Years >= 3 { return 30 }
if u.Vip { return 10 }
return 0
}
// AI 写的测试(期望值由同一套规则推导)
want := 0
if u.Vip && u.Years >= 3 { want = 30 } else if u.Vip { want = 10 }
这样写,一旦业务规则改了(比如 3 年变 5 年),实现和测试会一起改错,测试永远绿。
正确做法是写死具体的期望值,让它成为一条独立的事实:
cases := []struct{ name string; u User; want int }{
{"老会员", User{Vip: true, Years: 5}, 30},
{"新会员", User{Vip: true, Years: 1}, 10},
{"非会员", User{Vip: false, Years: 9}, 0},
}
假绿灯之三:为了通过而改测试
第三种最危险,因为它看起来像"修好了"。当你把失败告诉 AI,它有相当概率去改断言而不是改实现:
测试失败:
TestEscapeFormula期望'+8但得到+8。 AI:已修改测试期望值为+8。✅
它没说谎——测试确实通过了。但真实的 bug(Excel 公式注入)被"修"进了测试里,代码反而更危险了。
我的规矩是:测试失败了,只允许改实现;要改断言,必须先说清"原期望为什么是错的"。这句话必须是我来判断的,不是它。
我的三道检查
每次 AI 交来一批测试,我会做三件事:
- 反向验证:随机挑两三个测试,手动把实现改错(把
>=改成>、把return 30改成return 20),看测试是否变红。不变红的就是无效测试。 - 看断言内容:只断言
err == nil、只t.Log不断言、断言里出现和实现一样的推导逻辑——全部要求重写。 - 看数据来源:期望值必须写成常量或字面量,不能由被测代码或同一个辅助函数算出来。
顺便说说 AI 在测试上真正强的地方
不是说 AI 写测试不好——它有三个地方比我强得多:
- 穷举边界:0、负数、空字符串、超长字符串、Unicode、时区、闰年,它一次能列全(我经常漏);
- 模板化:表驱动、
t.Run子测试、并发用例的结构,写得又快又整齐; - 补错误路径:让它"把每个 return err 的分支都覆盖一遍",它比我耐心。
所以正确的分工是:让它生成用例清单和骨架,我来定义期望值并做反向验证。
小结
测试的价值不在于"数量"或"覆盖率数字",而在于实现错的时候它会红。AI 生成的测试默认满足不了这个条件——它擅长写"看起来对的测试"。把反向验证这一步补上,假绿灯就会现形;跳过这一步,你得到的只是一份让自己安心的假证据。