用 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 负责把接口签名与三类极端场景(阻塞、部分失败、并发)展开成代码,人负责判断「哪些依赖根本不该被替换」以及「替身行为是否与真实现一致」;验收只认一条——删掉期望或改坏替身,测试必须变红。

笔记加载中…