Backend Integration Testing with Testcontainers

使用 Testcontainers 实现后端集成测试

前言

前端测试主要重点在于与浏览器交互(Jsdom🔗、Headless Browser),且通常只与单一后端进行通信,而后端测试则面临截然不同的难题:分布式服务与状态

这篇文章介绍实战中我如何通过 Testcontainers🔗 创建 Docker 测试环境,从而实现完整的后端集成测试流程。

什么是 Testcontainers

基于 Docker 构建测试环境,测试程序通过真实的依赖运行
  • Docker🔗 是一种容器化技术,能够将应用程序及其依赖打包成轻量、可移植的镜像运行。
  • Testcontainers🔗 是构建在 Docker 之上的一套库,提供简洁的 API,让开发者在测试过程中以 Docker 容器的形式启动真实的依赖服务。

导入测试的背景

面对大量增删改查(CRUD)操作,我既希望保持最大的信心(测试与真实环境一致),又要容易构建,主要有以下几种方向:

  • Mock:模拟替代与其他服务的交互
    • 更偏向单元测试做法,无法完整测试跨服务行为。
  • In-memory:轻量服务替代品
  • 真实环境
    • 通过 Docker 启动一套相同环境,Testcontainers 提供完善的 API 与生态,可以在开发过程中动态创建和销毁服务。

实际案例

以 Go Gin 项目为例:go-gin-testing-todos🔗 使用 MongoDB 作为数据库,并根据 Testcontainers 文件🔗 文档来构建隔离的测试环境。

1. 封装服务启动逻辑

定义 SetupTestContainer 函数,处理一些样板代码,快速启动一个 Mongo 服务:

package testhelper
import (
"context"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func SetupTestContainer(ctx context.Context) (*mongodb.MongoDBContainer, *mongo.Database, error) {
// 启动最新的 MongoDB 容器
mongodbContainer, err := mongodb.Run(ctx, "mongo:latest")
if err != nil {
return nil, nil, err
}
// 获取容器动态分配的连接字符串
endpoint, err := mongodbContainer.ConnectionString(ctx)
if err != nil {
return nil, nil, err
}
// 连接到 MongoDB
client, err := mongo.Connect(ctx, options.Client().ApplyURI(endpoint))
if err != nil {
return nil, nil, err
}
return mongodbContainer, client.Database("test_db"), nil
}

2. 测试中启动服务

在实际的集成测试文件(如 todo_edit_test.go)中,在测试开始时调用 SetupTestContainer,并确保在测试结束后销毁容器:

func TestDeleteTodoIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
container, db, err := testhelper.SetupTestContainer(ctx)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := container.Terminate(ctx); err != nil {
t.Fatalf("failed to terminate container: %s", err)
}
}()
svc := service.NewTodoService(db)
ctrl := controller.NewTodoController(svc)
router := gin.Default()
router.DELETE("/todos/:id", ctrl.Delete)
router.GET("/todos/:id", ctrl.GetByID)
// Setup: 删除前注入要被删除的数据
todo := &model.Todo{
Title: "To be deleted",
Completed: false,
CreatedAt: time.Now(),
}
collection := db.Collection("todos")
res, err := collection.InsertOne(ctx, todo)
assert.NoError(t, err)
id := res.InsertedID.(primitive.ObjectID)
// Test: 测试 DELETE API
deleteRes := httptest.NewRecorder()
deleteReq, _ := http.NewRequest("DELETE", "/todos/"+id.Hex(), nil)
router.ServeHTTP(deleteRes, deleteReq)
assert.Equal(t, http.StatusOK, deleteRes.Code)
// Verify: 通过 GET API 验证结果
getRes := httptest.NewRecorder()
getReq, _ := http.NewRequest("GET", "/todos/"+id.Hex(), nil)
router.ServeHTTP(getRes, getReq)
assert.Equal(t, http.StatusNotFound, getRes.Code)
}

Testcontainers 带来的优势

  1. 环境隔离:每个测试都可以拥有独立的服务,避免数据污染。
  2. 动态连接:Testcontainers 会自动将 Docker 映射到随机端口,这意味着测试可以在 CI/CD 环境或多人开发机器上同时运行,而不会冲突。
  3. 基础设施即代码(IaC):测试环境定义直接写在测试代码中,无需额外维护共享测试数据库。

总结

Testcontainers 让跨服务集成测试变得非常容易,适合用于关键逻辑(如权限计算、数据删除与编辑)。我在实际开发中也明显感受到编写集成测试的好处:

  • 测试作为规格契约,帮助开发者快速进入状态
  • 测试擅长处理和记录复杂且难以覆盖的场景
  • 测试带来信心

完整代码可参考:go-gin-testing-todos🔗 项目。

延伸阅读