Skip to content

集成测试与测试容器

单元测试通过 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 内存库可以极大提升速度,但有几个注意事项:

  1. SQL 方言差异:SQLite 不支持某些 MySQL 特性(如 ON DUPLICATE KEY UPDATE、存储过程)。
  2. 类型系统差异:SQLite 是动态类型,MySQL 是严格类型。某些约束行为不同。
  3. 并发模型差异:SQLite 写串行,MySQL 多版本并发。
  4. 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 的集成测试,核心要点:

  1. 三种测试分工:单元(验证逻辑)、集成(验证组件协作)、E2E(验证业务流),形成测试金字塔。
  2. SQLite 内存库:快速、零依赖,适合大部分 ORM 测试,但方言有差异。
  3. testcontainers-go:启动真实 Docker 容器,最接近生产环境,CI 友好。
  4. TestMain:全局初始化与清理,适合共享资源。
  5. 事务回滚:每个测试独立事务,测试结束 Rollback,速度快但有限制。
  6. 测试数据工厂:用 Builder 模式封装数据构造,提高可读性与复用性。
  7. 隔离策略:独立 schema/事务回滚/前缀数据/独立容器,各有所长。
  8. 最佳实践:用 build tag 区分集成测试、用 -short 跳过、版本对齐生产、容器复用。

下一篇我们将进入本系列的最后一篇——测试最佳实践与覆盖率,系统总结 Go 测试的工程化方法、CI/CD 集成策略、覆盖率目标设定与 TDD/BDD 流程。


集成测试是单元测试与生产之间的「缓冲带」。它既保留了单元测试的自动化优势,又能验证真实依赖下的行为。把 SQLite 用于日常快速验证,把 testcontainers 用于 nightly 完整测试,是大部分 Go 项目的最佳组合。