Go自動テスト - Testcontainersを用いたユニットテストの実践-
この記事はFinatext グループ Advent Calendar 2024の 8 日目の記事です。
はじめに
みなさんは Go アプリケーションのユニットテストで使う DB をモックしていますか。
それとも専用コンテナを立てていますでしょうか。
モックの柔軟性を利用したより複雑なケースを用意したり、専用コンテナで本番に近しいケースを用意したりと、時と場合によって使い分けていることが多いのではないかと思います。
本記事では、専用コンテナを利用したアプローチに焦点を当て、Testcontainers for Goを活用したユニットテスト基盤について詳しく解説します。
前提
Testcontainers for Go とは
自動テストのためのコンテナを簡単に作成できるライブラリです。
開発者は、コンテナの定義をプログラムでも Dockerfile でも行うことができます。
また、使い終わったら簡単にクリーンアップすることもできます。
以下の記事でも言及されていますが、ユニットテスト実行前にテスト用のコンテナを起動する docker compose up
のような準備が不要になるのは魅力的に感じるのではないでしょうか。
また、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 のみサポートされています。
私自身も、昨年のアドベントカレンダーで紹介させていただきました。
ory/dockertest も Testcontainers と同様に、コンテナの定義をプログラムでも Dockerfile でも行うことができるため、設定に関してはほとんど遜色ありません。
データストアもいくつかサポートされていて、Example が用意されています。
DATA-DOG/go-txdb
こちらも同じくユニットテストで使われるライブラリです。
主な特徴としては、このライブラリ経由でコネクションを作成することによって、テストケースごとにトランザクションを作成することができるで、テストの並列化ができるポイントが魅力です。
デフォルトのままだと、データベースコンテナはdocker compose up
のように自前で用意する必要がありますが、裏側で Testcontainers を使うように設定をすることもできるようです。
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
にユニットテストで必要なテストデータクエリを記述します。
func TestMain(m *testing.M)
で行う
ポイント 2: テスト用コンテナ生成を テスト用コンテナは任意の Dockerfile から作成します。
FROM postgres:15.4-alpine AS db
以下、TestMain
を用意します。
各ユニットテストが実行される前に、この関数内でテスト用コンテナ、DB コネクションを生成します。
// 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()
}
各ユニットテストは、以下のように記述します。
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 アドベントカレンダーで公開予定なのでぜひお楽しみに!
Discussion