Goの結合テストが遅い・不安定…その悩み、Testcontainers-goで解決しませんか?
この記事の目次
こんな “結合テストの悩み”、ありませんか?
- 古典的な Docker 運用(docker-compose 等・固定ポート)だと並列しづらく、逐次実行で遅い
- mock は本番相当から遠く、信頼しきれない(mockがグリーンでも本番で落ちる)
- ローカルとCIの環境差やポート競合でフレークが多く、再現性が低い
導入企業: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コンテナを設定するコード
```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月時点の情報です。