Go 基础体系 · 第 88/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 测试生态:Testify、GoMock、Mockery 与 Testcontainers

本文以 Go 1.26.4 为基准,示例固定使用 github.com/stretchr/testify v1.11.1go.uber.org/mock v0.6.0github.com/vektra/mockery/v3 v3.5.5github.com/testcontainers/testcontainers-go v0.39.0。版本号应进入 go.mod、工具模块或 CI 镜像,镜像也必须固定标签或摘要。标准库 testing 仍是执行模型;第三方工具只负责改善断言、生成替身或提供真实依赖,不能替测试定义业务契约。

1. 先按风险选择测试层

纯函数和领域规则用普通单元测试,反馈快且失败定位准。HTTP 编解码用 httptest,数据库适配器用真实数据库集成测试,跨进程协议用契约测试,部署链路才由少量端到端测试覆盖。层级越高越真实,也越慢、越容易受环境影响。

测试金字塔不是固定比例。关键是让每个测试回答一个问题:重复标题是否被拒绝,SQL 是否遵守唯一键,HTTP 是否映射为 409,还是整个服务能否完成发布。若一次失败同时可能来自五个系统,就应补下层测试缩小诊断范围。

2. Testify 的 assert 与 require

assert 记录失败后继续当前测试,适合检查多个相互独立的结果;require 调用 FailNow,适合前置条件失败后无法继续的情况:

func TestDecodeArticle(t *testing.T) {
	article, err := DecodeArticle([]byte(`{"title":"Go"}`))
	require.NoError(t, err)
	assert.Equal(t, "Go", article.Title)
	assert.False(t, article.Published)
}

require 只能从运行测试的 goroutine 调用。在后台 goroutine 调用不会安全终止父测试,甚至可能留下资源。并发结果应发回 channel,由测试 goroutine 断言。assert.NoError 之后仍解引用可能为空的结果会产生二次 panic,因此 setup 失败用 require

3. 断言错误、集合和时间

优先断言可观察语义。错误用 ErrorIs/ErrorAs,不要绑定完整错误字符串;元素顺序属于契约时用 Equal,无序集合才用 ElementsMatch。浮点和时间使用领域允许的误差,而不是随意大容差。

func TestCreateDuplicate(t *testing.T) {
	_, err := service.Create(context.Background(), "same-title")
	require.Error(t, err)
	assert.ErrorIs(t, err, ErrConflict)

	var conflict *ConflictError
	require.ErrorAs(t, err, &conflict)
	assert.Equal(t, "title", conflict.Field)
}

assert.Equal 基于反射,nil slice 与空 slice、不同数值类型都可能不相等。这往往是在提醒契约尚未定义,不应立刻改成更宽松断言。大 JSON 或结构体失败难读时,用专门 diff 或先规范化后逐字段比较。

4. Suite 提供共享生命周期,但要谨慎

suite.Suite 可提供 SetupSuiteSetupTestTearDownTestTearDownSuite,适合共享昂贵集成 fixture:

type RepositorySuite struct {
	suite.Suite
	db   *sql.DB
	repo *Repository
}

func (s *RepositorySuite) SetupTest() {
	_, err := s.db.ExecContext(s.T().Context(), "TRUNCATE articles")
	s.Require().NoError(err)
}

func TestRepositorySuite(t *testing.T) {
	suite.Run(t, new(RepositorySuite))
}

Suite 方法共享接收者状态,默认不适合随意 t.Parallel。一个测试改变 fixture 后未恢复,会让执行顺序影响结果。若每个测试 setup 很小,普通 TestXxx 配合 t.Cleanup 更直观。不要用 suite 模拟面向对象继承层次。

5. Fake、stub、spy 与 mock 的差异

fake 有简化但可工作的状态模型,如内存仓储;stub 返回预设值;spy 记录调用;mock 根据预期交互决定通过与否。纯领域逻辑优先真实值或 fake,只有调用顺序、重试次数、事务边界等交互本身就是契约时才使用 mock。

type memoryRepository struct {
	mu       sync.Mutex
	articles map[string]Article
}

func (r *memoryRepository) Save(_ context.Context, article Article) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, exists := r.articles[article.ID]; exists {
		return ErrConflict
	}
	r.articles[article.ID] = article
	return nil
}

并行测试会并发使用 fake,就必须实现与真实接口一致的并发保证。过于宽松的 fake 可能允许真实数据库拒绝的数据,因此唯一键、NULL、隔离级别和 SQL 方言必须由真实集成测试验证。

6. GoMock 的生成和控制器

GoMock 的原始 github.com/golang/mock 已停止维护;本文使用 Uber 维护的 go.uber.org/mock v0.6.0。生成命令固定版本,并通过 go:generate 留下来源:

