53.3 集成测试

一句话

集成测试解决 “单元都对了,拼起来却不对”——用真实依赖验证模块之间真的合得上。

为什么单元测试全绿,集成还是会挂

因为单元测试把依赖 mock 掉了。真实对接时,mock 的假设会撞上现实的契约:

单元测试里 mock 的假设 现实的契约
"Save 返回 nil" 数据库字段类型不匹配,报错
"这个查询返回一行" SQL 写错了,返回零行
"Redis 肯定连着" key 前缀对不上,读不到

🔑 **mock 验证的是 “你的假设”,集成测试验证的是 “真实的契约”。**假设对了,不代表契约对。

核心:集成测试必须用 “真实依赖”

如果用 mock,那就是单元测试,不是集成测试。集成测试的价值恰恰在 “真实”:

  • 真实数据库 (Postgres / MySQL)
  • 真实缓存 (Redis)
  • 真实消息队列 (Kafka / RabbitMQ)
  • 真实的外部 API (或其替身服务器)

Go 怎么起真实依赖:testcontainers

testcontainers-go 用 Docker 在测试里拉起真实依赖,测完自动销毁:

import (
    "context"
    "database/sql"
    "fmt"
    "testing"

    _ "github.com/jackc/pgx/v5/stdlib" // pgx 驱动
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestUserRepo_Insert(t *testing.T) {
    ctx := context.Background()

    // 1. 起一个真实的 Postgres 容器
    pg, err := testcontainers.GenericContainer(ctx,
        testcontainers.GenericContainerRequest{
            ContainerRequest: testcontainers.ContainerRequest{
                Image:        "postgres:16-alpine",
                ExposedPorts: []string{"5432/tcp"},
                Env: map[string]string{
                    "POSTGRES_PASSWORD": "test",
                    "POSTGRES_DB":       "app",
                },
                WaitingFor: wait.ForLog("database system is ready to accept connections"),
            },
            Started: true,
        })
    if err != nil {
        t.Fatal(err)
    }
    defer pg.Terminate(ctx) // 测完销毁,不留垃圾

    // 2. 拿到真实连接串
    host, _ := pg.Host(ctx)
    port, _ := pg.MappedPort(ctx, "5432")
    dsn := fmt.Sprintf("postgres://postgres:test@%s:%s/app?sslmode=disable", host, port.Port())

    db, err := sql.Open("pgx", dsn)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // 3. 建表(生产上换成 migrate 迁移脚本)
    if _, err := db.Exec(`CREATE TABLE users (id BIGSERIAL PRIMARY KEY, name TEXT)`); err != nil {
        t.Fatal(err)
    }

    // 4. 走真实的 repository,再从数据库读回来验证
    repo := NewUserRepo(db)
    if err := repo.Insert(ctx, User{Name: "张三"}); err != nil {
        t.Fatalf("Insert: %v", err)
    }

    var name string
    if err := db.QueryRow(`SELECT name FROM users WHERE id = 1`).Scan(&name); err != nil {
        t.Fatal(err)
    }
    if name != "张三" {
        t.Fatalf("got %q, want 张三", name)
    }
}

这测试真正验证了:连接串对、驱动对、建表 SQL 对、Insert 的 SQL 对、字段映射对——这五件事,单元测试的 mock 一件都验证不了。

测试数据管理:每个测试要干净

方案 做法 取舍
事务回滚 整个测试包在一个事务里,测完 rollback 简单,但不适用于代码里有 COMMIT 的场景
独立 schema 每个测试用独立 schema,测完 drop 干净,实现略复杂
容器隔离 每个测试起一个新容器 最干净,但最慢

何时写、何时不写

✅ 该写集成测试 ❌ 不该写(交给单元测试)
数据访问层(SQL、ORM 映射、迁移脚本) 纯业务逻辑(计算、校验)
缓存层(序列化、TTL、key 设计) 分支判断、状态机
消息层(生产/消费、序列化格式) 工具函数

落到数字:真实依赖的成本

项 量级 说明
起一个 Postgres 容器 2–10s(首次拉镜像更久) 建议用固定版本镜像 + 本地缓存
每个测试独立 schema 10–100ms 比 “重建库” 快得多
集成测试套件 1–5min 超过 10min 就要并行/拆分
复用容器(suite 级) 省 90% 启动时间 但测试间要隔离
// testcontainers-go:起真实 Postgres,用完即弃
pg, _ := postgres.Run(ctx, "postgres:16-alpine",
    postgres.WithDatabase("test"),
    postgres.WithUsername("test"), postgres.WithPassword("test"))
defer pg.Terminate(ctx)
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")

判据:集成测试的价值 = “它用的依赖有多真”;用内存库替代,就等于把要验证的契约又换成了 mock。

自检

Q1 单元测试全绿,但一上环境就报 “字段类型不匹配”。这说明什么?哪个测试层该背锅?

▸ 答案:单元测试的 mock 没验证真实契约。这类 “SQL 对不上、类型对不上” 的问题,只有集成测试 (连真实数据库) 才能提前暴露。

Q2 有人在集成测试里用内存数据库 (如 SQLite) 替代 Postgres。这会有什么盲区?

▸ 答案:SQL 方言不同 (upsert、锁语义、内置函数),“SQLite 能跑” 不代表 “Postgres 能跑”。内存数据库是另一种 mock,不是集成测试——它把你要验证的 “真实契约” 又替换掉了。

Q3 集成测试为什么不能共享一个数据库?

▸ 答案:因为测试之间会互相污染:A 测试插入的数据影响 B 的查询结果,失败还随执行顺序变化(flaky 的经典来源)。正确做法是 “每个测试独立 schema/事务回滚”,或每个 suite 一个库并串行清理。

Q4 集成测试应该放在 CI 的哪个阶段?

▸ 答案:放在单元测试之后、E2E 之前,且只在 PR/合并时跑(不必每次提交)。它需要真实依赖(容器/服务),启动成本高,但比 E2E 便宜且定位更准——是 “发现契约错” 的最后一层。

Q5 集成测试里的 “测试数据” 怎么管理?

▸ 答案:三原则:由测试自己创建(不依赖预置数据)、用固定种子/固定 ID(可重复)、结束时清理或回滚。共享的 “测试库基线数据” 会随时间腐化,最后没人知道哪条数据是谁的。