用 AI 写测试:表驱动、边界用例与伪实现
AI 写测试的速度远快于写实现,但产出质量参差:它擅长铺一堆"输入 1 期待 2"的正常路径,也很擅长在测试变红时直接改断言。而测试的价值恰恰集中在它不擅长的部分——边界、错误路径,以及测试替身(test double)的位置。
本章要解决的问题
- 用例数量看着不少,全是正常路径,改坏代码测试照样全绿;
- 断言写得太松(只查
err == nil),漏掉返回值内容与副作用; - 为了让测试通过,AI 修改的是测试而不是实现;
- 依赖真实时间、数据库与 HTTP,测试跑得慢、随机失败,且无法构造边界场景。
表驱动测试:一次铺满用例
Go 里表驱动(table-driven)测试结构固定,最适合交给 AI 批量生成,你只需要把用例意图写全:
为 internal/coupon 的 Redeem 生成表驱动测试,写入 redeem_test.go。
要求:
1. 使用 func TestRedeem(t *testing.T) + []struct{...} + t.Run(tt.name, ...) 结构,name 用中文短语;
2. 用例必须覆盖四组:正常路径(首次核销成功);边界(金额恰好等于门槛、券在过期前一秒、userID 为 0);
错误路径(券不存在、已使用、已过期、不属于该用户、低于门槛);并发(100 个 goroutine 争抢同一张券,仅 1 次成功);
3. 每个用例用 errors.Is 断言哨兵错误,不要只断言 err != nil;
4. 不使用 mock 框架,手写 fake;时间必须可注入,禁止调用 time.Now();完成后运行 go test ./internal/coupon/... -run TestRedeem -count=1 -race -v 并贴出全绿输出。
生成的骨架大致是这样:
func TestRedeem(t *testing.T) {
base := time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
coupon *Coupon
wantErr error
}{
{name: "首次核销成功", coupon: newCoupon(100, 50, base.Add(time.Hour)), wantErr: nil},
{name: "金额恰好等于门槛", coupon: newCoupon(100, 100, base.Add(time.Hour)), wantErr: nil},
{name: "券已过期", coupon: newCoupon(100, 50, base.Add(-time.Second)), wantErr: ErrCouponExpired},
{name: "券不存在", coupon: nil, wantErr: ErrCouponNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewService(newFakeRepo(tt.coupon))
svc.nowFn = func() time.Time { return base }
err := svc.Redeem(context.Background(), 7, 100)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Redeem() err = %v, want %v", err, tt.wantErr)
}
})
}
}
断言用 errors.Is 而不是 ==:一旦实现里对错误做了包装(fmt.Errorf("redeem: %w", err)),== 会全部失败,测试就变成因为实现细节而不是行为挂掉。
边界与错误路径要成组枚举
| 分组 | 典型用例 | 常被测漏的原因 |
|---|---|---|
| 空值与零值 | nil 入参、空字符串、ID 为 0 | "业务上不会传" |
| 边界相等 | 金额 == 门槛、长度 == 上限 | 只测了小于和大于 |
| 时间边界 | 过期前一秒、过期当秒、时钟回拨 | 依赖真实时间,难以构造 |
| 重复与幂等 | 同一请求连续两次、重放旧请求 | 只跑了一次 |
| 依赖失败 | 数据库超时、HTTP 500 | 需要 fake 才能注入 |
伪实现(fake)优于 mock 框架
Repo 接口只有两个方法时,手写 fake 比引入 mock 框架更快也更稳:
type fakeRepo struct {
mu sync.Mutex
coupon *Coupon
}
func (f *fakeRepo) GetForUpdate(ctx context.Context, id int64) (*Coupon, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.coupon == nil {
return nil, ErrCouponNotFound
}
return f.coupon, nil
}
func (f *fakeRepo) MarkUsed(ctx context.Context, id int64, at time.Time) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.coupon == nil || f.coupon.Status == StatusUsed {
return false, nil
}
f.coupon.Status = StatusUsed
return true, nil
}
fake 里带 sync.Mutex 是必要的:并发用例要跑 -race,若 fake 自己不是线程安全的,你测出来的就是 fake 的 bug。
定向跑测:别每次都跑全量
go test ./internal/coupon/... -run TestRedeem -count=1 -v # 单个测试函数
go test ./internal/coupon/... -run 'TestRedeem/券已过期' -count=1 -v # 单个子用例
go test ./internal/coupon/... -count=1 -race # 并发用例
-count=1 用来绕过测试结果缓存:默认情况下相同输入的通过结果会被缓存,容易让你误以为新改动也通过了。
识别"为了让测试通过而改测试"
这是 AI 辅助开发里最隐蔽的退化模式,盯住 diff 里这几类信号:
| 危险信号 | 真实含义 |
|---|---|
断言从 errors.Is(err, ErrCouponExpired) 改成 err != nil | 放宽标准,实现错了测试也绿 |
出现 t.Skip("暂时跳过") 或注释掉整条用例 | 直接删掉证据 |
期望值从 wantErr 改成 err(拿实际值当期望) | 循环论证,测试永远通过 |
新增 time.Sleep(100 * time.Millisecond) | 用等待掩盖竞态,机器一慢就随机红 |
删掉 -race 参数或并发用例 | 回避最难验证的那部分 |
发现后不要让它"改回来",而是冻结测试文件、只允许改实现,并在判定前把原测试从 git 恢复:git checkout -- internal/coupon/redeem_test.go && go test ./internal/coupon/... -count=1 -run TestRedeem。若恢复后测试红、实现看起来却合理,说明约束本身有问题,应回到规格(见 05 章)修验收标准,而不是偷偷改测试。
常见坑
- 只断言不报错:
if err != nil { t.Fatal(err) }之后就结束,返回值内容和副作用完全没验; - 在测试里调 time.Now():夜里跑挂、跨年跑挂,且无法构造过期场景;
- fake 不线程安全:并发用例一开
-race就报数据竞争,白排查半天; - 把覆盖率当目标:AI 很容易为覆盖率写一堆重复断言,覆盖率涨了缺陷检出率却没变。
小结:让 AI 写测试的价值在铺量,把关在用例清单。要求它用表驱动结构、按分组枚举边界与错误路径、用手写 fake 替代真实依赖与 mock 框架,并把"改测试"明确列为禁止项;再配合 -count=1 -race 与定向 -run 命令,测试才会成为真正的回归安全网。