《Go 语言编程实战》11.1 分层测试与 Testcontainers

给 TaskHub 搭起分层测试:先划清单元、集成、端到端三层的边界与比例,再用 Testcontainers 在测试里拉起真实的 postgres:17-alpine,用 pgx 建表并跑通一次插入查询;同时给出本机 colima 环境下必须设置 DOCKER_HOST 与 socket override 的真实排错过程,以及用 TestMain 共享容器降本的做法。

本节把 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练