前言
前端測試主要重點在與瀏覽器打交道(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 展露的優點
- 環境隔離:每個測試都可以擁有自己獨立的服務,避免資料污染。
- 動態連接:Testcontainers 會自動映射 Docker 到隨機埠,這意味著測試可以在 CI/CD 環境或多人開發機器上同時運行,而不會發生衝突。
- 基礎設施即代碼 (IaC):測試環境定義就寫在測試代碼中,不需要額外維護一個共用的測試資料庫。
總結
Testcontainer 讓跨服務整合測試變得非常容易,適合添加於關鍵的邏輯上(重大授權計算、刪除編輯資料),我自己在開發中也感受到撰寫整合測試的好處:
- 測試作為規格合約,幫助開發者進入狀況
- 測試善於處理與紀錄繁雜且難測的情境
- 測試帶來信心
完整代碼可以參考:go-gin-testing-todos專案。