2025/11/04

テクノロジー

Goの結合テストが遅い・不安定…その悩み、Testcontainers-goで解決しませんか?

この記事の目次

    こんな “結合テストの悩み”、ありませんか?

    • 古典的な 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

    時間がない方へ:結論
    1. testcontainers-go はテストコード(Go)だけで完結。YAMLや手動のDocker操作なしで、起動・待機・破棄を一気通貫
    2. mockはユニットには有効だが、統合の信頼性は落ちやすい。結合テストは実コンポーネントで検証しよう
    3. Docker Compose等でダミー環境を手作りするより、ライフサイクル管理・待機戦略・並列実行・CI統合に強い
    4. 根本をtestcontainersで、再現しにくいエラーはmockで、ハイブリッドが最も有効

    Testcontainers

    Testcontainersとは、データベース、メッセージブローカー、ウェブブラウザ、あるいはDockerコンテナ内で実行可能なほぼあらゆるものの使い捨てで軽量なインスタンスを提供するオープンソースライブラリです。

    テストコードから「必要な依存ミドルウェアのコンテナ」をオンデマンドで立ち上げ、準備完了まで待って、テスト終了時に自動破棄する仕組み(ライブラリ)です。mockでは埋まらない本番差分(DB・ネットワーク・シリアライズなど)を、実コンテナで素早く再現できます。

    Testcontainers vs Gomock vs 古典的Dockerコンテナ

    従来の手法と比較してみました。

    観点testcontainers-goGomock古典的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

    コンテナを設定するコード

    ```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.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!!

    ※本記事は2025年11月時点の情報です。

    著者:マイナビエンジニアブログ編集部