Go 集成测试实战——Testcontainers-go、真实数据库/Redis 测试
Go 集成测试实战——Testcontainers-go、真实数据库/Redis 测试
适读人群:Go 开发工程师、后端工程师、测试工程师 | 阅读时长:约 15 分钟 | 核心价值:用 Testcontainers-go 在测试中启动真实容器,彻底解决集成测试环境一致性问题
我有个老朋友叫刘磊,在一家电商公司做 Go 后端。去年他们公司发生了一件让他难忘的事:QA 在测试环境验证通过的功能,上线后出了 Bug,追查下来发现是数据库版本不一致导致的——测试环境 MySQL 5.7,生产环境 MySQL 8.0,某个 JSON 函数的行为差异踩了坑。
刘磊跟我说这件事的时候,我问他:"你们单测是怎么做的?"他说:"单测用 Mock,数据库操作全 Mock 掉了。"我说:"那就是问题所在了——Mock 测的是你的代码逻辑,但 SQL 语句对不对、ORM 生成的 query 有没有问题、数据库版本行为差异,这些都测不到。"
集成测试的价值就在这里:它测的是代码和真实外部系统的交互是否正确。而 Testcontainers-go,就是解决集成测试环境一致性的最佳方案。
1. Testcontainers-go 是什么?
Testcontainers-go 是一个 Go 库,允许你在测试代码里用编程方式启动 Docker 容器。每次测试,都用一个全新的、真实的数据库/Redis/Kafka 容器来跑——测完自动销毁。
核心优势:
- 与生产环境完全一致的数据库版本
- 每次测试状态干净,没有数据污染
- CI 环境和本地环境行为一致(只要有 Docker)
- 无需维护独立的测试数据库实例
1.1 安装
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get github.com/testcontainers/testcontainers-go/modules/redis
go get github.com/testcontainers/testcontainers-go/modules/mysql2. PostgreSQL 集成测试实战
2.1 启动 PostgreSQL 容器
package repository_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func setupPostgres(t *testing.T) *pgx.Conn {
t.Helper()
ctx := context.Background()
pgContainer, err := postgres.Run(ctx,
"postgres:15-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
require.NoError(t, err)
// 测试结束后自动清理容器
t.Cleanup(func() {
require.NoError(t, pgContainer.Terminate(ctx))
})
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
conn, err := pgx.Connect(ctx, connStr)
require.NoError(t, err)
t.Cleanup(func() {
conn.Close(ctx)
})
// 运行 migration
err = runMigrations(ctx, conn)
require.NoError(t, err)
return conn
}
func runMigrations(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(200) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS orders (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id),
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
return err
}2.2 完整 Repository 集成测试
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
conn := setupPostgres(t)
repo := repository.NewUserRepository(conn)
ctx := context.Background()
t.Run("创建并查询用户", func(t *testing.T) {
user := &model.User{
ID: "user-001",
Name: "老张",
Email: "zhang@example.com",
}
err := repo.Save(ctx, user)
require.NoError(t, err)
found, err := repo.FindByID(ctx, "user-001")
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, user.ID, found.ID)
assert.Equal(t, user.Name, found.Name)
assert.Equal(t, user.Email, found.Email)
assert.WithinDuration(t, time.Now(), found.CreatedAt, 5*time.Second)
})
t.Run("邮箱唯一约束", func(t *testing.T) {
user1 := &model.User{ID: "user-002", Name: "用户A", Email: "dup@example.com"}
user2 := &model.User{ID: "user-003", Name: "用户B", Email: "dup@example.com"}
err := repo.Save(ctx, user1)
require.NoError(t, err)
err = repo.Save(ctx, user2)
require.Error(t, err)
// 验证错误类型是唯一约束违反
assert.True(t, repository.IsUniqueViolation(err),
"expected unique violation error, got: %v", err)
})
t.Run("查询不存在的用户", func(t *testing.T) {
_, err := repo.FindByID(ctx, "non-existent")
require.Error(t, err)
assert.ErrorIs(t, err, repository.ErrNotFound)
})
t.Run("分页查询", func(t *testing.T) {
// 先插入 5 条数据
for i := 0; i < 5; i++ {
u := &model.User{
ID: fmt.Sprintf("page-user-%d", i),
Name: fmt.Sprintf("用户%d", i),
Email: fmt.Sprintf("page%d@example.com", i),
}
require.NoError(t, repo.Save(ctx, u))
}
users, total, err := repo.List(ctx, 1, 3) // page=1, size=3
require.NoError(t, err)
assert.Len(t, users, 3)
assert.GreaterOrEqual(t, total, int64(5))
})
}3. Redis 集成测试实战
package cache_test
import (
"context"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/redis"
)
func setupRedis(t *testing.T) *redis.Client {
t.Helper()
ctx := context.Background()
redisContainer, err := redis.Run(ctx, "redis:7-alpine")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, redisContainer.Terminate(ctx))
})
addr, err := redisContainer.Endpoint(ctx, "")
require.NoError(t, err)
client := redis.NewClient(&redis.Options{
Addr: addr,
})
t.Cleanup(func() {
client.Close()
})
return client
}
func TestSessionCache_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
client := setupRedis(t)
cache := NewSessionCache(client)
ctx := context.Background()
t.Run("设置并获取 Session", func(t *testing.T) {
session := &Session{
UserID: "u-001",
Token: "token-abc123",
ExpiresAt: time.Now().Add(time.Hour),
}
err := cache.Set(ctx, "sess:u-001", session, time.Hour)
require.NoError(t, err)
got, err := cache.Get(ctx, "sess:u-001")
require.NoError(t, err)
assert.Equal(t, session.UserID, got.UserID)
assert.Equal(t, session.Token, got.Token)
})
t.Run("过期自动删除", func(t *testing.T) {
err := cache.Set(ctx, "temp-key", &Session{UserID: "x"}, 100*time.Millisecond)
require.NoError(t, err)
time.Sleep(200 * time.Millisecond) // 等待过期
_, err = cache.Get(ctx, "temp-key")
assert.ErrorIs(t, err, ErrCacheMiss)
})
t.Run("删除 Session", func(t *testing.T) {
err := cache.Set(ctx, "del-key", &Session{UserID: "y"}, time.Hour)
require.NoError(t, err)
err = cache.Delete(ctx, "del-key")
require.NoError(t, err)
_, err = cache.Get(ctx, "del-key")
assert.ErrorIs(t, err, ErrCacheMiss)
})
}4. 共享容器:用 TestMain 提升测试速度
每个测试都启动一个新容器,速度会很慢(每次大约 2-5 秒)。对于同一个包内的多个测试,可以共享一个容器:
package repository_test
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5"
)
var (
testDB *pgx.Conn
testCtx = context.Background()
)
func TestMain(m *testing.M) {
// 启动共享 PostgreSQL 容器
pgContainer, conn, err := startPostgresContainer(testCtx)
if err != nil {
panic(err)
}
testDB = conn
// 运行所有测试
code := m.Run()
// 清理
conn.Close(testCtx)
pgContainer.Terminate(testCtx)
os.Exit(code)
}
func startPostgresContainer(ctx context.Context) (testcontainers.Container, *pgx.Conn, error) {
pgContainer, err := postgres.Run(ctx,
"postgres:15-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2),
),
)
if err != nil {
return nil, nil, err
}
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
return nil, nil, err
}
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
return nil, nil, err
}
if err := runMigrations(ctx, conn); err != nil {
return nil, nil, err
}
return pgContainer, conn, nil
}每个测试用事务隔离数据:
func TestUserRepo_Save(t *testing.T) {
// 开启事务,测试完毕后回滚,不影响其他测试
tx, err := testDB.Begin(testCtx)
require.NoError(t, err)
t.Cleanup(func() { tx.Rollback(testCtx) })
repo := repository.NewUserRepository(tx) // 传入 tx 而非 conn
user := &model.User{ID: "tx-user-001", Name: "事务用户", Email: "tx@test.com"}
err = repo.Save(testCtx, user)
require.NoError(t, err)
found, err := repo.FindByID(testCtx, "tx-user-001")
require.NoError(t, err)
assert.Equal(t, "事务用户", found.Name)
// tx.Rollback 在 t.Cleanup 中执行,所有数据回滚
}5. 踩坑实录
踩坑记录 1:Docker 未启动导致测试失败
在 CI 环境(GitHub Actions / GitLab CI),默认不一定有 Docker Daemon。Testcontainers-go 需要 Docker socket 可访问。GitHub Actions 默认的 ubuntu runner 内置 Docker,但需要确认 docker ps 可执行。如果用 self-hosted runner,要手动确保 Docker 可用。
解决方案:在 CI 配置里加健康检查步骤,或者在 TestMain 里优雅降级:
func TestMain(m *testing.M) {
if _, err := exec.LookPath("docker"); err != nil {
fmt.Println("Docker not available, skipping integration tests")
os.Exit(0)
}
// ... 正常启动容器
}踩坑记录 2:容器启动超时
默认的等待策略可能不够,尤其是 CI 机器性能差时。PostgreSQL 容器第一次 pull 镜像会很慢,后续有缓存才快。解决方案:WithStartupTimeout(60*time.Second) 增加超时时间,CI 配置里预先 pull 镜像:
# .github/workflows/test.yml
- name: Pre-pull test images
run: |
docker pull postgres:15-alpine
docker pull redis:7-alpine踩坑记录 3:并行测试容器端口冲突
多个测试包并行运行时,如果固定了容器端口(比如 5432:5432),会发生冲突。解决方案:永远不要固定 host 端口,让 Testcontainers 自动分配随机端口:
// 错误做法
postgres.WithExposedPorts("5432:5432")
// 正确做法:不指定端口,用 ConnectionString() 获取实际地址
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")6. CI 集成配置
# .github/workflows/integration-test.yml
name: Integration Tests
on: [push, pull_request]
jobs:
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
- name: Pre-pull Docker images
run: |
docker pull postgres:15-alpine
docker pull redis:7-alpine
- name: Run unit tests
run: go test -short -race ./...
- name: Run integration tests
run: go test -v -timeout 120s ./... -run Integration8. 集成测试最佳实践:事务回滚隔离
在共享数据库实例上运行集成测试时,最优雅的数据隔离方式是利用数据库事务:每个测试开启一个事务,所有操作都在事务内完成,测试结束后回滚,不影响其他测试。这比每次清空表数据要高效得多。
// testutil/tx_test_helper.go
package testutil
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
// WithTx 在测试中使用独立事务,测试结束后自动回滚
func WithTx(t *testing.T, conn *pgx.Conn, fn func(tx pgx.Tx)) {
t.Helper()
ctx := context.Background()
tx, err := conn.Begin(ctx)
require.NoError(t, err, "begin transaction")
// 测试结束后回滚(无论成功失败)
t.Cleanup(func() {
if err := tx.Rollback(ctx); err != nil {
// 如果 tx 已被提交,Rollback 会返回错误,属于正常情况
t.Logf("rollback (may be normal if committed): %v", err)
}
})
fn(tx)
}使用方式:
func TestUserRepository_Transaction(t *testing.T) {
testutil.WithTx(t, testDB, func(tx pgx.Tx) {
repo := repository.NewUserRepository(tx)
ctx := context.Background()
// 在事务里创建用户
user := &model.User{ID: "test-001", Name: "事务测试用户", Email: "tx@test.com"}
err := repo.Save(ctx, user)
require.NoError(t, err)
// 查询确认存在
found, err := repo.FindByID(ctx, "test-001")
require.NoError(t, err)
assert.Equal(t, "事务测试用户", found.Name)
// 测试结束,事务回滚,数据消失
// 下一个测试不会看到这条数据
})
}这种模式的核心优势:测试之间完全隔离,不需要每次清空数据库,速度也快(回滚比 DELETE 快)。
9. 集成测试在工程体系中的定位
很多团队引入集成测试后,发现它和单元测试的边界不清楚:什么场景写单测,什么场景写集成测试?
我的判断标准是:看测试的目的是什么,不是看用了什么工具。
单元测试的目的是:验证业务逻辑是否正确(条件判断、计算、状态转换)。Mock 掉外部依赖,让测试只关注被测代码本身的逻辑。
集成测试的目的是:验证代码和外部系统的交互是否正确(SQL 语法、ORM 映射、事务行为、索引效率)。用真实的外部系统,让测试能发现接口边界上的问题。
两者都是必需的,不是替代关系。一个健康的项目,通常是:70% 单元测试(快速验证逻辑)+ 20% 集成测试(验证外部交互)+ 10% E2E 测试(验证关键用户路径)。
10. Testcontainers 的高级用法
掌握了基础的 MySQL 和 Redis 容器启动,Testcontainers-go 还有很多高级用法值得了解。
自定义镜像:对于需要特定配置的服务,可以基于标准镜像构建自定义镜像:
// 使用自定义 Dockerfile 启动容器
customReq := tc.ContainerRequest{
FromDockerfile: tc.FromDockerfile{
Context: "./testdata/custom-postgres",
Dockerfile: "Dockerfile.test",
},
ExposedPorts: []string{"5432/tcp"},
WaitingFor: wait.ForListeningPort("5432/tcp"),
}
container, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{
ContainerRequest: customReq,
Started: true,
})网络隔离:多个容器之间需要通信时,可以创建自定义网络:
network, err := tc.GenericNetwork(ctx, tc.GenericNetworkRequest{
NetworkRequest: tc.NetworkRequest{
Name: "test-network",
},
})
mysqlContainer, err := mysql.Run(ctx, "mysql:8.0",
network.ConnectNetwork("test-network"),
)
appContainer, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{
ContainerRequest: tc.ContainerRequest{
Networks: []string{"test-network"},
},
})11. 集成测试的数据管理策略
集成测试最难管理的部分不是容器的启动,而是测试数据的准备和清理。
方案一:每个测试独立事务,测试后回滚
这是最简洁的方案,测试结束后所有数据自动消失,不影响其他测试:
func withTx(t *testing.T, db *sql.DB, fn func(*sql.Tx)) {
t.Helper()
tx, err := db.Begin()
require.NoError(t, err)
t.Cleanup(func() {
tx.Rollback() // 不管成功还是失败,都回滚
})
fn(tx)
}
func TestUserRepository_Create(t *testing.T) {
withTx(t, testDB, func(tx *sql.Tx) {
repo := NewUserRepository(tx)
user, err := repo.Create("test@example.com")
require.NoError(t, err)
assert.NotEmpty(t, user.ID)
// tx 在测试结束后自动回滚,数据不残留
})
}这个方案对大多数 CRUD 测试非常有效。缺点是无法测试跨事务的行为(如 READ COMMITTED 隔离级别下的并发读),以及无法测试事务提交后的副作用(如触发器、CDC 事件)。
方案二:测试专属 Schema
为每个测试包分配独立的数据库 Schema,测试完成后删除整个 Schema:
func createTestSchema(t *testing.T, db *sql.DB) string {
t.Helper()
schemaName := fmt.Sprintf("test_%d", time.Now().UnixNano())
_, err := db.Exec(fmt.Sprintf("CREATE SCHEMA %s", schemaName))
require.NoError(t, err)
// 执行迁移
runMigrations(t, db, schemaName)
t.Cleanup(func() {
db.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schemaName))
})
return schemaName
}这个方案隔离性最强,并行测试完全无干扰,但需要数据库支持多 Schema(PostgreSQL 天然支持,MySQL 需要用多数据库模拟)。
方案三:Fixtures 工厂
对于需要大量测试数据的场景,用 Fixture Factory 按需创建:
// factory/order.go
type OrderFactory struct {
db *sql.DB
}
func (f *OrderFactory) CreateOrder(opts ...OrderOption) *Order {
order := &Order{
ID: uuid.New().String(),
Status: OrderStatusPending,
Amount: 100.0,
// 合理的默认值
}
for _, opt := range opts {
opt(order)
}
// 保存到数据库
saveOrder(f.db, order)
return order
}
// 使用
func TestOrderService_Ship(t *testing.T) {
factory := NewOrderFactory(testDB)
order := factory.CreateOrder(
WithStatus(OrderStatusPaid),
WithAmount(299.0),
)
err := svc.Ship(order.ID)
require.NoError(t, err)
// ...
}Fixture Factory 让测试数据的创建有统一的接口,减少了测试间的代码重复,也让测试意图更清晰。
12. 集成测试的工程投入回报分析
集成测试的最大争议,是它的运行时间和维护成本。和单元测试相比,集成测试有几倍甚至十几倍的运行时间,这让很多团队在 CI 里跑集成测试时很头疼。
正确的解法不是放弃集成测试,而是分层运行:
单元测试在每次 PR 都跑(30 秒内完成),这是最高频的质量反馈;集成测试在 PR 合并到主干后跑(允许 5-10 分钟),作为第二道防线;端到端测试在版本发布前跑(允许 30 分钟以上),作为最终确认。
这种分层不是妥协,而是对不同类型测试价值和成本的合理分配。单元测试发现逻辑错误速度最快,集成测试发现集成问题是它的强项,端到端测试发现用户路径上的问题。三者合力,构成真正全面的质量保障体系。
13. 集成测试的价值与定位再审视
工程师常常有一种困惑:我写了单测,又接入了 Testcontainers 做集成测试,两者有什么本质区别?什么时候应该写集成测试而不是单测?
这个问题的答案来自测试金字塔的理念。测试金字塔的底层是大量的单元测试(快速、便宜、聚焦),中间是适量的集成测试(验证组件间的协作),顶层是少量的端到端测试(验证用户路径)。每一层的成本和价值都不同,关键是找到适合你业务场景的比例。
单元测试擅长验证:算法逻辑是否正确、边界条件是否处理、错误分支是否覆盖。但单元测试有一个根本局限——它用 Mock 替代了真实依赖。Mock 按你的预期行为,而不是按真实行为。你 Mock 的数据库驱动可能和真实驱动的行为略有不同,你 Mock 的外部 API 可能没有反映真实的错误格式。
集成测试的独特价值,正是验证你的代码和真实依赖协作时是否正确。你的 SQL 在真实 MySQL 8.0 上是否按预期执行?你处理 Redis 的 WATCH 命令在真实 Redis 里是否正确实现了乐观锁?这类问题,Mock 回答不了,只有真实的容器化依赖才能回答。
一个经验性的判断:如果代码的正确性强依赖于特定的外部系统行为(SQL 方言、Redis 命令语义、消息队列的顺序保证),就写集成测试。如果代码的正确性只依赖于接口契约("给 Save 传这个对象,它应该返回没有错误"),就用 Mock 单测。两者之间没有绝对的界限,需要根据具体场景判断。
Testcontainers 的维护策略
Testcontainers 的一个常见问题是:CI 时间随着集成测试增多而线性增长。几个控制时间的策略:
使用 TestMain 共享容器,整个包只启动一次容器而不是每个测试函数都启动,这是最有效的单次优化。
把集成测试标记为 integration tag,在 CI 里和单元测试分开运行:
//go:build integration
// +build integration这样日常开发时 go test ./... 只跑单测,专门的集成测试 Job 才跑 go test -tags=integration ./...。
14. Testcontainers 生态的未来
Testcontainers 生态在快速发展,值得关注几个趋势方向:
Testcontainers Cloud 提供了在云端运行 Testcontainers 的能力,解决了 CI 环境里 Docker-in-Docker 的权限问题——不需要 privileged 容器,Runner 上不需要安装 Docker,容器在专用的云服务里启动和管理。对于使用容器化 CI Runner 的团队(比如 Kubernetes 上的 GitLab Runner),这是一个有吸引力的方向。
Testcontainers for Desktop 允许开发者在本地机器上统一管理测试容器的生命周期,提供 UI 界面查看当前运行的测试容器,简化了调试体验。
语言支持方面,Testcontainers 已经支持 Go、Java、Python、Node.js、.NET、Rust 等主流语言。跨语言的一致接口意味着在多语言 mono-repo 里,不同服务的集成测试可以用统一的方式管理测试依赖。
工具会持续演进,但 Testcontainers 解决的核心问题——让集成测试拥有真实的外部依赖而不依赖外部共享环境——是永恒的。无论工具如何变化,这个需求都存在。今天学会 Testcontainers 的思维方式,在明天的工具演进中也不会过时。
15. 写给第一次接触 Testcontainers 的工程师
如果你今天第一次读到 Testcontainers,最简单的起点是:选一个你现在用 SQLite 内存数据库做集成测试的项目,把 SQLite 换成 Testcontainers 的 MySQL 或 PostgreSQL,看看有没有因为数据库差异而发现的问题。大多数情况下,你会发现一两个 SQL 在真实 MySQL 上行为和 SQLite 不同的地方。这就是 Testcontainers 最直观的价值演示。
从这一个小改变开始,逐步扩展到 Redis、Kafka、S3——每引入一个真实依赖,你的测试就多了一层对真实系统行为的验证。Testcontainers 的学习曲线平缓,API 设计直觉,容器的启动和清理完全自动化。唯一需要适应的是测试时间变长了,但这个时间换来的是更高的测试可信度,值得。
Testcontainers 改变的不只是测试方式,更是工程师对"集成测试"的信心。当你的集成测试用的是真实的 MySQL 8.0、真实的 Redis 7.2,测试通过意味着代码在真实环境里也会工作——这种可信度,是内存 DB 或共享测试环境给不了的。 这种可信度的积累,是整个测试体系价值的基础。一个让人信任的测试套件,比一个让人怀疑的高覆盖率更有价值。从 Testcontainers 开始,建立对测试结果的真正信任。
集成测试的价值在于消除"这在我机器上是好的"这个最常见的借口。当整个团队都用 Testcontainers 在完全一致的环境里运行测试,这个借口就失效了——测试通过,就是真正通过;测试失败,就是真正有问题,和个人的本地环境无关。
写在最后
刘磊后来把他们的订单服务 Repository 层全都改用了 Testcontainers-go 做集成测试,跑完一遍发现了 3 个之前靠 Mock 发现不了的问题:一个 SQL 注入风险、一个事务隔离级别问题、一个 index 没建导致的慢查询。
这就是集成测试的价值——不是替代单测,而是补全单测测不到的那一层。
下一篇我们聊测试覆盖率,如何让覆盖率真正成为质量保障,而不只是用来应付 KPI 的数字。
