はじめに
この記事は enechain Advent Calendar 2023 の11日目の記事です。 本日はenechainのソフトウェアエンジニアの @eji が担当します。
私が所属するチームでは、今月の頭に社内向けの新サービスを公開しました。 このサービスのバックエンドはGoで作っており、DBスキーマ管理にAtlas、ORMにBunを使っています。
今回は、このサービス開発で私たちが採用したテスト方針と、それを実現するためのテスト環境構築方法について紹介します。 Atlasを使ったテスト環境構築についての記事をあまり見かけなかったため、この記事がAtlasを新たに採用する方の助けになると幸いです。
サービスの内部構成
はじめに、このサービスの内部構成について紹介します。
このサービスは以下のようなに4層で構成されています。
アダプター層
アダプター層はAPIリクエストをアプリケーション層のユースケースに渡し、結果を変換して返しています。 この層では認証・認可、多言語対応、エラーログ出力なども行っています。
アプリケーション層に依存しています。
アプリケーション層
アプリケーション層はドメイン層のモデルやサービスを利用してユースケースを実装しています。 この層では、外部サービスとやりとりするためのインタフェースを定義しており、インタフェース層で実体を実装しています。
ドメイン層に依存しています。
インフラ層
インフラ層はアプリケーション層で定義したインタフェースの実体を実装しています。DBや外部APIとの通信処理はここに実装しています。 Bunはこの部分で利用しています。
全ての層に依存しています。
ドメイン層
ドメイン層はビジネスモデルを定義しています。主にバリデーションやエンティティの生成処理を実装しています。 ドメイン層は他のいずれの層にも依存していません。
テスト方針
プロジェクトの最初からテスト方針を明示していなかったのですが、テストのメンテナンスコストを抑えつつリグレッションを 検知できるようにテストを作っていった結果、以下のようなテスト方針になっています。
アダプター、アプリケーション、インフラ層のテスト
必要なら書く
これらの層のテストは後述するAPIテストでほぼカーバーできるため、基本的に書きません。 ただし、APIテストでモックしているモジュール(外部API通信モジュールなど)は必ずテストを書くようにしています。
これにより改修時のテスト修正をAPIテストに留められ、テストのメンテナンスコストが抑えられます。
ドメイン層のテスト
必ず書く
ドメイン層はエンティティの生成処理やバリデーションなどを実装しています。 基本機能はAPIテストでカバーできるのですが、バリデーションテストはテストパターンが多く APIテストではカバーしきれないため必ずテストを書くようにしています。
基本的に引数のみに依存する関数になっており、テストは書きやすいです。 testingライブラリを使ったテーブル駆動テストでテストを書いています。
APIテスト
必ず書く
外部APIの呼び出し以外は基本的にモックは使わず、ブラックボックステストでAPIテストを書いています。 以下のようなメリットがあるため、必ずテストを書くようにしています。
APIの利用方法や他のAPIへの影響が分かりやすくなる
具体的なAPI呼び出しをテストに書くことにより、APIの利用方法が分かりやすくなります。 また、API呼び出しが他のAPIの呼び出し結果にどのように影響が出るかをテストに書くことにより、 APIの仕様変更による影響範囲が分かりやすくなります。
リファクタリングしやすくなる
モジュールに対して機能を追加したりインタフェースを変えると、依存関係が多い場合リグレッションが発生しやすくなります。
全てのAPIに対してブラックボックステストを書くことによりリグレッションが検知しやすくなり、 安心してリファクタリングしやすくなります。
上記のように、APIテストはメリットが多くプロジェクトの早い段階からテストを書くことによりリグレッションによる手戻りが防げ、 開発スピードが高く保てます。
しかし、APIテストはDBの用意やAPIサーバーの起動など準備が大変なため、テスト環境が整っていないとAPIテストを書くハードルが高くなります。 そのため、開発の早い段階でAPIテストの環境を整えておくことで、APIテストを書くハードルが下げられます。
以下でこのサービスのAPIテストの環境構築方法、とりわけ一番手間のかかるDBの用意について紹介します。
APIテスト環境の構成
最終的には以下のような構成になりました。
DB
dockertestを使ってテストDBを立ち上げています。 テスト実施者はDockerさえ動いていれば、テスト環境の構築については意識する必要がありません。
また、テストDBは都度まっさらな状態で立ち上がるため、前回のテストの影響を受けることなくテストが実行できます。 スキーママイグレーションは AtlasのGo SDKを利用しています。
さらに、テスト間の干渉を防ぐため、go-txdbを使いテストごとにデータがクリアしています。
APIサーバー
このサービスのAPIはconnect-goを使っています。 connect-goはhttptestを使うことで実際にサーバーを立ち上げてテストすることができます。 ポートは都度空いている番号が割り当てられるため、テスト間の干渉を防ぐことができます。
構築手順
dockertestを使ってパッケージ毎にテストDBを立ち上げる
まずはdockertestを使ってパッケージごとにテストDBを立ち上げられるようにします。
以下のような関数を作成します。
package testhelper ... func RunTestDb(ctx context.Context) (func(), error) { // dockertestがDockerに接続するためのクライアントプールを作成 pool, err := dockertest.NewPool("") if err != nil { return nil, err } // Dockerに接続できているか確認 err = pool.Client.Ping() if err != nil { return nil, err } // DockerでDBを起動 resource, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "postgres", Tag: "15.2", Env: []string{ fmt.Sprintf("POSTGRES_PASSWORD=%s", "postgres"), fmt.Sprintf("POSTGRES_USER=%s", "postgres"), fmt.Sprintf("POSTGRES_DB=%s", "test_db"), }, }) if err != nil { return nil, err } // Docker内のDBがオープンしているポート番号を指定して、ホスト側に割り当てられているホスト名とポート番号を取得する hostAndPort := resource.GetHostPort("5432/tcp") // PostgreSQLのデフォルトポート番号 dbUrl := fmt.Sprintf("postgres://postgres:postgres@%s/test_db", hostAndPort) // 一定時間過ぎた場合に自動でコンテナを止めるようにする if err := resource.Expire(120); err != nil { return nil, err } // Dockerで立ち上げたDBに接続する var db *sql.DB if err = pool.Retry(func() error { d, err := sql.Open("postgres", dbUrl) if err != nil { return err } db = d return d.Ping() }); err != nil { return nil, err } return func() { if err := db.Close(); err != nil { panic(err) } if err := pool.Purge(resource); err != nil { panic(err) } }, nil }
サンプルのため、Dockerを立ち上げる際の細かい設定やエラー処理は適当です。
この関数を TestMain
で呼び出すことで、テスト実行時にテストDBが立ち上がり、テスト終了時にテストDBが削除されるようになります。
package sample_api_test ... func TestMain(m *testing.M) { closer, err := testhelper.RunTestDb() if err != nil { panic(err) } defer closer() m.Run() }
AtlasのGo SDKを使いDBスキーマを更新
立ち上げたDBのスキーマはまっさらな状態なため、テスト実行前にスキーマを更新する必要があります。 AtlasのGo SDKを使い、スキーマの更新を行います。
以下のようなコードを RunTestDb
関数に追加します。
// Atlasのクライアントを作成 client, err := atlasexec.NewClient("", "atlas") if err != nil { return nil, err } // スキーママイグレーションを適用する if _, err := client.Apply(ctx, &atlasexec.ApplyParams{ DirURL: "file://migration_dir", URL: dbUrl, }); err != nil { return nil, err }
追加したものが以下になります。これにより、テスト実行前にスキーママイグレーションが適用されます。
package testhelper ... func RunTestDb(ctx context.Context) (func(), error) { // dockertestがDockerに接続するためのクライアントプールを作成 pool, err := dockertest.NewPool("") if err != nil { return nil, err } // Dockerに接続できているか確認 err = pool.Client.Ping() if err != nil { return nil, err } // DockerでDBを起動 resource, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "postgres", Tag: "15.2", Env: []string{ fmt.Sprintf("POSTGRES_PASSWORD=%s", "postgres"), fmt.Sprintf("POSTGRES_USER=%s", "postgres"), fmt.Sprintf("POSTGRES_DB=%s", "test_db"), }, }) if err != nil { return nil, err } // Docker内のDBがオープンしているポート番号を指定して、ホスト側に割り当てられているホスト名とポート番号を取得する hostAndPort := resource.GetHostPort("5432/tcp") // PostgreSQLのデフォルトポート番号 dbUrl := fmt.Sprintf("postgres://postgres:postgres@%s/test_db", hostAndPort) // 一定時間過ぎた場合に自動でコンテナを止めるようにする if err := resource.Expire(120); err != nil { return nil, err } // Dockerで立ち上げたDBに接続する var db *sql.DB if err = pool.Retry(func() error { d, err := sql.Open("postgres", dbUrl) if err != nil { return err } db = d return d.Ping() }); err != nil { return nil, err } // Atlasのクライアントを作成 client, err := atlasexec.NewClient("", "atlas") if err != nil { return nil, err } // スキーママイグレーションを適用する if _, err := client.Apply(ctx, &atlasexec.ApplyParams{ DirURL: "file://migration_dir", URL: dbUrl, }); err != nil { return nil, err } return func() { if err := db.Close(); err != nil { panic(err) } if err := pool.Purge(resource); err != nil { panic(err) } }, nil }
go-txdbを使いコネクションを作成
上記の設定により、テスト実行時にテストDBが立ち上がり、テスト終了時にテストDBが削除されるようになりました。 テストDBはパッケージ単位で立ち上がるため、パッケージ間のテスト間の干渉は防げます。
しかし、同一パッケージのテスト間のテストデータ干渉は防げません。
これを防ぐため、go-txdb を使いテストごとにテストデータをクリアできるようにします。
下記のコードを RunTestDb
関数に追加します。
// ドライバを登録する // これにより、"txdb" という名前でコネクションを作成できるようになる txdb.Register("txdb", "postgres", dbUrl)
追加したものが以下になります。
package testhelper ... func RunTestDb(ctx context.Context) (func(), error) { // dockertestがDockerに接続するためのクライアントプールを作成 pool, err := dockertest.NewPool("") if err != nil { return nil, err } // Dockerに接続できているか確認 err = pool.Client.Ping() if err != nil { return nil, err } // DockerでDBを起動 resource, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "postgres", Tag: "15.2", Env: []string{ fmt.Sprintf("POSTGRES_PASSWORD=%s", "postgres"), fmt.Sprintf("POSTGRES_USER=%s", "postgres"), fmt.Sprintf("POSTGRES_DB=%s", "test_db"), }, }) if err != nil { return nil, err } // Docker内のDBがオープンしているポート番号を指定して、ホスト側に割り当てられているホスト名とポート番号を取得する hostAndPort := resource.GetHostPort("5432/tcp") // PostgreSQLのデフォルトポート番号 dbUrl := fmt.Sprintf("postgres://postgres:postgres@%s/test_db", hostAndPort) // 一定時間過ぎた場合に自動でコンテナを止めるようにする if err := resource.Expire(120); err != nil { return nil, err } // Dockerで立ち上げたDBに接続する var db *sql.DB if err = pool.Retry(func() error { d, err := sql.Open("postgres", dbUrl) if err != nil { return err } db = d return d.Ping() }); err != nil { return nil, err } // Atlasのクライアントを作成 client, err := atlasexec.NewClient("", "atlas") if err != nil { return nil, err } // スキーママイグレーションを適用する if _, err := client.Apply(ctx, &atlasexec.ApplyParams{ DirURL: "file://migration_dir", URL: dbUrl, }); err != nil { return nil, err } // ドライバを登録する // これにより、"txdb" という名前でコネクションを作成できるようになる txdb.Register("txdb", "postgres", dbUrl) return func() { if err := db.Close(); err != nil { panic(err) } if err := pool.Purge(resource); err != nil { panic(err) } }, nil }
次に登録したドライバを使って、テストごとにコネクションを作成できるようにします。
package testhelper func NewTestDBConn() (*bun.DB, func(), error) { // 登録済みの "txdb" という名前のドライバを使ってコネクションを作成する db, err := sql.Open("txdb", "test_conn") if err != nil { return nil, nil, err } // BunのDBを作成する bunDB := bun.NewDB(db, pgdialect.New()) closer := func() { if err := db.Close(); err != nil { panic(err) } } return bunDB, closer, nil }
これを使い以下のようにテストから呼び出すことにより、テストごとにデータがクリアされるようになります。
package sample_api_test ... func TestMain(m *testing.M) { closer, err := testhelper.RunTestDb() if err != nil { panic(err) } defer closer() m.Run() } func TestSample1(t *testing.T) { t.Run("test1", func(t *testing.T) { db, closer, err := testhelper.NewTestDBConn() if err != nil { t.Fatal(err) } defer closer() ... }) t.Run("test2", func(t *testing.T) { db, closer, err := testhelper.NewTestDBConn() if err != nil { t.Fatal(err) } defer closer() ... }) }
まとめ
今回私たちが開発したサービスのテスト方針と、それを実現するためのテスト環境構築方法について紹介しました。
このサービスでは、テストのメンテナンスコストを抑えつつリグレッションを検知しやすくなるように、APIテストに比重を置きました。 APIテストの作成はDBの用意やAPIサーバーの起動など準備が大変なため、APIテストのための環境づくりが重要になります。
この記事では、テスト環境構築の手間を抑えるために、dockertestを使ってパッケージ毎にテストDBを立ち上げ、 AtlasのGo SDKを使いスキーママイグレーションを適用し、go-txdbを使いテストごとにデータをクリアする方法を紹介しました。
もし、Atlasを使ったテスト環境構築について悩んでいる方がいれば、この記事が参考になれば幸いです。
最後に
今回開発したサービスは今後新しい機能を追加していく予定です。とりわけ、集計処理などの重いバッチ処理が増えていきそうな感じです。 今回の記事ではAPIテストの環境構築について紹介しましたが、今後はバッチ処理のテスト環境構築についても記事にできたら良いなと思っています。
enechain では、事業拡大のために随時仲間を募集しています。興味がある方はぜひお声がけください!