用 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 命令,测试才会成为真正的回归安全网。

笔记加载中…