RemitAid Tech Blog
🧪

DBを使ったインテグレーションテストを導入し、モック中心のテストから脱却した

に公開

こんにちは!ソフトウェアエンジニアの inari111 です。

この記事では、RemitAidでDBを使ったインテグレーションテストを導入した背景、方針、得られた効果について紹介します。

はじめに

DBにはMySQL, Redisを使っていて、バックエンドはGoで実装しています。

この記事で登場する各テストに関しても説明しておきます。

  • ユニットテスト
    • 関数やメソッドなど最小単位のコードが正しく動作するかを検証するテスト
    • 外部依存(DB、APIなど)はモックに置き換え
  • インテグレーションテスト
    • 複数のモジュールを組み合わせた状態で正しく連携するかを検証するテスト
    • DBアクセスや外部API呼び出しを含む場合もある

インテグレーションテスト導入前

もともとRemitAidではユニットテストのみを書いていました。
外部依存(DB、APIなど)はモックに置き換えているため高速に実行できていました。
テスト実行に時間がかからないのはメリットでしたが、デメリットもいくつかありました。

  • DBがモックされているため実際に動作確認をするまでCRUD処理が正しいかわからない
    • 例: カラム追加・型変更などがあってもテストでは気付けない
  • モックの戻り値を開発者の都合のいいように設定できてしまう
    • モックが現実と乖離していてもテストはパスしてしまう
  • リファクタリングをするとモックの呼び出しがずれてテストが壊れる
    • 振る舞いは変わっていないのに、内部実装の変更だけでテストの修正が必要になる

モックになっている箇所が多いため、何をテストしているのか目的を見失いがちになっていたように思います。

インテグレーションテスト導入

私たちはDBを使ったインテグレーションテストを導入することにしました。

方針

現状のインテグレーションテストの方針はこのようになっています。

  • テストを並列実行
  • 正常系と異常系を書く
    • 意図的に起こすことが難しい異常系のテストケースではモックを使う
  • 外部APIを呼び出す箇所に関してはモック化する
  • usecaseのインテグレーションを書いていれば、永続化層のテストはスキップ
    • usecaseテストでDB操作を含めて検証するため、永続化層を個別にテストする必要性が低い
  • アサーションはできるだけstruct同士で行う
    • フィールド単位の比較だと検証漏れが起きやすいため
    • フィールドを増やしたときにテストが失敗するため、変更に気づくことができる

testutil

インテグレーションテストで使用するDBのセットアップ、アサーション、ティアダウンを行うためにtestutilを用意しました。

testutil/
  ├── assertions.go   -- 構造体・スライスの比較やErrorの検証を行うアサーションヘルパー
  ├── db.go           -- テスト関数名からユニークなMySQL DBを作成・削除するセットアップ/ティアダウン
  ├── migration.go    -- schema.sqlを読み込んでテストDBにスキーマを適用する
  ├── query.go        -- ジェネリクスでSQLクエリ結果を構造体にマッピングする
  ├── rds_client.go   -- Read/Write両方に同一DBを返すテスト用RDSクライアントスタブ
  ├── redis.go        -- テスト名からDB番号を割り当ててRedisをセットアップ・クリーンアップ
  └── factory/        -- 各テーブルのテストデータを生成するファクトリ関数

実装の一部を例として紹介します。

SetupTestDB ではテスト関数名からDB名を生成し、作成しています。
これはテストを並列で行うためです。

// db.go
// 例
const (
	testDBPrefix = "test_"
)

type DBTestHelper struct {
	DB         *sql.DB
	DBName     string
	MasterConn *sql.DB // DB作成/削除用の接続
}

func SetupTestDB(t *testing.T) *DBTestHelper {
	t.Helper()

	// テスト関数名からDB名を生成(並列実行対応)
	dbName := fmt.Sprintf("%s%s", testDBPrefix, sanitizeDBName(t.Name()))

	// マスター接続
	masterDSN := os.Getenv("TEST_DATABASE_URL")
	if masterDSN == "" {
		masterDSN = "root:root@tcp(localhost:3306)/?parseTime=true"
	}

	masterConn, err := sql.Open("mysql", masterDSN)
	if err != nil {
		t.Fatalf("Failed to connect to master DB: %v", err)
	}

	// テスト用DBを作成
	_, err = masterConn.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName))
	if err != nil {
		t.Fatalf("Failed to drop test DB: %v", err)
	}
	_, err = masterConn.Exec(fmt.Sprintf("CREATE DATABASE %s CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin", dbName))
	if err != nil {
		t.Fatalf("Failed to create test DB: %v", err)
	}

	// テスト用DBに接続
	testDSN := fmt.Sprintf("root:root@tcp(localhost:3306)/%s?parseTime=true&multiStatements=true", dbName)
	testDB, err := sql.Open("mysql", testDSN)
	if err != nil {
		t.Fatalf("Failed to connect to test DB: %v", err)
	}

	if err := RunMigrations(testDB, dbName); err != nil {
		t.Fatalf("Failed to run migrations: %v", err)
	}

	return &DBTestHelper{
		DB:         testDB,
		DBName:     dbName,
		MasterConn: masterConn,
	}
}

