用 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,单测才会从「覆盖率装饰」变成重构时的安全带。

笔记加载中…