🌊

Go/Docker/GitHub Actions環境でのDBテスト方法検討

2023/02/24に公開

はじめまして2022/11に入社しましたソフトウェアエンジニアの葛西です。
主にバックエンド開発を担当しています。
社内で競プロ部を立ち上げたのでいつか機会があれば記事にしたいなと思っています!

はじめに

チーム内で現在のプロジェクトのDB(リポジトリ層)単体テストをどういう風にやろうかという話になり、色々悩みながらやり方を検討していったのでその過程と最終的にどのように実装したかを残しておきたいと思い記事にしました。

技術スタック

まずDBテスト方法を検討する際の前提条件として現在のプロジェクトで使用している技術スタックを書いておきます。

言語
Go

DBマイグレーションツール
sql-migrate
スキーマ変更用のSQLを用意しておけばコマンドでスキーマ変更などを反映してくれるというツールです。
スキーマ変更用のSQLはこのプロジェクトのディレクトリに直接置いています。

ORMライブラリ
sqlboiler
スキーマからORMのコードを自動生成してくれるライブラリです。

DB
MySQL
DBコンテナを立ち上げられるようにdocker-compose.ymlをプロジェクトのディレクトリに直接置いています。

CI/CD
GitHub Actions
PRを上げると自動テストが走るようになっています。

このような構成で、スキーマ用のSQLとDBコンテナのdocker-compose.ymlはプロジェクトのディレクトリ内に配置してあるのでいつでもDBのコンテナは立ち上げられるようになっています。

DBテスト実装案

上記の技術スタックを前提条件としてDBテストの実装案が4つ挙げられました。
これらの案に対して採用/不採用になった理由を見ていきたいと思います。

テスト実行前にDBコンテナを立ち上げる案

一番単純なやり方でgo test ./...を実行する前にDBコンテナを立ち上げておいて各DBテストでそのコンテナにアクセスするという案です。

しかしGoでは複数パッケージのテストはデフォルトで各パッケージ毎に並列でテストが実行されるようになっており、この案には複数のテストケースが競合してテスト結果が不安定になってしまうという問題点があったため不採用としました。

テストを直列で動かす案

↑の案に対してgo test-pオプションで並列に実行されるテストの数を制御できるため-p 1として全てのテストを直列で実行すれば各テストケースが競合しないのでテストの不安定さを解消できるという案です。

この案に関しては、テスト結果が不安定になるということはないのですが、テストケースが増えるに従ってテストの実行時間が増え、CI/CDの時間が増加し、結果的に生産性の悪化につながるためこちらも不採用としました。

各パッケージ毎にDBコンテナを立ち上げる案

各パッケージのテスト実行前に実行する前処理をTestMainに記述することができるので、各パッケージのTestMainでDBコンテナを立ち上げてテスト実行時にはそのDBコンテナにアクセスする案です。
ただし各DBコンテナでポートが競合しないように空いているポートを探して起動するという必要があります。

この案はテスト結果も安定し、並列して実行できるためテスト時間にも問題はないのですが、各パッケージで空いているポートを探してコンテナを立ち上げ、各パッケージのテストでは立ち上げたDBコンテナにアクセスするということを実現するためにはテスト用のヘルパーなどを実装する必要があり、テストの実装と保守にコストがかかりそうと判断し今回は不採用にしました。
ただテスト環境の整備に十分時間が取れればこの案を実現するライブラリなどを作って展開するというのもありだと思っています。

テストケース毎にトランザクションを張ってロールバックをする案

最後にこの案は、一番最初の案と同じくテストの実行前にコンテナを立ち上げておいて各テストケース内でトランザクションの開始とテスト終了時にロールバックを実行するという案です。

このやり方では各テストケースで競合が発生せず、パッケージ毎に並列してテストを実行できるためテスト時間もかからず、テストコードの実装も簡単なのでこちらの案を採用にしました。

実装例

ということで今回採用したテストケース毎にトランザクションを張ってロールバックをする案の実装例を簡単に記載しておきます。
今回は下記のようなidとnameのカラムだけがあるUsersテーブルとidでUser情報を取得するメソッドFindByIDが用意されているという設定でテストコードとGitHub Actions Workflowの実装例を記載します。
簡単化のために色々省略していますがご了承ください🙏

・Usersテーブル

CREATE TABLE IF NOT EXISTS users
(
	id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
	name varchar(255) DEFAULT NULL,
)