//go:generate go run go.uber.org/mock/mockgen@v0.6.0 \
//go:generate   -source=publisher.go -destination=publisher_mock_test.go \
//go:generate   -package=article

gomock.NewController(t) 在现代版本会注册清理并在测试结束验证预期。mock 文件若只供本包测试可生成到 _test.go,避免进入生产构建。跨包复用时放到明确的 internal/testdouble,不要发布庞大 mocks 包作为公共 API。

7. 用 GoMock 表达真正重要的交互

下面的契约是“保存成功后发布一次;发布失败要返回错误”,调用本身有业务意义:

func TestServicePublishesSavedArticle(t *testing.T) {
	ctrl := gomock.NewController(t)
	repo := NewMockRepository(ctrl)
	publisher := NewMockPublisher(ctrl)
	article := Article{ID: "a-1", Title: "Go"}

	repo.EXPECT().Save(gomock.Any(), article).Return(nil)
	publisher.EXPECT().Publish(gomock.Any(), article).Return(nil)

	service := NewService(repo, publisher)
	require.NoError(t, service.Publish(context.Background(), article))
}

参数匹配器不能一部分用 matcher、一部分直接值;必要时用 gomock.Eq。context 常含 deadline 或 trace,不宜直接比较实例,可写 matcher 检查 deadline 和业务值。DoAndReturn 可模拟阻塞、捕获参数或动态返回,但回调越复杂,测试越像第二份实现。

8. 顺序、次数与并发边界

默认预期可按任意顺序满足。仅当协议要求顺序时使用 gomock.InOrderAfter;把内部实现顺序全部锁死,会让行为不变的重构也失败。Times(1) 是默认,AnyTimes 容易掩盖重复调用,MinTimes/MaxTimes 应有明确契约理由。

并发方法可能让预期匹配顺序不确定。不要用 time.Sleep 等 goroutine“应该执行了”,应用 channel/barrier 建立 happens-before:

started := make(chan struct{})
release := make(chan struct{})
publisher.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(
	func(context.Context, Article) error {
		close(started)
		<-release
		return nil
	},
)
go service.Flush(context.Background())
<-started
close(release)

测试结束前必须等待 goroutine,否则 controller 清理可能与调用竞态。继续运行 go test -race,mock 不是竞态检测器。

9. Mockery 的配置驱动生成

Mockery v3.5.5 读取接口并生成 Testify mock,适合已使用 mock.Mock API 的项目。集中配置比散落长命令更易复现:

# .mockery.yaml
version: "3"
packages:
  example.com/article/internal/article:
    interfaces:
      Repository:
      Publisher:
dir: "{{.InterfaceDir}}"
filename: "{{.InterfaceName | snakecase}}_mock_test.go"
structname: "Mock{{.InterfaceName}}"
pkgname: "article"

执行 go run github.com/vektra/mockery/v3@v3.5.5。生成配置、工具版本和输出必须一起评审。接口方法变化后生成物未更新会在 CI 暴露;若生成物提交仓库,CI 重生成后检查 diff,而不是自动提交。

10. Testify mock 的使用边界

Mockery 生成类型通常通过 On 注册预期,通过 AssertExpectations 验证:

repo := NewMockRepository(t)
repo.On("Save", mock.Anything, mock.MatchedBy(func(article Article) bool {
	return article.Title == "Go"
})).Return(nil).Once()

service := NewService(repo)
require.NoError(t, service.Create(context.Background(), "Go"))
repo.AssertExpectations(t)

字符串方法名不受编译器重构保护,这是与生成强类型 recorder API 的重要权衡。现代生成模板可提供期望辅助方法,但仍应在升级 Mockery 时检查输出差异。不要在同一个包随意混用 GoMock 与 Testify mock,两套生命周期和匹配器会增加维护成本。

11. Testcontainers 的核心机制

Testcontainers for Go v0.39.0 通过 Docker 兼容 API 创建容器、网络和日志流。容器不是进程内 fake:它能验证真实协议、驱动、schema 和故障,但启动时间、镜像拉取及宿主资源成为测试输入。

func startPostgres(t *testing.T) (context.Context, *postgres.PostgresContainer) {
	t.Helper()
	ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
	t.Cleanup(cancel)

	container, err := postgres.Run(ctx,
		"postgres:17.6-alpine",
		postgres.WithDatabase("articles"),
		postgres.WithUsername("test"),
		postgres.WithPassword("test"),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).WithStartupTimeout(90*time.Second),
		),
	)
	require.NoError(t, err)
	t.Cleanup(func() {
		require.NoError(t, testcontainers.TerminateContainer(container))
	})
	return ctx, container
}

