こんな “結合テストの悩み”、ありませんか? 古典的な Docker 運用(docker-compose 等・固定ポート)だと並列しづらく、逐次実行で遅い mock は本番相当から遠く、信頼しきれない(mockがグリーンでも本番で落ちる) ローカルとCIの環境差やポート競合でフレークが多く、再現性が低い そこで、 testcontainers-go 導入企業:Spotify、 Intel、Shopify、ElasticSearch、OpenTelemetry、Netflix、Uber 対応言語: Java、Go、.NET、Node.js、Python、Rust、Haskell、Ruby 対応ツール:PostgreSQL、MySQL、 ...etc TL;DR 時間がない方へ:結論 testcontainers-go はテストコード(Go)だけで完結。YAMLや手動のDocker操作なしで、起動・待機・破棄を一気通貫 mockはユニットには有効だが、統合の信頼性は落ちやすい。結合テストは実コンポーネントで検証しよう Docker Compose等でダミー環境を手作りするより、ライフサイクル管理・待機戦略・並列実行・CI統合に強い 根本をtestcontainersで、再現しにくいエラーはmockで、ハイブリッドが最も有効 Testcontainers Testcontainersとは、データベース、メッセージブローカー、ウェブブラウザ、あるいはDockerコンテナ内で実行可能なほぼあらゆるものの使い捨てで軽量なインスタンスを提供するオープンソースライブラリです。 テストコードから「必要な依存ミドルウェアのコンテナ」をオンデマンドで立ち上げ、準備完了まで待って、テスト終了時に自動破棄する仕組み(ライブラリ)です。mockでは埋まらない本番差分(DB・ネットワーク・シリアライズなど)を、実コンテナで素早く再現できます。 Testcontainers vs Gomock vs 古典的Dockerコンテナ 従来の手法と比較してみました。 観点 testcontainers-go Gomock 古典的dockerコンテナ 本番近さ・信頼性 実コンテナで高い。ネットワーク/シリアライズ差も拾える 低い。本番差を取りこぼしやすい 高いが、人手運用や待機ズレで事故が出やすい 速度・並列性 動的ポートで高並列。CI時間30〜70%短縮の報告(例: 14→5分) 単体は最速だが結合価値は低い 並列化が難しく逐次実行になりがちで遅い 待機・安定性 待機戦略で2〜5秒/サービスに収束。フレーク40〜80%減 待機不要だが現実差を検出しにくい 固定sleep/順序依存でフレーク多め セットアップ/破棄 テストコードで自動起動・自動クリーンアップ 低コスト(mock定義のみ) YAML管理・手動起動/停止・掃除が必要 デバッグ性 起動ログ/ヘルスチェックをテストから取得しやすい 再現しないバグが多く原因特定が難しい ログ収集や再現が手間 LK2でのTestcontainers-go実装 自分が参加しているPJ、LK(営業さん向けサービス)では、testcontainers-goをスモークテストとして導入 スモーク(Smoke test) ソフトウェアが起動し、基本的な機能が動作するかどうかを迅速に確認する予備的なテストです。 本格的なテストを行う前に、システムに深刻な不具合(ブロッキングバグ)がないことを確認し、テスト工程全体の効率を上げることを目的としています。電気製品で電源投入時に煙が出ないかを確認するテストに由来し、ソフトウェアにおいては「テストするに値するか」を判断するのに役立ちます LK2での実装 フォルダ構成 /testutils /testcontainers.go / testutils / testcontainers . go コンテナを設定するコード ```golang// testcontainers.go ```type MySQLContainer struct {Container testcontainers.ContainerDB *gorm.DBDSN string}func SetupMySQLContainer(ctx context.Context) (*MySQLContainer, error) {currentDir, err := os.Getwd()if err != nil {return nil, fmt.Errorf("failed to get current directory: %w", err)}backendDir := filepath.Join(currentDir, "..", "..", "..")// Load configuration from backend folderconfig, err := configs.LoadFromPath(backendDir)if err != nil {return nil, fmt.Errorf("failed to load config: %w", err)}req := testcontainers.ContainerRequest{Image: "mysql:8.0.32",Env: map[string]string{"MYSQL_ROOT_PASSWORD": config.TestDB.Password,"MYSQL_DATABASE": config.TestDB.Name,"MYSQL_USER": config.TestDB.User,"MYSQL_PASSWORD": config.TestDB.Password,},ExposedPorts: []string{config.TestDB.Port},WaitingFor: wait.ForLog("port: 3306 MySQL Community Server").WithStartupTimeout(90 * time.Second),}container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req,Started: true,})if err != nil {return nil, err}mappedPort, err := container.MappedPort(ctx, "3306")if err != nil {return nil, err}hostIP, err := container.Host(ctx)if err != nil {return nil, err}dsn := fmt.Sprintf("dsn作成")return &MySQLContainer{Container: container,DB: nil,DSN: dsn,}, nil}``` ``` golang // testcontainers.go ``` type MySQLContainer struct { Container testcontainers . Container DB * gorm . DB DSN string } func SetupMySQLContainer ( ctx context . Context ) ( * MySQLContainer , error ) { currentDir , err := os . Getwd () if err != nil { return nil , fmt . Errorf ( " failed to get current directory: %w " , err ) } backendDir := filepath . Join ( currentDir , " .. " , " .. " , " .. " ) // Load configuration from backend folder config , err := configs . LoadFromPath ( backendDir ) if err != nil { return nil , fmt . Errorf ( " failed to load config: %w " , err ) } req := testcontainers . ContainerRequest { Image : " mysql:8.0.32 " , Env : map [ string ] string { " MYSQL_ROOT_PASSWORD " : config . TestDB . Password , " MYSQL_DATABASE " : config . TestDB . Name , " MYSQL_USER " : config . TestDB . User , " MYSQL_PASSWORD " : config . TestDB . Password , }, ExposedPorts : [] string { config . TestDB . Port }, WaitingFor : wait . ForLog ( " port: 3306 MySQL Community Server " ). WithStartupTimeout ( 90 * time . Second ), } container , err := testcontainers . GenericContainer ( ctx , testcontainers . GenericContainerRequest { ContainerRequest : req , Started : true , }) if err != nil { return nil , err } mappedPort , err := container . MappedPort ( ctx , " 3306 " ) if err != nil { return nil , err } hostIP , err := container . Host ( ctx ) if err != nil { return nil , err } dsn := fmt . Sprintf ( " dsn作成 " ) return & MySQLContainer { Container : container , DB : nil , DSN : dsn , }, nil } ``` テストコード ```golang```var (mysqlContainer *testutils.MySQLContainermodel models.CallStatuserr error)type expectedResult struct {statusCode intisErr bool}func TestMain(m *testing.M) {ctx := context.Background()// コンテナの立ち上げmysqlContainer, err = testutils.SetupMySQLContainer(ctx)if err! = nil {log.Fatalf("Failed to setup MySQL container: %v", err)}// DBのマイグレーションerr = mysqlContainer.MigrateDB(&model)if err! = nil {log.Fatalf("Failed to initialize DB: %v", err)}defer func() {err = mysqlContainer.Terminate(ctx)if err != nil {log.Fatalf("Failed to terminate MySQL container: %v", err)}}()m.Run()}func NewTestEcho(db *gorm.DB) *echo.Echo {repo := callstatusInfra.NewRepository(db)usecase := callstatusUsecase.NewUseCase(repo)callstatusController := controller.NewCallStatusController(usecase)e := restecho.CreateMux()controller.InitRouting(e, controller.Controllers{CallStatusController: callstatusController,})return e}func Test_GetAllCallStatuses(t *testing.T) {testCases := []struct {name stringseed []*models.CallStatustable testutils.WithTypeexpected expectedResult}{{name: "【200】架電ステータス一覧取得",table: testutils.WithSeed,seed: callstatus.GenerateRandomSeeds(5),expected: expectedResult{statusCode: http.StatusOK, isErr: false},},{name: "【200】架電ステータス(データなし)",table: testutils.WithEmpty,seed: callstatus.GenerateRandomSeeds(0),expected: expectedResult{statusCode: http.StatusOK, isErr: false},},{name: "【200】架電ステータス(1個のみ)",table: testutils.WithSeed,seed: callstatus.GenerateRandomSeeds(1),expected: expectedResult{statusCode: http.StatusOK, isErr: false},},{name: "【500】DB接続エラー",table: testutils.WithNoTable,seed: nil,expected: expectedResult{statusCode: http.StatusInternalServerError, isErr: true},},}for _, tc := range testCases {t.Run(tc.name, func(t *testing.T) {db := testutils.CreateNewDB(t, mysqlContainer, tc.table, model, tc.seed)e := NewTestEcho(db)recorder := httptest.NewRecorder()request := callstatus.NewJSONRequest(t, http.MethodGet, URL, nil)e.ServeHTTP(recorder, request)require.Equal(t, "application/json", recorder.Header().Get("Content-Type"))require.Equal(t, tc.expected.statusCode, recorder.Code)if tc.expected.isErr {// ★ JSONデータの確認var responseData map[string]interface{}err := json.Unmarshal(recorder.Body.Bytes(), &responseData)require.NoError(t, err)if len(responseData) > 0 {require.Contains(t, responseData, "code")require.Contains(t, responseData, "message")}} else {// ★ JSONデータの確認var responseData []map[string]interface{}err := json.Unmarshal(recorder.Body.Bytes(), &responseData)require.NoError(t, err)// ★ データ件数の確認require.Equal(t, len(tc.seed), len(responseData))if len(responseData) > 0 {require.Contains(t, responseData[0], "id")require.Contains(t, responseData[0], "name")require.Contains(t, responseData[0], "order")require.Contains(t, responseData[0], "createdAt")require.Contains(t, responseData[0], "updatedAt")require.Contains(t, responseData[0], "deletedAt")}}})}}``` ``` golang ``` var ( mysqlContainer * testutils . MySQLContainer model models . CallStatus err error ) type expectedResult struct { statusCode int isErr bool } func TestMain ( m * testing . M ) { ctx := context . Background () // コンテナの立ち上げ mysqlContainer , err = testutils . SetupMySQLContainer ( ctx ) if err ! = nil { log . Fatalf ( " Failed to setup MySQL container: %v " , err ) } // DBのマイグレーション err = mysqlContainer . MigrateDB ( & model ) if err ! = nil { log . Fatalf ( " Failed to initialize DB: %v " , err ) } defer func () { err = mysqlContainer . Terminate ( ctx ) if err != nil { log . Fatalf ( " Failed to terminate MySQL container: %v " , err ) } }() m . Run () } func NewTestEcho ( db * gorm . DB ) * echo . Echo { repo := callstatusInfra . NewRepository ( db ) usecase := callstatusUsecase . NewUseCase ( repo ) callstatusController := controller . NewCallStatusController ( usecase ) e := restecho . CreateMux () controller . InitRouting ( e , controller . Controllers { CallStatusController : callstatusController , }) return e } func Test_GetAllCallStatuses ( t * testing . T ) { testCases := [] struct { name string seed [] * models . CallStatus table testutils . WithType expected expectedResult }{ { name : " 【200】架電ステータス一覧取得 " , table : testutils . WithSeed , seed : callstatus . GenerateRandomSeeds ( 5 ), expected : expectedResult { statusCode : http . StatusOK , isErr : false }, }, { name : " 【200】架電ステータス(データなし) " , table : testutils . WithEmpty , seed : callstatus . GenerateRandomSeeds ( 0 ), expected : expectedResult { statusCode : http . StatusOK , isErr : false }, }, { name : " 【200】架電ステータス(1個のみ) " , table : testutils . WithSeed , seed : callstatus . GenerateRandomSeeds ( 1 ), expected : expectedResult { statusCode : http . StatusOK , isErr : false }, }, { name : " 【500】DB接続エラー " , table : testutils . WithNoTable , seed : nil , expected : expectedResult { statusCode : http . StatusInternalServerError , isErr : true }, }, } for _ , tc := range testCases { t . Run ( tc . name , func ( t * testing . T ) { db := testutils . CreateNewDB ( t , mysqlContainer , tc . table , model , tc . seed ) e := NewTestEcho ( db ) recorder := httptest . NewRecorder () request := callstatus . NewJSONRequest ( t , http . MethodGet , URL , nil ) e . ServeHTTP ( recorder , request ) require . Equal ( t , " application/json " , recorder . Header (). Get ( " Content-Type " )) require . Equal ( t , tc . expected . statusCode , recorder . Code ) if tc . expected . isErr { // ★ JSONデータの確認 var responseData map [ string ] interface {} err := json . Unmarshal ( recorder . Body . Bytes (), & responseData ) require . NoError ( t , err ) if len ( responseData ) > 0 { require . Contains ( t , responseData , " code " ) require . Contains ( t , responseData , " message " ) } } else { // ★ JSONデータの確認 var responseData [] map [ string ] interface {} err := json . Unmarshal ( recorder . Body . Bytes (), & responseData ) require . NoError ( t , err ) // ★ データ件数の確認 require . Equal ( t , len ( tc . seed ), len ( responseData )) if len ( responseData ) > 0 { require . Contains ( t , responseData [ 0 ], " id " ) require . Contains ( t , responseData [ 0 ], " name " ) require . Contains ( t , responseData [ 0 ], " order " ) require . Contains ( t , responseData [ 0 ], " createdAt " ) require . Contains ( t , responseData [ 0 ], " updatedAt " ) require . Contains ( t , responseData [ 0 ], " deletedAt " ) } } }) } } ``` まとめ Testcontainersは、mockの速さと本番近さの「いいとこ取り」を、テストコードからの動的起動・確実な待機・自動破棄で実現する実践的な選択肢です。 ユニットはMockで素早く、結合はTestcontainersで確かに、長寿命の手動環境やE2EはCompose等に限定するのが合理的な使い分けです。 Docker必須・初回pullなどの前提はあるものの、並列実行と待機戦略を組み込めば、CI時間短縮、フレーク減、ローカル=CIの再現性向上が期待できます。 Happy coding!!