本节把 TaskHub 推进到「改动敢不敢发」:前九章的代码都靠人肉点一遍,从这一章起建立分层测试——快的单元测试守住逻辑,真实依赖的集成测试守住 SQL 与序列化,端到端测试守住整条 HTTP 路径。
适用版本:Go 1.27(实测go1.27.0)+testcontainers-go v0.44.0+ Docker Engine 29.5.2。
11.1 分层测试与 Testcontainers
写到第 11 章,TaskHub 已经是一个有缓存、有消息、多租户的服务。这时候「跑一下看看」已经不够了:一次改动可能只影响某个 SQL 的分页,或者某个 JSON 字段的命名,肉眼根本看不出来。测试是唯一能让你在周五下午也敢合并代码的东西。
11.1.1 测试金字塔:三层,比例差一个数量级
| 层 | 依赖 | 速度 | 数量占比 | 守住什么 |
|---|---|---|---|---|
| 单元 | 无(或纯内存) | 微秒–毫秒 | 70% | 业务逻辑、边界条件 |
| 集成 | 真实 DB/缓存/队列 | 秒 | 20% | SQL 正确性、序列化、事务 |
| 端到端 | 完整服务 + 真实依赖 | 秒–十秒 | 10% | 路由、中间件、跨组件协作 |
比例不是教条,但它反映一个事实:测试越靠近真实,越慢、越脆、越贵。如果反过来——写 200 个端到端测试却只有 5 个单元测试——CI 会跑十几分钟,还经常因为容器没起来而红。
三层各自的典型形态:
- 单元:表驱动测试 + 接口 mock,
go test ./...一秒跑完。 - 集成:Testcontainers 拉真实依赖,验证
SELECT ... WHERE tenant_id = $1真的过滤了。 - 端到端:
httptest.NewServer起完整 handler,发真实 HTTP 请求。
11.1.2 为什么不用 sqlite / mock 替掉 Postgres
一个常见的「省事」做法是用 sqlite 内存库或 mock 掉 database/sql 来跑集成测试。它省了容器,但会漏掉一大堆只在真实 Postgres 上才暴露的问题:
| 问题 | sqlite/mock 能发现吗 |
|---|---|
BIGSERIAL 自增行为 | 否 |
RETURNING id 是否被支持 | 否 |
JSONB、数组类型、ON CONFLICT | 否 |
| 事务隔离级别与锁 | 否 |
| 连接池与真实网络超时 | 否 |
TaskHub 用的是 Postgres 特有能力(ON CONFLICT DO NOTHING 做幂等、JSONB 存元数据)。测试环境必须和线上同种数据库,否则测试给的是虚假的安全感。
Testcontainers 的价值就在这:它让「起一个真实 Postgres」的成本降到和写代码一样低——不用手工 docker run、不用管端口、测试结束自动清理。
11.1.3 第一个 Testcontainers 测试
先看一个最小可用的集成测试:起容器、建表、插入、查询、断言。
package tcdemo_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"demo/internal/tcdemo"
)
func TestRepoAgainstRealPostgres(t *testing.T) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:17-alpine",
postgres.WithDatabase("taskhub"),
postgres.WithUsername("taskhub"),
postgres.WithPassword("secret"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second)),
)
require.NoError(t, err)
defer func() { _ = pg.Terminate(ctx) }()
// ... 建连接池、建表、断言 ...
}
几个必须理解的点:
postgres.Run是modules/postgres提供的封装,比裸testcontainers.GenericContainer省事,自动处理端口、用户名、密码。- 等待策略是成败关键。Postgres 的启动日志
database system is ready to accept connections会出现两次:第一次是初始化临时实例,第二次才是真正就绪。所以WithOccurrence(2)不能省——只用一次会在初始化中途连上去,测试随机失败。 defer pg.Terminate(ctx)必须写。Testcontainers 还有一个叫 Ryuk 的看门狗容器负责兜底清理,但显式 Terminate 更及时,也让本地docker ps干净。
11.1.4 本机实测输出
上面的测试在本机跑通,输出如下(已省略容器拉取的中间日志):
$ go test ./internal/tcdemo/ -run TestRepoAgainstRealPostgres -v
=== RUN TestRepoAgainstRealPostgres
🐳 Creating container for image postgres:17-alpine
🐳 Starting container: 2c5eb5282472
✅ Container started: 2c5eb5282472
repo_test.go:54: container host=postgres://taskhub:secret@localhost:32771/taskhub?sslmode=disable
repo_test.go:55: created task id=1 title="write chapter 11" status="open", open=1
🐳 Terminating container: 2c5eb5282472
--- PASS: TestRepoAgainstRealPostgres (1.61s)
PASS
ok demo/internal/tcdemo 2.289s
两个可验证的事实:id=1 说明 BIGSERIAL 的 RETURNING id 在真实 Postgres 上正常工作(这是 sqlite 测不出来的);整个测试 1.61 秒,其中容器启动约 1 秒——镜像已在本机,所以没有拉取时间,冷启动会慢得多。
11.1.5 本机踩坑:colima 下必须设两个环境变量
这是本节最有价值的部分。本机 Docker 是 colima 后端(DOCKER_HOST 默认指向 ~/.colima/default/docker.sock),Testcontainers 直接跑会报:
run postgres: generic container: get provider: rootless Docker not found,
failed to create Docker provider
设上 DOCKER_HOST 后又报第二个错:
reaper: new reaper: run container: container start: Error response from daemon:
error while creating mount source path '/Users/.../.colima/default/docker.sock':
mkdir ...: operation not supported: could not start container
原因:Ryuk 看门狗容器要把 Docker socket 挂载进容器内,但 colima 的 socket 在宿主机上是 ~/.colima/default/docker.sock,而在虚拟机内部它是 /var/run/docker.sock。容器里没有宿主机那个路径,挂载自然失败。
正确姿势是两个变量一起设:
export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"
export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock
go test ./... -count=1
TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE 告诉 Testcontainers「容器里看到的 socket 路径是这个」,Ryuk 就能正常挂载了。CI 上如果也是 colima 或 rootless Docker,这两个变量同样要写进环境;用标准 Docker Desktop 或 Linux 原生 Docker 则不需要。
11.1.6 用 TestMain 共享容器
每个测试函数都起一个容器,几十个测试就是几十次启动,CI 时间会爆炸。正确做法是整个 package 共享一个容器,在 TestMain 里起:
var testPool *pgxpool.Pool
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:17-alpine", /* 同上 */)
if err != nil {
fmt.Fprintln(os.Stderr, "start container:", err)
os.Exit(1)
}
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
pool, _ := pgxpool.New(ctx, dsn)
// 全局 schema 只建一次
_, _ = pool.Exec(ctx, `CREATE TABLE tasks (...)`)
testPool = pool
code := m.Run()
pool.Close()
_ = pg.Terminate(ctx)
os.Exit(code)
}
配套两条纪律:
os.Exit(code)必须放在m.Run()之后、清理之后。TestMain里不能用defer——os.Exit不会执行 defer,容器就漏了。- 容器只在包级别共享,不在跨包共享。不同 package 的
TestMain各起一个,隔离干净,代价可控。
11.1.7 最底层的单元测试:表驱动守住边界
集成测试之前,先把纯逻辑用表驱动测试锁住。TaskHub 的分页参数解析就是个典型:它不碰数据库,但边界条件一堆,最容易写错。
package paging
import "fmt"
type Page struct {
Limit int
Offset int
}
// Parse 把 ?limit=&offset= 解析成安全的 Page,并套上上下限
func Parse(limit, offset int) (Page, error) {
if limit <= 0 {
limit = 20 // 默认页大小
}
if limit > 100 {
limit = 100 // 硬上限,防止 SELECT * 拖垮数据库
}
if offset < 0 {
return Page{}, fmt.Errorf("offset must be >= 0, got %d", offset)
}
return Page{Limit: limit, Offset: offset}, nil
}
配套的表驱动测试用 t.Run 给每个用例起名字,失败时直接告诉你是哪一条:
func TestParse(t *testing.T) {
cases := []struct {
name string
limit, off int
want paging.Page
wantErr bool
}{
{"default limit", 0, 0, paging.Page{Limit: 20, Offset: 0}, false},
{"explicit limit", 50, 100, paging.Page{Limit: 50, Offset: 100}, false},
{"cap at 100", 5000, 0, paging.Page{Limit: 100, Offset: 0}, false},
{"negative offset", 10, -1, paging.Page{}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := paging.Parse(c.limit, c.off)
if c.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, c.want, got)
})
}
}
本机实测输出:
$ go test ./internal/paging/ -v
=== RUN TestParse
=== RUN TestParse/default_limit
=== RUN TestParse/explicit_limit
=== RUN TestParse/cap_at_100
=== RUN TestParse/negative_offset
--- PASS: TestParse (0.00s)
--- PASS: TestParse/default_limit (0.00s)
--- PASS: TestParse/explicit_limit (0.00s)
--- PASS: TestParse/cap_at_100 (0.00s)
--- PASS: TestParse/negative_offset (0.00s)
PASS
ok demo/internal/paging 0.676s
四个用例覆盖了「缺省、正常、超上限、非法输入」四类边界。表驱动 + 命名子测试是 Go 里性价比最高的测试形态:加一条用例只加一行数据,不用复制测试函数。这层测试没有容器、没有 IO,几百个也就几十毫秒。
11.1.8 什么时候不该用 Testcontainers
它不是银弹。以下场景要绕开:
| 场景 | 更合适的选择 |
|---|---|
| 纯函数、算法逻辑 | 单元测试,别起容器 |
| 只验证 HTTP 序列化 | httptest,不需要 DB |
| 本地没装 Docker 的开发机 | 用 build tag 隔离集成测试(见 11.3) |
| 需要极快反馈的 TDD 循环 | 先单元测试,集成测试留到 pre-commit |
把集成测试标记出来、允许在无 Docker 环境跳过,是团队协作的基本要求。做法是给文件加 build tag:
//go:build integration
package tcdemo_test
跑的时候 go test -tags=integration ./...,平时 go test ./... 不会碰到它们。
小结
- 三层测试:单元守住逻辑、集成守住 SQL 与序列化、端到端守住整条 HTTP 路径,比例大致 7:2:1。
- 测试数据库必须和线上同种;sqlite/mock 测不出
RETURNING、JSONB、隔离级别。 - Testcontainers 起真实依赖,等待策略要等
ready to accept connections的第二次出现。 - colima/rootless 环境要设
DOCKER_HOST与TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE两个变量。 - 用
TestMain让整个 package 共享容器,os.Exit放最后、别用 defer。 - 集成测试加 build tag,让无 Docker 的开发机也能跑单元测试。
有了真实依赖的集成测试,下一个问题是:依赖别人的服务时怎么测?下一节讲契约测试与 mock。
阅读导航:上一节:10.3 链路追踪(OTel) · 下一节:11.2 契约测试与 mock 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。