メインコンテンツまでスキップ

Testcontainersを活用したRepository層の効率的なテスト方法

· 約9分
Takato Sasagawa

こんにちは、笹川です。バックエンドシステムのRepository層のテストでTestcontainersを活用する方法について紹介します。

この記事では、GolangのプロジェクトでTestcontainersgolang-migrateを活用し、PostgreSQLを利用したRepository層のテストを効率的に行う方法を解説します。Mockや統一されたDocker環境を使った従来の方法と比較し、より柔軟で効率的なテスト手法を共有します。

以下の内容をカバーします:

  1. Testcontainersを使ったPostgreSQLコンテナの起動
  2. golang-migrateを用いたマイグレーションファイルの利用
  3. MockDocker Composeと比較したメリット
  4. 実際のRepository層のテストコードの例

Testcontainersとは

Testcontainersは、テストのためにコンテナ化された依存関係(例: データベース、メッセージキューなど)を簡単に起動できるライブラリです。これにより、テストごとにクリーンな環境を提供し、実際の依存関係に近い環境でテストを実行可能です。特に、本番環境で使用するコンポーネントとの整合性が求められる場合に非常に有用です。

ディレクトリ構成

プロジェクトのディレクトリ構成は以下の通りです:

.
├── go.mod
├── go.sum
├── internal
│ ├── domain
│ │ ├── model
│ │ │ └── bank.go
│ │ └── repository
│ │ ├── bank.go
│ │ └── tx.go
│ ├── infrastracture
│ │ └── postgres
│ │ ├── bank.go
│ │ ├── bank_test.go
│ │ ├── db.go
│ │ ├── testdb.go
│ │ └── tx.go
│ └── usecase
│ ├── bank.go
│ └── bank_test.go
└── migrate
└── postgres
├── 000001_create_bank_accounts_table.down.sql
├── 000001_create_bank_accounts_table.up.sql
├── 000002_create_transactions_table.down.sql
└── 000002_create_transactions_table.up.sql

従来の方法との比較

Mockを使用する方法

Mockを使用する方法はユニットテストとして有効ですが、以下の課題があります:

  • 実際のデータベース挙動を再現できない。
  • 複雑なクエリやトランザクションをMockで再現するのが困難。
  • 本番環境での予期せぬエラーを検知しにくい。

一方で、Testcontainersを使うことで本番と同じデータベース環境をテスト時に再現でき、信頼性が向上します。

Docker Composeを使用する方法

Docker Composeで統一されたデータベース環境を構築しCIで利用する方法は広く採用されていますが、以下の課題があります:

  • CI/CD環境での管理が煩雑になりがち。
  • テスト間でのデータのクリーンアップが手間。
  • データ不整合が発生しやすく、並列実行化が難しい。

Testcontainersはテストごとに独立したコンテナを起動するため、テストデータの影響が残らず、テストの独立性を確保できます。

この図のように、各テストは独立したデータベース環境で実行され、相互の影響を排除できます。 またコード内に起動や停止の処理を記述できるため、テストの環境構築が容易です。

PostgreSQLコンテナのセットアップ

testcontainers-goを使用して、テスト用のPostgreSQLコンテナを起動するコードを以下に示します。

このコードでは、testcontainers-goのPostgreSQLモジュールを使用してPostgreSQLコンテナを起動し、sqlxのDBインスタンスとコンテナの終了処理を返します。 使用するimageの指定や、データベース名、ユーザ名、パスワードの設定、起動完了までの待機時間などを指定できます。

指定している他にもWithInitScriptsを使って初期化スクリプトを実行や、WithEnvを使って環境変数の設定などが可能です。

testdb.go:

func NewTestContainerDBConnection(ctx context.Context, t *testing.T) (*sqlx.DB, func()) {

pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("test-db"),
postgres.WithUsername("postgres"),
postgres.WithPassword("postgres"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).WithStartupTimeout(5*time.Second)),
)
if err != nil {
t.Fatalf("Failed to start postgres container: %v", err)
}
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("Failed to get connection string: %v", err)
}

