Go 基础体系 · 第 88/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go 测试生态:Testify、GoMock、Mockery 与 Testcontainers
本文以 Go 1.26.4 为基准,示例固定使用 github.com/stretchr/testify v1.11.1、go.uber.org/mock v0.6.0、github.com/vektra/mockery/v3 v3.5.5 和 github.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 可提供 SetupSuite、SetupTest、TearDownTest 和 TearDownSuite,适合共享昂贵集成 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.InOrder 或 After;把内部实现顺序全部锁死,会让行为不变的重构也失败。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 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 依赖注入:手工装配、Uber Fx 与 Wire 的选择
- 下一篇:Go 开发工具链:Delve、Air、golangci-lint、govulncheck 与生成器
- 延伸:Go 测试体系:表驱动测试、子测试、Benchmark 与 Fuzz
- 延伸:Go database/sql 基础:连接池、事务、Context 与 NULL
- 延伸:Go RabbitMQ 实战:Exchange、Queue、确认、重试与死信
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论