Appearance
集成测试与测试容器
单元测试通过 Mock 隔离依赖,能跑得飞快,但「Mock 通过」并不等于「真实数据库上能跑通」。集成测试用真实依赖(数据库、缓存、消息队列)验证多个组件协作时的行为,是单元测试与端到端测试之间的桥梁。本篇将讲解集成测试的策略、测试金字塔模型、用 SQLite 替代品快速测试、testcontainers-go 启动 Docker 容器、TestMain 全局初始化、事务回滚隔离、测试数据工厂模式。
一、单元测试 vs 集成测试 vs 端到端测试
三种测试的目标与边界:
| 维度 | 单元测试 | 集成测试 | 端到端测试(E2E) |
|---|---|---|---|
| 范围 | 单个函数/方法 | 多个组件协作(如 Service+DB) | 整个系统(HTTP→DB→外部 API) |
| 依赖 | Mock | 真实依赖(DB、缓存) | 完整生产环境 |
| 速度 | 毫秒级 | 秒级 | 分钟级 |
| 数量 | 大量(数千个) | 中等(数百个) | 少量(数十个) |
| 失败定位 | 精确(一个函数) | 较精确(一组组件) | 模糊(任意环节都可能挂) |
| 主要价值 | 验证逻辑正确 | 验证集成层正确 | 验证业务流正确 |
何时写集成测试?
- 当 Mock 越来越复杂、与真实行为偏离时。
- 当 SQL、迁移、事务边界是 bug 高发区时。
- 当多个 Service 共享数据库 schema,需要验证兼容性时。
- 当 ORM 行为不确定时(如级联删除、预加载)。
何时写 E2E?
- 关键业务路径(如下单、支付)。
- 跨服务通信。
- 上线前的回归测试。
二、测试金字塔
测试金字塔(Test Pyramid)是一个经典模型:
text
/\
/E2\ ← 少量 E2E(顶部,慢且少)
/----\
/Integ\ ← 中等数量集成测试(中段)
/--------\
/ Unit \ ← 大量单元测试(底部,快且多)
/--------------\核心思想:
- 底层最宽:单元测试应该占绝大多数(70-80%),跑得快、定位准。
- 中段适中:集成测试覆盖关键集成点(15-20%)。
- 顶部最少:E2E 测试最贵,应该精选业务关键路径(5-10%)。
如果一个项目反过来——E2E 多、单元少——会形成「测试冰淇淋」反模式:测试慢、定位难、维护成本高,最终被废弃。
实践建议:
- 把能在单元层覆盖的逻辑尽量下沉,集成层只测「真实依赖下的协作」。
- 集成测试也要快——用 SQLite 内存库、Docker 容器复用,避免每个测试启一个新实例。
- E2E 测试要稳定——避免依赖时间、外部第三方服务。
三、集成测试策略
集成测试的几种典型策略:
1. 共享一个测试数据库
整个测试套件共用一个数据库实例,每个测试用唯一数据(如带前缀的 ID)。优点是快,缺点是数据互相干扰、需要清理。
2. 每个测试一个独立 schema/database
每个测试在 SetupTest 创建独立 database,TearDownTest 删除。隔离性好,但启动慢。
3. 事务回滚
每个测试在事务中执行,测试结束 Rollback。数据不落库,速度快,但要求代码不显式 commit。
4. Testcontainers 容器化
每个测试套件启动一个 Docker 容器,提供真实环境。最接近生产,但要求 CI 有 Docker。
下面分别介绍 SQLite 替代、testcontainers、事务回滚三种方案。
四、测试数据库:使用 SQLite 替代
如果生产用 MySQL/PostgreSQL,测试用 SQLite 内存库可以极大提升速度,但有几个注意事项:
- SQL 方言差异:SQLite 不支持某些 MySQL 特性(如
ON DUPLICATE KEY UPDATE、存储过程)。 - 类型系统差异:SQLite 是动态类型,MySQL 是严格类型。某些约束行为不同。
- 并发模型差异:SQLite 写串行,MySQL 多版本并发。
- GORM 抽象层:用 ORM 可以屏蔽大部分差异,但复杂查询仍可能行为不同。
下面用 GORM 演示 SQLite 内存库测试。
product.go:
go
package product
import (
"context"
"errors"
"gorm.io/gorm"
)
type Product struct {
gorm.Model
Name string `gorm:"not null"`
Price float64 `gorm:"not null"`
Stock int `gorm:"default:0"`
}
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(ctx context.Context, p Product) (Product, error) {
if err := r.db.WithContext(ctx).Create(&p).Error; err != nil {
return Product{}, err
}
return p, nil
}
func (r *Repository) FindByID(ctx context.Context, id uint) (Product, error) {
var p Product
err := r.db.WithContext(ctx).First(&p, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Product{}, ErrProductNotFound
}
return p, err
}
func (r *Repository) List(ctx context.Context) ([]Product, error) {
var list []Product
err := r.db.WithContext(ctx).Find(&list).Error
return list, err
}
func (r *Repository) Update(ctx context.Context, p Product) error {
return r.db.WithContext(ctx).Save(&p).Error
}
func (r *Repository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&Product{}, id).Error
}
var ErrProductNotFound = errors.New("product not found")测试:
go
package product_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"example.com/product"
)
// SQLiteSuite 用 SQLite 内存库做集成测试。
type SQLiteSuite struct {
suite.Suite
db *gorm.DB
repo *product.Repository
}
func (s *SQLiteSuite) SetupSuite() {
// 整个套件只创建一个内存库
// `file::memory:?cache=shared` 让多个连接共享同一个内存数据库
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.db = db
}
func (s *SQLiteSuite) SetupTest() {
// 每个测试开始前,迁移 schema 并清理表
s.Require().NoError(s.db.AutoMigrate(&product.Product{}))
s.db.Exec("DELETE FROM products")
s.repo = product.NewRepository(s.db)
}
func (s *SQLiteSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
_ = sqlDB.Close()
}
}
func (s *SQLiteSuite) TestCreate() {
p, err := s.repo.Create(context.Background(), product.Product{
Name: "Apple", Price: 9.9, Stock: 100,
})
s.Require().NoError(err)
s.NotZero(p.ID)
s.Equal("Apple", p.Name)
}
func (s *SQLiteSuite) TestFindByID() {
created, _ := s.repo.Create(context.Background(), product.Product{
Name: "Banana", Price: 5.5, Stock: 50,
})
found, err := s.repo.FindByID(context.Background(), created.ID)
s.Require().NoError(err)
s.Equal("Banana", found.Name)
s.Equal(5.5, found.Price)
}
func (s *SQLiteSuite) TestFindByID_NotFound() {
_, err := s.repo.FindByID(context.Background(), 999999)
s.Require().Error(err)
s.ErrorIs(err, product.ErrProductNotFound)
}
func (s *SQLiteSuite) TestList() {
// 准备数据
_, _ = s.repo.Create(context.Background(), product.Product{Name: "A", Price: 1})
_, _ = s.repo.Create(context.Background(), product.Product{Name: "B", Price: 2})
_, _ = s.repo.Create(context.Background(), product.Product{Name: "C", Price: 3})
list, err := s.repo.List(context.Background())
s.Require().NoError(err)
s.Len(list, 3)
}
func (s *SQLiteSuite) TestUpdate() {
created, _ := s.repo.Create(context.Background(), product.Product{
Name: "Old", Price: 10, Stock: 0,
})
created.Name = "New"
created.Price = 20
err := s.repo.Update(context.Background(), created)
s.Require().NoError(err)
updated, _ := s.repo.FindByID(context.Background(), created.ID)
s.Equal("New", updated.Name)
s.Equal(20.0, updated.Price)
}
func (s *SQLiteSuite) TestDelete() {
created, _ := s.repo.Create(context.Background(), product.Product{
Name: "ToDelete", Price: 1,
})
err := s.repo.Delete(context.Background(), created.ID)
s.Require().NoError(err)
_, err = s.repo.FindByID(context.Background(), created.ID)
s.Require().Error(err)
}
func (s *SQLiteSuite) TestList_Empty() {
list, err := s.repo.List(context.Background())
s.Require().NoError(err)
s.Empty(list)
}
func TestSQLiteSuite(t *testing.T) {
suite.Run(t, new(SQLiteSuite))
}
// 一个独立的集成测试,演示不在 Suite 里的写法
func TestSQLiteIntegration(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
defer func() {
sqlDB, _ := db.DB()
_ = sqlDB.Close()
}()
require.NoError(t, db.AutoMigrate(&product.Product{}))
db.Exec("DELETE FROM products")
repo := product.NewRepository(db)
// 创建并查询
p, err := repo.Create(context.Background(), product.Product{
Name: "Test", Price: 100, Stock: 10,
})
require.NoError(t, err)
assert.NotZero(t, p.ID)
// 查询所有
list, err := repo.List(context.Background())
require.NoError(t, err)
assert.Len(t, list, 1)
}SQLite 内存库要点:
file::memory:?cache=shared让所有连接共享同一个内存数据库(默认每个连接独立)。AutoMigrate在 SetupTest 中调用,确保 schema 一致。DELETE FROM products在 SetupTest 中清理,避免上个测试的数据干扰。- 用
Logger: Silent关闭 GORM 默认的 SQL 日志输出。
五、testcontainers-go:Docker 容器化测试
testcontainers-go 是 Testcontainers 项目的 Go 实现,它能在测试中按需启动 Docker 容器(MySQL、PostgreSQL、Redis、Kafka 等),并在测试结束后自动销毁。
安装:
bash
go get github.com/testcontainers/testcontainers-go特点:
- 真实环境:用真实的 MySQL/Redis,不漏掉方言差异。
- 隔离性好:每个测试套件独立容器。
- CI 友好:现代 CI 都支持 Docker。
- 缺点:启动慢(每个容器几秒),需要 Docker 环境。
1. MySQL 容器
go
package product_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"example.com/product"
)
// MySQLSuite 用真实 MySQL 容器做集成测试。
type MySQLSuite struct {
suite.Suite
container testcontainers.Container
db *gorm.DB
repo *product.Repository
}
func (s *MySQLSuite) SetupSuite() {
ctx := context.Background()
// 启动 MySQL 8 容器
req := testcontainers.ContainerRequest{
Image: "mysql:8",
ExposedPorts: []string{"3306/tcp"},
Env: map[string]string{
"MYSQL_ROOT_PASSWORD": "test",
"MYSQL_DATABASE": "testdb",
"MYSQL_USER": "test",
"MYSQL_PASSWORD": "test",
},
WaitingFor: wait.ForLog("ready for connections").
WithOccurrence(2).
WithStartupTimeout(60 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
s.Require().NoError(err)
s.container = container
// 获取容器映射的端口
host, err := container.Host(ctx)
s.Require().NoError(err)
port, err := container.MappedPort(ctx, "3306/tcp")
s.Require().NoError(err)
dsn := fmt.Sprintf("test:test@tcp(%s:%s)/testdb?charset=utf8mb4&parseTime=True&loc=Local",
host, port.Port())
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.db = db
s.Require().NoError(db.AutoMigrate(&product.Product{}))
}
func (s *MySQLSuite) TearDownSuite() {
if s.container != nil {
_ = s.container.Terminate(context.Background())
}
if s.db != nil {
sqlDB, _ := s.db.DB()
_ = sqlDB.Close()
}
}
func (s *MySQLSuite) SetupTest() {
s.db.Exec("DELETE FROM products")
s.repo = product.NewRepository(s.db)
}
func (s *MySQLSuite) TestCreateAndFind() {
p, err := s.repo.Create(context.Background(), product.Product{
Name: "Apple", Price: 9.9, Stock: 100,
})
s.Require().NoError(err)
s.NotZero(p.ID)
found, err := s.repo.FindByID(context.Background(), p.ID)
s.Require().NoError(err)
s.Equal("Apple", found.Name)
s.Equal(9.9, found.Price)
}
func (s *MySQLSuite) TestList() {
for i := 0; i < 5; i++ {
_, err := s.repo.Create(context.Background(), product.Product{
Name: fmt.Sprintf("Item-%d", i),
Price: float64(i) + 0.5,
})
s.Require().NoError(err)
}
list, err := s.repo.List(context.Background())
s.Require().NoError(err)
s.Len(list, 5)
}
func (s *MySQLSuite) TestMySQLSpecificBehavior() {
// 验证 MySQL 特有的行为(如事务回滚、锁)
// 这类测试在 SQLite 上跑不出真实行为
ctx := context.Background()
tx := s.db.WithContext(ctx).Begin()
defer tx.Rollback()
repo := product.NewRepository(tx)
p, err := repo.Create(ctx, product.Product{Name: "TxItem", Price: 1})
s.Require().NoError(err)
// 在事务内可见
_, err = repo.FindByID(ctx, p.ID)
s.Require().NoError(err)
// Rollback 后,从主连接查询应不可见
tx.Rollback()
_, err = s.repo.FindByID(ctx, p.ID)
s.Require().Error(err)
}
func TestMySQLSuite(t *testing.T) {
// 跳过:如果环境没有 Docker
if testing.Short() {
t.Skip("跳过需要 Docker 的集成测试")
}
suite.Run(t, new(MySQLSuite))
}2. Redis 容器
go
package cache_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/redis/go-redis/v9"
)
// Cache 是被测的缓存封装。
type Cache struct {
client *redis.Client
}
func NewCache(client *redis.Client) *Cache {
return &Cache{client: client}
}
func (c *Cache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
return c.client.Set(ctx, key, value, ttl).Err()
}
func (c *Cache) Get(ctx context.Context, key string) (string, error) {
val, err := c.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrNotFound
}
return val, err
}
var ErrNotFound = errNotFound{}
type errNotFound struct{}
func (e errNotFound) Error() string { return "not found" }
// RedisSuite 是 Redis 集成测试套件。
type RedisSuite struct {
suite.Suite
container testcontainers.Container
client *redis.Client
cache *Cache
}
func (s *RedisSuite) SetupSuite() {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForLog("Ready to accept connections").WithStartupTimeout(30 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
s.Require().NoError(err)
s.container = container
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "6379/tcp")
s.client = redis.NewClient(&redis.Options{
Addr: host + ":" + port.Port(),
})
s.cache = NewCache(s.client)
// 等待 Redis 就绪
s.Require().Eventually(func() bool {
return s.client.Ping(ctx).Err() == nil
}, 5*time.Second, 100*time.Millisecond)
}
func (s *RedisSuite) TearDownSuite() {
if s.client != nil {
_ = s.client.Close()
}
if s.container != nil {
_ = s.container.Terminate(context.Background())
}
}
func (s *RedisSuite) SetupTest() {
s.Require().NoError(s.client.FlushDB(context.Background()).Err())
}
func (s *RedisSuite) TestSetGet() {
ctx := context.Background()
err := s.cache.Set(ctx, "k1", "v1", time.Minute)
s.Require().NoError(err)
val, err := s.cache.Get(ctx, "k1")
s.Require().NoError(err)
s.Equal("v1", val)
}
func (s *RedisSuite) TestGetNotFound() {
_, err := s.cache.Get(context.Background(), "nonexistent")
s.Require().Error(err)
}
func (s *RedisSuite) TestTTL() {
ctx := context.Background()
err := s.cache.Set(ctx, "k_short", "v", 100*time.Millisecond)
s.Require().NoError(err)
// 立即查得到
_, err = s.cache.Get(ctx, "k_short")
s.Require().NoError(err)
// 等过期后查不到
time.Sleep(200 * time.Millisecond)
_, err = s.cache.Get(ctx, "k_short")
s.Require().Error(err)
}
func TestRedisSuite(t *testing.T) {
if testing.Short() {
t.Skip("跳过需要 Docker 的集成测试")
}
suite.Run(t, new(RedisSuite))
}容器复用:如果多个测试套件都需要 MySQL,可以为每个 Suite 启动独立容器,也可以用
testcontainers-go的 reuse 机制共享容器。前者隔离性好但慢,后者快但需要谨慎清理数据。
六、TestMain:全局测试初始化
TestMain(m *testing.M) 是测试包的入口函数,定义后 go test 会先调用它,由它决定何时调用 m.Run() 执行其他测试。用途:
- 初始化共享资源(数据库连接、容器、日志)。
- 加载配置文件。
- 执行迁移。
- 决定退出码。
go
package main_test
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"example.com/product"
)
// 全局共享资源
var (
testDB *gorm.DB
testRepo *product.Repository
)
func TestMain(m *testing.M) {
// 短模式跳过容器启动
if testing.Short() {
fmt.Println("短模式:跳过 Docker 容器初始化")
os.Exit(m.Run())
}
ctx := context.Background()
container, err := startMySQL(ctx)
if err != nil {
fmt.Printf("启动 MySQL 容器失败: %v\n", err)
os.Exit(1)
}
defer func() {
_ = container.Terminate(ctx)
}()
db, err := setupDB(ctx, container)
if err != nil {
fmt.Printf("初始化数据库失败: %v\n", err)
os.Exit(1)
}
testDB = db
testRepo = product.NewRepository(db)
// 执行所有测试
code := m.Run()
// 清理
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
os.Exit(code)
}
func startMySQL(ctx context.Context) (testcontainers.Container, error) {
req := testcontainers.ContainerRequest{
Image: "mysql:8",
ExposedPorts: []string{"3306/tcp"},
Env: map[string]string{
"MYSQL_ROOT_PASSWORD": "test",
"MYSQL_DATABASE": "testdb",
},
WaitingFor: wait.ForLog("ready for connections").
WithOccurrence(2).
WithStartupTimeout(60 * time.Second),
}
return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
}
func setupDB(ctx context.Context, container testcontainers.Container) (*gorm.DB, error) {
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "3306/tcp")
dsn := fmt.Sprintf("root:test@tcp(%s:%s)/testdb?charset=utf8mb4&parseTime=True&loc=Local",
host, port.Port())
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, err
}
if err := db.AutoMigrate(&product.Product{}); err != nil {
return nil, err
}
return db, nil
}
// 使用共享 testDB 的测试
func TestGlobalRepo_Create(t *testing.T) {
if testing.Short() {
t.Skip("跳过")
}
testDB.Exec("DELETE FROM products")
p, err := testRepo.Create(context.Background(), product.Product{
Name: "Global", Price: 1,
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
if p.ID == 0 {
t.Error("ID should not be zero")
}
}TestMain 的注意点:
- 只调用一次:每个测试包最多一个
TestMain。 - 必须调用
m.Run():否则不会执行任何测试。 - 退出码:用
os.Exit(m.Run())传递退出码,不要直接return。 - 资源清理:
defer不会执行(因为os.Exit),清理要在os.Exit之前。
七、测试隔离:事务回滚
事务回滚是数据库测试隔离的经典技巧:
go
package product_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"example.com/product"
)
func TestTxRollbackIsolation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&product.Product{}))
db.Exec("DELETE FROM products")
// 测试 1:创建一个产品
t.Run("create", func(t *testing.T) {
tx := db.Begin()
defer tx.Rollback() // 测试结束回滚,数据不落库
repo := product.NewRepository(tx)
_, err := repo.Create(context.Background(), product.Product{
Name: "Tx", Price: 1,
})
require.NoError(t, err)
list, _ := repo.List(context.Background())
require.Len(t, list, 1)
})
// 测试 2:在主连接里看不到测试 1 的数据
t.Run("isolated", func(t *testing.T) {
repo := product.NewRepository(db)
list, _ := repo.List(context.Background())
require.Empty(t, list, "事务回滚后应无数据")
})
}事务回滚的优点:
- 极快(不真的写库)。
- 完全隔离(每个测试独立事务)。
- 不需要清理脚本。
缺点:
- 不能测试「跨连接」的行为(事务隔离级别)。
- 不能测试需要 commit 才生效的逻辑(如触发器)。
- 不能测试「事务嵌套」「Savepoint」等高级特性。
八、测试数据工厂模式
复杂测试需要构造大量数据,散落在测试代码里会很乱。「测试数据工厂」模式把数据构造封装为函数,提高复用性。
go
package product_test
import (
"fmt"
"example.com/product"
)
// ProductBuilder 是一个建造者,用于构造 Product。
type ProductBuilder struct {
p product.Product
}
func NewProductBuilder() *ProductBuilder {
return &ProductBuilder{p: product.Product{
Name: "DefaultName",
Price: 10.0,
Stock: 0,
}}
}
func (b *ProductBuilder) WithName(name string) *ProductBuilder {
b.p.Name = name
return b
}
func (b *ProductBuilder) WithPrice(price float64) *ProductBuilder {
b.p.Price = price
return b
}
func (b *ProductBuilder) WithStock(stock int) *ProductBuilder {
b.p.Stock = stock
return b
}
func (b *ProductBuilder) Build() product.Product {
return b.p
}
// 工厂函数:常见场景的快捷构造
func MakeApple() product.Product {
return NewProductBuilder().
WithName("Apple").
WithPrice(9.9).
WithStock(100).
Build()
}
func MakeBanana() product.Product {
return NewProductBuilder().
WithName("Banana").
WithPrice(5.5).
WithStock(50).
Build()
}
func MakeProducts(n int) []product.Product {
products := make([]product.Product, n)
for i := range products {
products[i] = NewProductBuilder().
WithName(fmt.Sprintf("Product-%d", i)).
WithPrice(float64(i) + 0.5).
Build()
}
return products
}使用:
go
func TestFactoryExample(t *testing.T) {
// 用工厂构造测试数据
apple := MakeApple()
require.Equal(t, "Apple", apple.Name)
custom := NewProductBuilder().
WithName("Custom").
WithPrice(99.9).
WithStock(999).
Build()
require.Equal(t, "Custom", custom.Name)
batch := MakeProducts(10)
require.Len(t, batch, 10)
}工厂模式的好处:
- 可读性:
MakeApple()比Product{Name: "Apple", Price: 9.9, Stock: 100}表意更清晰。 - 可维护:Product 加新字段时,只需更新 Builder,不影响已有测试。
- 可组合:
WithName().WithPrice().Build()链式构造。 - 可复用:常见场景封装为
MakeXxx函数。
九、完整示例:数据库集成测试
把前面的元素整合,下面是一个完整的「订单服务」集成测试套件。
order.go:
go
package order
import (
"context"
"errors"
"fmt"
"gorm.io/gorm"
)
type Order struct {
gorm.Model
UserID uint `gorm:"not null;index"`
ProductID uint `gorm:"not null;index"`
Quantity int `gorm:"not null"`
TotalPrice float64 `gorm:"not null"`
Status string `gorm:"default:'pending'"`
}
var (
ErrOrderNotFound = errors.New("order not found")
ErrInvalidOrder = errors.New("invalid order")
ErrInsufficientStock = errors.New("insufficient stock")
)
type Service struct {
db *gorm.DB
}
func NewService(db *gorm.DB) *Service {
return &Service{db: db}
}
func (s *Service) PlaceOrder(ctx context.Context, userID, productID uint, quantity int, unitPrice float64) (Order, error) {
if quantity <= 0 {
return Order{}, fmt.Errorf("%w: quantity must be positive", ErrInvalidOrder)
}
order := Order{
UserID: userID,
ProductID: productID,
Quantity: quantity,
TotalPrice: unitPrice * float64(quantity),
Status: "pending",
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err
}
return nil
})
if err != nil {
return Order{}, err
}
return order, nil
}
func (s *Service) GetOrder(ctx context.Context, id uint) (Order, error) {
var o Order
err := s.db.WithContext(ctx).First(&o, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Order{}, ErrOrderNotFound
}
return o, err
}
func (s *Service) ListByUser(ctx context.Context, userID uint) ([]Order, error) {
var orders []Order
err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&orders).Error
return orders, err
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status string) error {
result := s.db.WithContext(ctx).Model(&Order{}).Where("id = ?", id).Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrOrderNotFound
}
return nil
}
func (s *Service) CancelOrder(ctx context.Context, id uint) error {
return s.UpdateStatus(ctx, id, "cancelled")
}测试:
go
package order_test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"example.com/order"
)
// OrderSuite 是订单服务的集成测试套件。
type OrderSuite struct {
suite.Suite
db *gorm.DB
svc *order.Service
}
func (s *OrderSuite) SetupSuite() {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(&order.Order{}))
s.db = db
s.svc = order.NewService(db)
}
func (s *OrderSuite) SetupTest() {
s.db.Exec("DELETE FROM orders")
}
func (s *OrderSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
_ = sqlDB.Close()
}
}
func (s *OrderSuite) TestPlaceOrder() {
o, err := s.svc.PlaceOrder(context.Background(), 1, 100, 2, 9.9)
s.Require().NoError(err)
s.NotZero(o.ID)
s.Equal(uint(1), o.UserID)
s.Equal(uint(100), o.ProductID)
s.Equal(2, o.Quantity)
s.Equal(19.8, o.TotalPrice)
s.Equal("pending", o.Status)
}
func (s *OrderSuite) TestPlaceOrder_InvalidQuantity() {
_, err := s.svc.PlaceOrder(context.Background(), 1, 100, 0, 9.9)
s.Require().Error(err)
s.ErrorIs(err, order.ErrInvalidOrder)
_, err = s.svc.PlaceOrder(context.Background(), 1, 100, -1, 9.9)
s.Require().Error(err)
s.ErrorIs(err, order.ErrInvalidOrder)
}
func (s *OrderSuite) TestGetOrder() {
created, _ := s.svc.PlaceOrder(context.Background(), 1, 100, 1, 5.0)
found, err := s.svc.GetOrder(context.Background(), created.ID)
s.Require().NoError(err)
s.Equal(created.ID, found.ID)
s.Equal(5.0, found.TotalPrice)
}
func (s *OrderSuite) TestGetOrder_NotFound() {
_, err := s.svc.GetOrder(context.Background(), 999999)
s.Require().Error(err)
s.ErrorIs(err, order.ErrOrderNotFound)
}
func (s *OrderSuite) TestListByUser() {
for i := 0; i < 3; i++ {
_, _ = s.svc.PlaceOrder(context.Background(), 1, uint(100+i), 1, 1.0)
}
// 用户 2 的订单
_, _ = s.svc.PlaceOrder(context.Background(), 2, 100, 1, 1.0)
list, err := s.svc.ListByUser(context.Background(), 1)
s.Require().NoError(err)
s.Len(list, 3)
}
func (s *OrderSuite) TestListByUser_Empty() {
list, err := s.svc.ListByUser(context.Background(), 999)
s.Require().NoError(err)
s.Empty(list)
}
func (s *OrderSuite) TestUpdateStatus() {
o, _ := s.svc.PlaceOrder(context.Background(), 1, 100, 1, 1.0)
err := s.svc.UpdateStatus(context.Background(), o.ID, "shipped")
s.Require().NoError(err)
updated, _ := s.svc.GetOrder(context.Background(), o.ID)
s.Equal("shipped", updated.Status)
}
func (s *OrderSuite) TestUpdateStatus_NotFound() {
err := s.svc.UpdateStatus(context.Background(), 999999, "shipped")
s.Require().Error(err)
s.ErrorIs(err, order.ErrOrderNotFound)
}
func (s *OrderSuite) TestCancelOrder() {
o, _ := s.svc.PlaceOrder(context.Background(), 1, 100, 1, 1.0)
err := s.svc.CancelOrder(context.Background(), o.ID)
s.Require().NoError(err)
cancelled, _ := s.svc.GetOrder(context.Background(), o.ID)
s.Equal("cancelled", cancelled.Status)
}
func (s *OrderSuite) TestTransactionIntegrity() {
// 测试事务回滚:模拟失败场景
ctx := context.Background()
o, err := s.svc.PlaceOrder(ctx, 1, 100, 1, 1.0)
s.Require().NoError(err)
// 在事务外修改状态,验证状态变化
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order.Order{}).Where("id = ?", o.ID).Update("status", "processing").Error; err != nil {
return err
}
// 模拟后续操作失败,触发回滚
return fmt.Errorf("simulated failure")
})
s.Require().Error(err)
// 状态应该没变(事务回滚)
current, _ := s.svc.GetOrder(ctx, o.ID)
s.Equal("pending", current.Status)
}
// 单独的测试:验证 SQLite 上的索引是否生效
func TestIndexesExist(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&order.Order{}))
defer func() {
sqlDB, _ := db.DB()
_ = sqlDB.Close()
}()
// 查询 sqlite_master,验证索引存在
var count int64
db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_orders%'").Scan(&count)
assert.Greater(t, count, int64(0), "应该存在 orders 表的索引")
}
func TestOrderSuite(t *testing.T) {
suite.Run(t, new(OrderSuite))
}执行:
bash
# 快速跑(只用 SQLite)
go test -v
# 跳过集成测试
go test -short
# 用 Docker 容器跑(如果有 MySQLSuite)
go test -v -run MySQLSuite十、集成测试的最佳实践
1. 用 build tag 区分单元与集成测试
go
//go:build integration
package order_test
import "testing"
func TestIntegration(t *testing.T) {
// 只有 go test -tags=integration 才会跑
}bash
go test ./... # 不跑集成测试
go test -tags=integration ./... # 跑集成测试2. 并行执行
集成测试通常较慢,并行能大幅加速。但要注意:
- 共享数据库的测试不要并行(会互相干扰)。
- 不同容器的测试可以并行。
3. 用 -short 跳过
在 CI 的快速构建阶段用 go test -short,跳过容器测试;在 nightly build 跑全套。
4. 数据清理要彻底
每个测试结束后要清理自己创建的数据,避免污染下一个测试:
- 用
DELETE FROM table WHERE id LIKE 'test_%'(如果 ID 用前缀)。 - 用
TRUNCATE TABLE(更快但更危险)。 - 用事务回滚(最快但有限制)。
- 用独立 schema/database(最隔离但启动慢)。
5. 容器版本要与生产一致
生产用 MySQL 8.0,测试就用 mysql:8.0,不要用 mysql:5.7。否则方言差异会让测试通过但生产崩。
6. 容器复用
如果多个测试套件都需要 MySQL,可以用 testcontainers-go 的 reuse 机制,避免重复启动:
go
// 用 reuse 选项复用容器
req := testcontainers.ContainerRequest{
Image: "mysql:8",
// ...
Reuse: true,
Name: "shared-mysql", // 必须命名才能复用
}十一、小结
本篇系统讲解了 Go 的集成测试,核心要点:
- 三种测试分工:单元(验证逻辑)、集成(验证组件协作)、E2E(验证业务流),形成测试金字塔。
- SQLite 内存库:快速、零依赖,适合大部分 ORM 测试,但方言有差异。
- testcontainers-go:启动真实 Docker 容器,最接近生产环境,CI 友好。
- TestMain:全局初始化与清理,适合共享资源。
- 事务回滚:每个测试独立事务,测试结束 Rollback,速度快但有限制。
- 测试数据工厂:用 Builder 模式封装数据构造,提高可读性与复用性。
- 隔离策略:独立 schema/事务回滚/前缀数据/独立容器,各有所长。
- 最佳实践:用 build tag 区分集成测试、用
-short跳过、版本对齐生产、容器复用。
下一篇我们将进入本系列的最后一篇——测试最佳实践与覆盖率,系统总结 Go 测试的工程化方法、CI/CD 集成策略、覆盖率目标设定与 TDD/BDD 流程。
集成测试是单元测试与生产之间的「缓冲带」。它既保留了单元测试的自动化优势,又能验证真实依赖下的行为。把 SQLite 用于日常快速验证,把 testcontainers 用于 nightly 完整测试,是大部分 Go 项目的最佳组合。