ティアダウンはテスト関数の最後に呼び出してDB削除(クリーンアップ)を行います。

// db.go
func (h *DBTestHelper) TeardownTestDB(t *testing.T) {
	t.Helper()

	// テスト用DB接続をクローズ
	if err := h.DB.Close(); err != nil {
		t.Logf("Failed to close test DB: %v", err)
	}

	// テスト用DBを削除
	_, err := h.MasterConn.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", h.DBName))
	if err != nil {
		t.Logf("Failed to drop test DB: %v", err)
	}

	// マスター接続をクローズ
	if err := h.MasterConn.Close(); err != nil {
		t.Logf("Failed to close master connection: %v", err)
	}
}

アサーションには go-cmp/cmp を使っています。

// assertions.go
type AssertOptions struct {
	IgnoreFields []string // 追加で除外するフィールド
}

func AssertStructWithOptions[T any](t *testing.T, want, got T, opts AssertOptions) {
	t.Helper()

	// 構造体比較(共通フィールドを除外)
	ignoreFields := []string{"CreatedAt", "UpdatedAt", "ID"}
	ignoreFields = append(ignoreFields, opts.IgnoreFields...)

	cmpOpts := cmp.Options{
		cmp.FilterPath(func(p cmp.Path) bool {
			if sf, ok := p.Last().(cmp.StructField); ok {
				name := sf.Name()
				for _, field := range ignoreFields {
					if name == field {
						return true
					}
				}
			}
			return false
		}, cmp.Ignore()),
	}

	if diff := cmp.Diff(want, got, cmpOpts); diff != "" {
		t.Errorf("struct mismatch (-want +got):\n%s", diff)
	}
}

各テストでは以下のように呼び出しています。

  • テスト関数ごとに t.Parallel() で並列化
    • テストケースごとには並列化しない
  • テスト関数ごとにDBを作成し並列化を実現
  • 必要に応じてテストケースごとに TRUNCATE を行う
func TestHoge(t *testing.T) {
	t.Parallel()

	dbHelper := testutil.SetupTestDB(t)
	defer dbHelper.TeardownTestDB(t)

	// Table Driven Tests

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// t.Parallel() は呼ばない

			testutil.TruncateAllTables(t, dbHelper.DB)

			// テスト実行

			// assertion
			testutil.AssertStructWithOptions(t, want, got, testutil.AssertOptions{
				IgnoreFields: []string{"Hoge"},
			})
		}
	}

}

CI

PR作成時とPRをマージしたときに実行するようにしています。
CIではGitHub Actionsのサービスコンテナを利用してMySQLとRedisを起動し、テスト用の環境を構築しています。
ユニットテストとインテグレーションテストをまとめて実行していますが、時間がかかるようになってきたら分けることも検討したいです。

インテグレーションテスト導入後の効果

導入後、以下の効果を実感しました。

  • DBの状態がどうなっているべきかをテストに書くことができるようになり、自動テストで検証できるようになった
  • 大きめのリファクタリングを安全に行えるようになった
    • 先にインテグレーションテストを書き、その後にリファクタリングを行う
    • 一部モジュール化のリファクタリングを進めており、振る舞いが変わっていないことをテストで保証できた
  • 手動で動作確認を行う箇所が減少した

導入後の課題

導入から3か月ほど経ち、課題も見えてきました。

  • インテグレーションテストが増えたことにより、ローカルやCI上のgo testの時間が長くなってきている
    • 現在、全テストの実行に約10分かかっており、待ち時間が長くなってきた
    • GitHub Actionsの利用時間が3000分/月を超え、課金が必要になってきた
  • テストのカバレッジがまだ十分ではない
    • ユニットテストはあるがインテグレーションテストを書けていない箇所はまだある

速度の改善については現在進めているので、別の記事で書きたいと思います。

おわりに

RemitAidのインテグレーションテスト導入について紹介しました。
DBを用いたインテグレーションテストはアプリケーションを堅牢に作るうえで必須だと考えています。
主要なAPIに関してはインテグレーションテストを整備できてきたので、重要な資産となるように今後もしっかり整備していきたいと思います。

Podcast 「RemiTalk」を配信していますので、もし良ければ聴いてみてください!

https://podcasts.apple.com/jp/podcast/remitalk/id1826516525

RemitAid Tech Blog
RemitAid Tech Blog

Discussion