🐁

Go自動テスト - Testcontainersを用いたユニットテストの実践-

2024/12/08に公開

この記事はFinatext グループ Advent Calendar 2024の 8 日目の記事です。

はじめに

みなさんは Go アプリケーションのユニットテストで使う DB をモックしていますか。
それとも専用コンテナを立てていますでしょうか。

モックの柔軟性を利用したより複雑なケースを用意したり、専用コンテナで本番に近しいケースを用意したりと、時と場合によって使い分けていることが多いのではないかと思います。

本記事では、専用コンテナを利用したアプローチに焦点を当て、Testcontainers for Goを活用したユニットテスト基盤について詳しく解説します。

前提

Testcontainers for Go  とは

自動テストのためのコンテナを簡単に作成できるライブラリです。

https://github.com/testcontainers/testcontainers-go

開発者は、コンテナの定義をプログラムでも Dockerfile でも行うことができます。
また、使い終わったら簡単にクリーンアップすることもできます。

以下の記事でも言及されていますが、ユニットテスト実行前にテスト用のコンテナを起動する docker compose upのような準備が不要になるのは魅力的に感じるのではないでしょうか。

https://future-architect.github.io/articles/20240409a/

また、Go だけでなく他言語のサポートがあるのと、提供されている Modules を使用することで、DynamoDB や Redis といったデータストアも利用できるそうです。
(データストアのテスト環境を Testcontainers に集約する使い方ができるのも魅力です)

著名な企業/プロジェクトでも導入されているようです。

Elastic - Testing of the APM Server, and E2E testing for Beats
Telegraf - Integration testing the plugin-driven server agent for collecting & > reporting metrics
Intel - Reference implementation design E2E testing for microservice-based > solutions
OpenTelemetry - Integration testing of the OpenTelemetry Collector receivers

比較されるライブラリ

ory/dockertest

似たライブラリに、ory/dockertest があります。
こちらは、Go のみサポートされています。

https://github.com/ory/dockertest

私自身も、昨年のアドベントカレンダーで紹介させていただきました。

https://qiita.com/uh-zz/items/575232437f885cc4730a

ory/dockertest も Testcontainers と同様に、コンテナの定義をプログラムでも Dockerfile でも行うことができるため、設定に関してはほとんど遜色ありません。

データストアもいくつかサポートされていて、Example が用意されています。

https://github.com/ory/dockertest/tree/v3/examples

DATA-DOG/go-txdb

こちらも同じくユニットテストで使われるライブラリです。

https://github.com/DATA-DOG/go-txdb

主な特徴としては、このライブラリ経由でコネクションを作成することによって、テストケースごとにトランザクションを作成することができるで、テストの並列化ができるポイントが魅力です。

デフォルトのままだと、データベースコンテナはdocker compose upのように自前で用意する必要がありますが、裏側で Testcontainers を使うように設定をすることもできるようです。

https://github.com/DATA-DOG/go-txdb?tab=readme-ov-file#testing

Testcontainers  へのモチベーション

上記踏まえた上で、Testcontainers を使う理由は以下のとおりです。

  • Testcontainers でサポートされているデータストア用 Modules が豊富なため、MySQL, PostgreSQL 以外にプロダクトで使うデータストアにも似た構成で用意できる
    • 現在サポートされていないデータストアに対しても、Modules の形でユーザーが拡張できる余地もある
  • Testcontainers 単体でスナップショット/リストア機能があるため、ユニットテスト間のデータ依存を排除できる

導入

ディレクトリ構成

便宜的にディレクトリ、ファイルを省略してます。

.
├── cmd
│   └── server
├── docker
│   └── Dockerfile
├── internal
│   └── usecase
│       ├── usecase_test.go
│       └── get_example_profile_test.go
├── migrations
│   ├── ddl
│   │   └── create_example_profile.sql
│   └── dml
│       └── insert_example_profiles.sql
└── testdata
    └── migrations
        └── get_example_profile_test.sql

ポイント 1: ユニットテストで使うテストデータ

ユニットテストファイルの get_example_profile_test.go と同名のファイルを testdata/migrationsに用意していることです。
get_example_profile_test.sqlにユニットテストで必要なテストデータクエリを記述します。

ポイント 2: テスト用コンテナ生成を func TestMain(m *testing.M)で行う

テスト用コンテナは任意の Dockerfile から作成します。