固定补丁镜像比 latest 可重现,安全要求更高时固定 digest。等待策略必须观察实际 readiness,容器已启动不代表数据库能接受连接。清理立即注册,即使迁移失败也能释放资源。

12. 迁移、事务和隔离策略

取得连接串后先打开并 Ping,再执行与生产相同的迁移:

dsn, err := container.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
db, err := sql.Open("pgx", dsn)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, db.Close()) })
require.NoError(t, db.PingContext(ctx))
require.NoError(t, migrateUp(ctx, db))

每个测试一个容器隔离最强但慢;一个包共享容器、每个测试独立 schema 或 TRUNCATE 更快,但要处理序列、连接状态和并行冲突。用事务回滚隔离时,被测代码必须复用同一事务,而且无法覆盖提交后行为。选择后把所有权和清理协议写进 helper。

13. 网络、容器与失败诊断

宿主测试访问映射端口;多个容器互访应加入同一临时网络并使用网络别名,不要把宿主映射地址塞给另一个容器。并行测试不要假定固定端口。远程 Docker、rootless 模式和 CI service container 的 host 语义可能不同,应使用库返回的 Host/Endpoint。

启动失败时保留镜像名、容器状态、wait strategy、端口和受控日志。超时不应只报 context deadline exceeded。认证失败检查 DSN 和初始化日志,connection refused 检查 readiness 与映射,测试挂住则抓 go test -timeout 的 goroutine dump和 Docker 事件。日志可能含密码,上传 CI artifact 前脱敏。

14. Context、超时与取消测试

每个外部操作都应使用 t.Context() 派生的有界 context。测试超时是最后保险,不是业务 deadline。取消语义应被主动验证:

func TestRepositoryStopsOnCancellation(t *testing.T) {
	ctx, cancel := context.WithCancel(t.Context())
	cancel()
	_, err := repo.Find(ctx, "a-1")
	require.Error(t, err)
	assert.ErrorIs(t, err, context.Canceled)
}

不要用极短毫秒 deadline 断言性能,慢 CI 会产生假失败。用可控制的 fake/blocking server 确认取消传播,再给集成测试宽松上限。后台 worker 测试必须停止并等待;需要时用 goleak,但先排除 runtime、数据库驱动等已知长期 goroutine。

15. 安全与供应链

测试依赖也是供应链。生成器用带版本的 go run 或独立 tools module;容器镜像来自允许 registry,定期扫描并更新。测试凭据只能访问临时资源,不复用生产 token。录制 HTTP fixture 前删除 Authorization、Cookie、个人信息和签名 URL。

不要把 Docker socket 暴露给不可信 PR 代码;拥有 socket 通常等同宿主高权限。公共仓库对 fork PR 可运行无秘密单测,把需要容器或凭据的任务放在受控环境并设置审批。测试数据要虚构且有确定清理策略。

16. 性能、稳定性与缓存

单元测试应在秒级完成,集成测试按包或能力分组。复用容器能降低启动成本,但共享状态会限制并行,必须用测量决定。预拉镜像、缓存 Go 模块和构建缓存可以提速,缓存键包含 Go 版本、go.sum 与工具配置。

不稳定测试不能靠重试长期隐藏。先记录随机种子、时区、并发数、镜像 digest 和失败日志,再修复共享状态、真实时钟或错误 wait condition。-count=20 适合放大偶发问题,-shuffle=on 可发现顺序依赖;最终 CI 仍应一次通过。

17. CI 分层与生成门禁

快速作业先验证生成一致性、格式、vet 和单测;race 与容器集成测试可并行到后续作业:

go generate ./...
git diff --exit-code -- '**/*_mock_test.go'
gofmt -w .
git diff --exit-code
go vet ./...
go test -shuffle=on ./...
go test -race ./...
go test -tags=integration -count=1 ./internal/postgres/...

Shell glob 在不同环境行为可能不同,正式流水线可用明确目录或仓库脚本。集成 job 设置总超时、Docker 磁盘清理和并发上限;失败时上传测试 JSON、脱敏容器日志和覆盖率,但不上传秘密。

18. 一套可维护的决策顺序

先用标准库写清输入、输出和错误;重复断言明显时引入 Testify。需要有状态替身时先手写小 fake;交互本身属于协议时在 GoMock 与 Mockery 中选择团队已有的一套。fake 无法覆盖真实语义时用 Testcontainers,把镜像、迁移、readiness、context 和清理都纳入契约。

最终评审的不是“用了多少测试库”,而是失败能否快速回答哪里坏了,生成能否复现,资源是否总能释放,并发测试是否无竞态,CI 与本地是否运行同一命令。工具减少样板之后仍应让业务意图留在最显眼的位置。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。