用 AI 写单元测试:表驱动、边界与错误路径
单元测试是 AI 最划算的落点:结构固定、反馈即时、不需要理解整个系统。「AI 生成的单测」与「能抓缺陷的单测」差距只有两处——边界值清单够不够全,错误路径断言有没有真在断言。
1. 先钉死骨架,再让 AI 填用例
表驱动(table-driven test)的骨架是固定的,把它写进提示词,AI 就不会自由发挥成十种风格。
角色:Go 测试工程师。
骨架要求:
- 一个 TestXxx 函数 + 匿名 struct 切片 cases + for range + t.Run;
- 字段固定为 name、in、want、wantErrIs(用 errors.Is 判断);
- name 用「场景/关键输入」格式,全体用例名唯一;
- 不引入 testify,不写 time.Sleep,不读环境变量;
- 外部依赖用接口参数注入替身,不要打桩全局变量。
任务:为 ParseAmount(raw string) (int64, error) 生成用例。
func TestParseAmount(t *testing.T) {
cases := []struct {
name string
in string
want int64
wantErrIs error
}{
{"正常/两位小数", "12.34", 1234, nil},
{"正常/整数无小数点", "100", 10000, nil},
{"正常/前导零", "0007", 700, nil},
{"边界/零", "0.00", 0, nil},
{"边界/最小一分", "0.01", 1, nil},
{"边界/空白包裹", " 12.34 ", 1234, nil},
{"异常/负数", "-1.00", 0, ErrNegativeAmount},
{"异常/超过上限", "100000000000.01", 0, ErrAmountOverflow},
{"异常/空字符串", "", 0, ErrEmptyAmount},
{"异常/只有小数点", ".", 0, ErrInvalidFormat},
{"异常/三位小数", "1.234", 0, ErrTooManyDecimals},
{"异常/全角数字", "12.34", 0, ErrInvalidFormat},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseAmount(tc.in)
if !errors.Is(err, tc.wantErrIs) {
t.Fatalf("ParseAmount(%q) err = %v, want %v", tc.in, err, tc.wantErrIs)
}
if err == nil && got != tc.want {
t.Errorf("ParseAmount(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
err == nil 才比值的写法很重要:错误路径下 got 通常是零值,跟着一起断言会让「实现顺带改了零值」这类问题也变红,噪声大。
2. 边界值清单:让 AI 按维度穷举
AI 对通用边界很敏感,对业务边界不敏感。把它当清单生成器用,逐维度要:
| 维度 | 让 AI 列出的边界 | 只有人能判断的 |
|---|---|---|
| 数值 | 0、±1、上限、上限+1、溢出 | 业务上限到底是多少 |
| 字符串 | 空、单空格、前后空白、超长 | 是否 trim、长度上限 |
| Unicode | 中文、emoji、全角、组合字符 | 长度按字节还是按字素(grapheme) |
| 时间 | 闰年 2/29、月末、跨年、夏令时切换 | 时间归属哪个时区 |
| 集合 | 空、单元素、重复元素、超大批量 | 顺序是否有语义 |
| 浮点 | 0.1+0.2、NaN、±Inf | 是否该改用定点数或整数分 |
提示词补一句「每个维度先列边界清单,再据此写用例,并标注无法判断的项」,产出质量会明显提升。
3. 错误路径:断言类型,不断言文案
type ValidationError struct {
Field string
Code string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Code }
func (e *ValidationError) Is(target error) bool {
t, ok := target.(*ValidationError)
return ok && t.Code == e.Code
}
_, err := ParseAmount("1.234")
var ve *ValidationError
if !errors.As(err, &ve) || ve.Field != "amount" {
t.Fatalf("err = %v, want ValidationError on amount", err)
}
规则:断言 errors.Is / errors.As 与结构化字段,不要断言 err.Error() 的完整文案——文案一改测试全红,而缺陷一个没少。
4. t.Run 命名:让它能当筛选器
- 用「场景/关键输入」格式:
{"异常/三位小数", ...},go test -run 'TestParseAmount/异常'就能只跑异常组。 t.Run(tc.name+"/"+tc.in, ...)在输入本身有辨识度时更省事。- 重名不报错,Go 只加
#01后缀,排查时很难受;CI 可以加一步检查表内 name 唯一。 - 不写
case1、正常情况;名字要能说明「什么输入、期望什么」。
Python 侧同样一套思路,参数化(parametrize)天然就是表驱动:
@pytest.mark.parametrize("raw,want", [("12.34", Decimal("12.34")), ("0.00", Decimal("0.00"))])
def test_parse_amount_ok(raw, want):
assert parse_amount(raw, currency="CNY") == want
@pytest.mark.parametrize("raw", ["", "1.234", "1,000.00"])
def test_parse_amount_invalid(raw):
with pytest.raises(InvalidAmountError):
parse_amount(raw, currency="CNY")
5. 验收与常见坑
验收动作固定四步:go vet ./... 通过 → 逐条读用例名是否都在描述契约 → 弄红实验(改坏实现看是否变红)→ 确认没有时间、随机、网络依赖。
- 闭包捕获:Go 1.22 之前
for _, tc := range cases需在循环里tc := tc,否则并发子测试读到同一个变量;用go test -race能抓到。 - 值比较工具:
reflect.DeepEqual对time.Time(含单调时钟字段)、含函数字段的结构体常误判,改用google/go-cmp的cmp.Diff。 - 浮点直接相等:
got == 0.1+0.2必红,改用整数分或math.Abs(got-want) < 1e-9。 - 共享可变输入:表里放切片或 map 时,用例内部修改会污染后续用例;每个用例构造自己的一份。
t.Skip掩盖失败:AI 遇到不确定的用例倾向跳过。跳过必须写明原因与问题单号,否则视为未完成。
小结:AI 负责把表驱动骨架填满通用边界与异常分支,人负责补齐业务边界、写准期望值、用「弄红实验」确认测试真的会失败;再配合唯一的 t.Run 命名与 -race,单测才会从「覆盖率装饰」变成重构时的安全带。