Backend Integration Testing using Testcontainers

使用 Testcontainers 實現後端整合測試

前言

前端測試主要重點在與瀏覽器打交道(Jsdom🔗、Headless Browser)且通常只與單一後端進行溝通,而後端測試則是面臨截然不同的難題:分散服務與狀態

這篇文章介紹實戰上我如何透過 Testcontainers🔗 創建 Docker 測試環境來達成完整的後端整合測試流程。

什麼是 Testcontainers

基於 Docker 建構測試環境,測試程式透過真實的依賴
  • Docker🔗 是一種容器化技術,能夠將應用程式及其依賴打包成輕量、可攜帶的映像檔執行。
  • Testcontainers🔗 是建立在 Docker 之上的一套函式庫,提供簡潔的 API 讓開發者在測試過程中,以 Docker 容器的形式啟動真實的依賴服務。

導入測試的背景

面對大量增刪查改(CRUD)的操作,我既希望保持最大的信心(測試與真實環境一致)又容易建構,主要有以下幾種方向:

  • Mock:模擬替換掉與其他服務的互動
    • 更偏向單元測試作法,無法完整測試橫跨服務的行為。
  • In-memory:輕量服務替代品
    • 例如 MongoDB 就存在官方的 mtest🔗drivertest🔗,但仍存在 API 不穩定與實踐可能不同的問題。
  • 真實環境
    • 透過啟動一份相同的環境透過 docker,TestContainers 有完善的 API 與生態可以在開發中即時的操作創建銷毀服務

實際案例

以 Go Gin 專案為範例: go-gin-testing-todos🔗 使用 MongoDB 作為資料庫,並根據 Testcontainers 文件🔗 來建立隔離的測試環境。

1. 封裝服務啟動邏輯

定義 SetupTestContainer 函式處理一些 Boilerplate Code 快速啟動一個 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):測試環境定義就寫在測試代碼中,不需要額外維護一個共用的測試資料庫。

總結

Testcontainer 讓跨服務整合測試變得非常容易,適合添加於關鍵的邏輯上(重大授權計算、刪除編輯資料),我自己在開發中也感受到撰寫整合測試的好處:

  • 測試作為規格合約,幫助開發者進入狀況
  • 測試善於處理與紀錄繁雜且難測的情境
  • 測試帶來信心

完整代碼可以參考:go-gin-testing-todos🔗專案。

延伸閱讀