用 AI 造桩与假实现:接口替身与依赖隔离
单元测试要快、要稳、要能构造真依赖造不出的场景(超时、部分失败、并发争用),这三件事都靠测试替身(test double)。AI 生成替身几乎零成本,但有代价:替身一旦与真依赖行为不一致,测试越绿越危险。
1. 三种替身,先定选型再动手
| 做法 | 生成成本 | 适合场景 | 风险 |
|---|---|---|---|
| 手写桩(stub) | 低 | 少量方法、需要精确控制返回值与阻塞 | 方法一多就膨胀 |
| mock 库(testify mock / gomock) | 低 | 需要校验调用参数与顺序、方法多 | 断言绑死实现细节,重构成本高 |
| 真实内存实现(fake) | 中 | 仓储层(repository)这类有状态的依赖 | 需自行保证语义等价,易与真库漂移 |
选型口诀:能用手写桩就别上 mock 库,能用内存实现就别 mock 掉整个数据层。 提示词里直接把选型写死,否则 AI 默认会生成 mock 库版本。
2. 手写桩:显式、可读、无框架依赖
type UserRepo interface {
Get(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, u *User) error
}
type stubUserRepo struct {
users map[int64]*User
getErr error
saveCall int
}
func (s *stubUserRepo) Get(ctx context.Context, id int64) (*User, error) {
if s.getErr != nil {
return nil, s.getErr
}
u, ok := s.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
func (s *stubUserRepo) Save(ctx context.Context, u *User) error {
s.saveCall++
s.users[u.ID] = u
return nil
}
被测代码只依赖 UserRepo 接口,测试里注入 stubUserRepo;Save 的调用次数用一个普通字段就够——测试串行执行时不需要加锁。
3. mock 库:什么时候值得用,怎么验收
提示词要给全接口签名,并要求「期望调用参数与返回值分开列,未在期望里声明的调用一律 panic」。gomock 版本由 mockgen -source=internal/repo/user.go -destination=internal/mock/user_mock.go 生成,测试里配合 gomock.NewController(t) 与 EXPECT().Get(gomock.Any(), int64(7))。
type mockUserRepo struct{ mock.Mock }
func (m *mockUserRepo) Get(ctx context.Context, id int64) (*User, error) {
args := m.Called(ctx, id)
u, _ := args.Get(0).(*User)
return u, args.Error(1)
}
func TestActivateNotFound(t *testing.T) {
repo := new(mockUserRepo)
repo.On("Get", mock.Anything, int64(7)).Return(nil, ErrNotFound).Once()
_, err := Activate(ctx, repo, 7)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
repo.AssertNotCalled(t, "Save", mock.Anything, mock.Anything)
}
验收重点只有一条:把 On 的期望删掉,测试必须失败;如果删掉还绿,说明期望是装饰品。
4. 并发与超时场景的桩
超时、取消、并发争用是 AI 最容易漏的三类场景,也是替身最有价值的地方。
type blockingRepo struct{ release chan struct{} }
func (b *blockingRepo) Get(ctx context.Context, id int64) (*User, error) {
select {
case <-b.release: // 手动放行,模拟慢依赖
return &User{ID: id}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func TestActivateTimeout(t *testing.T) {
repo := &blockingRepo{release: make(chan struct{})}
defer close(repo.release)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := Activate(ctx, repo, 7)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want DeadlineExceeded", err)
}
}
并发计数用原子类型而不是普通字段,并始终用 go test -race 跑一遍:
type countingRepo struct{ stubUserRepo; n atomic.Int64 }
提示词里点名要这三类场景:「构造依赖永久阻塞、依赖返回部分失败、同一依赖被并发调用,并说明如何让测试在 100ms 内结束」。
5. 什么时候不该 mock
- 被测对象自己的纯逻辑:mock 掉等于把被测代码挖空,剩下的测试在测替身。
- 标准库与通用数据结构:用真的,它们足够快且行为可信。
- 只有一个实现、没有 IO 的接口:真实现更快,也更能暴露契约不匹配。
- 数据层的语义验证:mock 掉 DAO 之后,SQL 条件、唯一约束、事务回滚全都没被验证,这类断言应留给集成测试。
- 只为了断言「调用了几次」:调用次数是实现的形状,不是契约,优先断言最终状态。
判断标准很朴素:这个替身替代的东西,我真的理解它的行为吗? 不理解就 mock,等于把「不理解」写进测试里。
6. 常见坑
- 替身过于宽松:
Return(nil, nil)让错误分支永远走不到;替身应默认对未声明的调用返回明确错误或 panic。 - 期望绑死参数:
On("Save", ctx, exactUserStruct)让被测代码多改一个字段就红,改用mock.MatchedBy只匹配关键字段。 - 替身与真实现漂移:真依赖新增了幂等、重试语义而替身没跟;在替身顶部注释指向真实实现的测试用例作为锚点。
小结:替身的选择顺序是「真实内存实现 → 手写桩 → mock 库」,AI 负责把接口签名与三类极端场景(阻塞、部分失败、并发)展开成代码,人负责判断「哪些依赖根本不该被替换」以及「替身行为是否与真实现一致」;验收只认一条——删掉期望或改坏替身,测试必须变红。