Dockerfile
FROM postgres:15.4-alpine AS db

以下、TestMainを用意します。
各ユニットテストが実行される前に、この関数内でテスト用コンテナ、DB コネクションを生成します。

usecase_test.go
// github.com/jackc/pgx v5.5.5
// github.com/testcontainers/testcontainers-go v0.33.1
// github.com/testcontainers/testcontainers-go/modules/postgres v0.32.0
package usecase

// NOTE: DBコネクション
// 各ユニットテストはこのコネクションを使います
var conn *pgxpool.Pool

func TestMain(m *testing.M) {
	os.Exit(testMain(m))
}

func testMain(m *testing.M) (result int) {
	ctx := context.Background()

	// NOTE: Dockerfileのイメージを元にコンテナ作成
	req := testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			FromDockerfile: testcontainers.FromDockerfile{
				Dockerfile: "./docker/Dockerfile",
				Repo:       "example-test-db",
				BuildOptionsModifier: func(buildOptions *types.ImageBuildOptions) {
					buildOptions.Target = "db"
				},
			},
			ExposedPorts: []string{"5432/tcp"},
			Env: map[string]string{
				"POSTGRES_USER":     "",
				"POSTGRES_PASSWORD": "",
				"POSTGRES_DB":       "",
				"TZ":                "",
			},
			Files: func() []testcontainers.ContainerFile {
				var files []testcontainers.ContainerFile
				// NOTE: DDL 実行
				for _, file := range ddlFiles {
					files = append(files, testcontainers.ContainerFile{
						HostFilePath:      file,
						ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(file),
					})
				}

				return files
			}(),
		},
		Started: true,
	}

	// NOTE: コンテナ生成
	container, err := testcontainers.GenericContainer(ctx, req)
	if err != nil {
		return 1
	}

	// NOTE: 生成したコンテナをPostgreSQLドライバーでラップする
	globalPostgresContainer, err = postgres.RunContainer(ctx,
		postgres.WithDatabase(""),
		postgres.WithUsername(""),
		postgres.WithPassword(""),
	)
	if err != nil {
		return 1
	}
	globalPostgresContainer.Container = container

	// NOTE: テストコンテナのスナップショットを取得する
	// 各テストケースでリストアした場合は、このポイントに戻る
	_ = globalPostgresContainer.Snapshot(ctx, postgres.WithSnapshotName("test-db-snapshot"))

	defer func() {
		if err := globalPostgresContainer.Terminate(ctx); err != nil {
			result = 1
		}
	}()

	connStr, err := globalPostgresContainer.ConnectionString(ctx)
	if err != nil {
		return 1
	}

	config, err := pgxpool.ParseConfig(connStr)
	if err != nil {
		return 1
	}

	conn, err = pgxpool.NewWithConfig(ctx, config)
	if err != nil {
		return 1
	}

	// NOTE: ユニットテスト実行
	return m.Run()
}

各ユニットテストは、以下のように記述します。

get_example_profile_test.go
func Test_getExampleProfile(t *testing.T) {
	ctx := context.Background()

        // NOTE: このユニットテスト関数が完了したときに、スナップショットを取得した状態に戻します。
        // つまり、このユニットテストで実行したSQLクエリをすべてリセットして、次のユニットテストへ移ります。
	t.Cleanup(func() {
		err := globalPostgresContainer.Restore(ctx)
		if err != nil {
			t.Errorf("failed to restore: %+v\n", err)
		}
	})

        // NOTE: setup 関数の中で、このユニットテストファイルと同名のファイルを `testdata/migration` から探して、マイグレーションを行います。
        // ここでマイグレーションしても、Cleanup でリストアが実行されるため、ほかのユニットテストに依存しません。
	if err := setup(t); err != nil {
		t.Errorf("failed to setup: %+v\n", err)
	}

        // 以下、ユニットテストを記述
}

課題

  • 裏返しになってしまいますが、リストアに時間がかかってしまうのが懸念です。
    • 回避策としてビルドタグを用いて並列化を実践していますが、恒久対応になっていないのがイマイチなポイントです。

おわりに

テスト用コンテナを用意する方法を、具体的なコードを用いて説明しました。
この仕組みを利用して、API テスト基盤に応用する手法も、Go アドベントカレンダーで公開予定なのでぜひお楽しみに!

https://qiita.com/advent-calendar/2024/go

Finatext Tech Blog

Discussion