・FindByIDメソッド

// sqlboilerから自動生成されたモデルUserを返すメソッド
// modelsパッケージにsqlboilerの生成したコードを置いている
func FindByID(ctx context.Context, id uint64) *models.User {
	// dbに接続
	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
	user, _ := models.FindUser(ctx, db, id)
	return user
}

テストコード

各テストケースの処理は
トランザクション開始→事前準備のデータ投入→テスト実行→検証→ロールバック
という流れになっています。
sqlboiler前提のテストコードになってしまいましたがgormなどの他のORMを使ったとしても全体的な流れはそこまで変わらないと思います。

package main

import (
	"context"
	"reflect"
	"testing"
)

func Test_userRepository_FindByID(t *testing.T) {
	// FindByIDの引数の構造体
	type args struct {
		id uint64
	}
	// 事前準備で挿入するレコードの構造体
	type record struct{
		user models.User
	}
	// テストケースのスライス
	tests := []struct {
		// テストケース名
		name string
		// 引数
		args args
		// 事前準備で挿入するレコード
		record *record
		// 出力の期待値
		want *models.User
	}{
		{
			name: "指定したIDのユーザーを取得できる",
			args: args{
				id: 1,
			},
			records: &records{
				models.User{
					ID: 1,
					Name: "Test User",
				}
			},
			want: &models.User{
				ID: 1,
				Name: "Test User",
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T){
			// トランザクション開始
			ctx := context.Background()
			tx := db.BeginTx(ctx)
			// テストケース終了時にロールバック
			defer tx.Rollback()
			// 事前準備 データ投入
			if tt.record != nil {
				tt.record.user.Insert(ctx, tx, boil.infer())
			}
			// テスト実行
			got = FindByID(ctx, tt.args.id)
			// 検証
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("FindByID() = %v, want %v", got, tt.want)
			}
		})
	}
}

GitHub Actions Workflow

Workflowの流れとしては
go build→DBコンテナを立てる→テスト実行
となっています。

name: Run Test

on:
  pull_request:
    branches:
      - {PR作成時にテストを実行するブランチ}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: go build
        run: go build -v ./...
      - name: build db container
        run: |
          docker compose up -d {dbコンテナ}
          {スキーマ適用コマンド}
      - name: run test
        run: go test -v ./...

上記の例は非常に簡単なメソッドに対するテストでしたが、このようにテストコードとGitHub Actionsを実装することで毎回のCI/CDにDBテストを組み込むことが可能になりました。

DB単体テストの必要性

現在このテストケース毎にトランザクションを張ってロールバックをする案でDBテストの実装と運用ができていてかなりリグレッションに対する恐怖心が和らいでいます。

しかし最近話題になっている単体テストの考え方/使い方を見るとリポジトリを個別でテストをすべきではないという主張が書いてあります(実はDBテストの実装を始めた後にこの本を読みました😿)。
理由としてはリポジトリには基本的に複雑なロジックが含まれていないためリグレッションに対するメリットがあまりない割に保守コストが高いという問題が挙げられていました。

この主張に対する個人的な見解を言うと、保守コストに関しては今回の実装案ではスキーマのSQLがちゃんと整備されていればそこまでかからず、リグレッションに対するメリットという観点でも実際にはテーブルの数が多くなるにつれてリポジトリのロジックも複雑になっていくのでそれなりに価値があると思っています。

ただ統合テストが充実していればわざわざDBを単体テストで検証する必要がなくなるという判断もあり得るので単体テスト、統合テスト、E2Eテストなどを含めて全体的なテスト戦略を決めた上でDBの単体テストの守備範囲を決める必要があります。
なので現在の結論としては全体的なテスト戦略を決めてDBの単体テストのカバー範囲を明確にするということは必要ではありますが、リポジトリに含まれている複雑なロジックを検証するためにDBテストの実装を進めていこうと思っています。

まとめ

以上がDBテストの実装案及び実装例になります。
今回DBの単体テストを実装するにあたり、いくつかの案を考えその中で一番コストが低くテストの安定性が高い案を選び実装し現在実際に運用しています。
また最後に述べたようにこれで終わりというわけではなく統合テストなども踏まえた上で全体的なテスト戦略を考えて効率的なテスト環境を作っていき、最終的には安全に早く価値のあるプロダクトを提供していけるようになっていきたいと思っています。

株式会社エスマット

Discussion