if err := migrateDB(connStr); err != nil {
t.Fatalf("Failed to migrate database: %v", err)
}

db, err := sqlx.Connect("postgres", connStr)
if err != nil {
t.Fatalf("Failed to connect to database: %v", err)
}

return db, func() {
if err := db.Close(); err != nil {
t.Fatalf("Failed to close database connection: %v", err)
}
if err := pgContainer.Terminate(ctx); err != nil {
t.Fatalf("Failed to terminate container: %v", err)
}
}
}

データベースのマイグレーション

Testcontainersで作成したデータベースは、テーブルのスキーマが初期状態のままです。テストデータベースのスキーマを管理するために、golang-migrateを使用します。

すでにgolang-migrateを使ってマイグレーションファイルを管理している場合、テストデータベースにも同じマイグレーションファイルを適用することで、本番環境と同じスキーマを再現できます。

testdb.go:

func migrateDB(connStr string) error {
_, filename, _, ok := runtime.Caller(0)
if !ok {
return fmt.Errorf("failed to get current file")
}

migrationPath := filepath.Join(filepath.Dir(filename), "../../../migrate/postgres")
absMigrationPath, err := filepath.Abs(migrationPath)
if err != nil {
return fmt.Errorf("failed to get absolute migration path: %s", err)
}

m, err := migrate.New("file://"+absMigrationPath, connStr)
if err != nil {
return fmt.Errorf("failed to create migration instance: %s", err)
}

if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("failed to apply migration: %s", err)
}

return nil
}

golang-migrateを使うメリット

golang-migrateを本番データベースのマイグレーションとテストデータベースのマイグレーションの両方に利用することで、以下のメリットがあります:

  1. 本番環境との一貫性: 本番環境で使用しているマイグレーションファイルをそのまま利用できるため、テストデータベースの準備が確実かつ効率的です。
  2. マイグレーションファイル自体のテスト: テストプロセスの中でマイグレーションファイルを実行することで、ファイルに誤りがないか検証できます。これにより、スキーマ変更の品質保証が向上します。

テストコードの例

次に、PostgreSQLを利用するRepository層のテスト例を示します。

func TestBankRepository_CreateBankAccount(t *testing.T) {
t.Parallel()
ctx := context.Background()

db, tearDown := NewTestContainerDBConnection(ctx, t)
defer tearDown()

repo := NewBankRepository(db)

tests := []struct {
name string
account *model.BankAccount
wantErr bool
}{
{
name: "Valid account creation",
account: &model.BankAccount{
AccountHolderName: "Alice",
Balance: 500.0,
},
wantErr: false,
},
{
name: "Negative balance",
account: &model.BankAccount{
AccountHolderName: "Bob",
Balance: -100.0,
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := repo.CreateBankAccount(ctx, tt.account)
if (err != nil) != tt.wantErr {
t.Errorf("CreateBankAccount() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

各テストはNewTestContainerDBConnectionを利用することで、テスト時に独立したデータベース環境を構築し、テスト結果が外部環境に依存しないようにします。 テスト終了時にtearDown関数を呼び出すことで、コンテナを削除しリソースを解放します。

テストケースごとにDBを分ける必要がない場合は、TestMain関数を使ってDBのセットアップとティアダウンを行うこともできます。

おわりに

Testcontainersgolang-migrateを組み合わせることで、MockやDocker Composeに代わる柔軟で効率的なテスト手法を提供できます。このアプローチは特に、複雑なビジネスロジックやトランザクションを含むバックエンドシステムで信頼性を向上させる上で有効です。

またTestcontainersは、PostgreSQLだけでなく、MySQL、MongoDB、Redisなどのデータベースやメッセージキューなど、さまざまなコンテナをサポートしています。ぜひ、プロジェクトに導入してみてください。