GoでAPI開発を行っており、自動テストの実行時間が長いことが課題でした。
今回は、テスト実行時間短縮のために行なった取り組みとその結果を記録します。
テストの実行時間を短縮する方法は、並列化以外にも「タイムアウト値をテスト時だけ短くする」などありましたが、今回は並列化に焦点を当てて記述します。
今回の並列化の取り組みの結果としては、パッケージ単位での並列化によってテスト時間を削減することができました。(-4分くらい)
また、DBアクセスを行う複数のテストを並列実行するにあたり、テスト用のコンテナに複数のDBを作成し、なおかつ使用中のDBにロックをかけることで競合を防ぎました。
背景
Goではテストの並列実行が可能ですが、単一のDBにアクセスするテストケースを複数同時に実行すると当然競合が生じます。
そのため、DBへのアクセスが競合しないように複数のDBを用意する必要がありました。
また、あるテストケース (ゴルーチン) がDBを使用しているとき並列実行中の他のテストケースが同じDBにアクセスしないよう、DBにロックをかける必要があります。
複数のDBを用意し、競合せずアクセスできるようにする
複数のDBを用意
今回はテスト用の1つのDockerコンテナ内に複数のDBを作成しました。
下記のように、(./db/mysql/test/initに置いた) DBを複数作成する処理を書いたシェルファイルなんかを、/docker-entrypoint-initdb.dにコピーして実行させました。
db-test:
volumes:
- ./db/mysql/test/init:/docker-entrypoint-initdb.d
DB数は環境変数などから読み取って柔軟に変更できるようにしておくと良いかと思います。
テスト間で競合しないようにDBにロックをかける
下記のコード例のように、テストケースごとに未使用のDBを探し、見つけたらそのDBをテストケースが完了するまでロックします。DBごとにロックファイルが作成され、そのファイルがロックされているかどうかでDBを使用できるか判断しています。
var dbName string
var dbNum int
// dbName, dbNumは環境変数などから初期値を取得する想定
func UseDBParallel(t *testing.T) {
t.Helper()
for {
for i := 1; i <= dbNum; i++ {
err := tryLock(t, "hogedb", i)
if err != nil {
continue
}
dbName = fmt.Sprintf("hogedb_%d", i)
return
}
time.Sleep(time.Second)
}
}
func tryLock(t *testing.T, dbName string, dbNum int) error {
t.Helper()
homeDir, err := os.UserHomeDir()
if err != nil {
panic(err)
}
filename := filepath.Join(homeDir, "dblock", fmt.Sprintf("%s_%d.lock", dbName, dbNum))
lockFileDescription, err := syscall.Open(filename, syscall.O_CREAT|syscall.O_RDONLY, 0750)
if err != nil {
return err
}
if err := syscall.Flock(lockFileDescription, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
if err := syscall.Close(lockFileDescription); err != nil {
return err
}
return err
}
t.Cleanup(func() {
if err := syscall.Flock(lockFileDescription, syscall.LOCK_UN); err != nil {
panic(err)
}
if err := syscall.Close(lockFileDescription); err != nil {
panic(err)
}
})
return nil
}
一応、コードの説明をしておきます。
syscall.Flock(lockFileDescription, syscall.LOCK_EX|syscall.LOCK_NB)
でロックファイルにロックをかけ、syscall.Flock(lockFileDescription, syscall.LOCK_UN)
でロックを解除しています。
dbName
がテストケース内で使用できるDB名に書き換えられます。dbName
は、テスト対象の関数でDBコネクションを確立する際に使用される想定です。
ロックされていないファイルを見つけることができなかった場合は、time.Sleep(time.Second)
で一定時間待ってからまたtryLock
を繰り返します。
テストを並列実行
ご存知の通り、Goにはテストを並列実行するために以下の2つの方法があります。
- パッケージ単位での並列化
- テスト関数 (TestXxx)・サブテスト (t.Run) 単位での並列化
パッケージ単位での並列化 (出番!)
下記のようにgo test
に-p
オプションをつける方法です。今回はこちらの方法のみを採用しました。
-p
に指定した数だけゴルーチンが生成され、それぞれのゴルーチン上で1つのパッケージのテストが実行されます。
go test -p [並列実行するパッケージの数]
ちなみに、-p
に指定した数値以上の値がGOMAXPROCS
に設定されていないと、意図した並列数で実行できません。
詳しくは、こちらの記事を読むとイメージしやすいかもしれません。
また、-p
オプションを指定しない場合はGOMAXPROCS
の値がデフォルトで-p
の値となります。
テスト関数・サブテスト単位での並列化 (出番じゃない)
下記のように、テスト関数やサブテスト関数内でt.Parallel
メソッドを使用することで可能です。
ちなみに、こちらはあくまでゴルーチンを複数作成して同時実行しているだけです。
t.Parallel
の機能についてはこちらの記事に非常にわかりやすくまとめられています。
func TestXxx(t *testing.T) {
t.Parallel()
t.Run("success: example completed", func(t *testing.T) {
t.Parallel()
...
})
}
パッケージ単位での並列化のみを採用
今回の並列化の取り組みでは、「 パッケージ単位での並列化」のみを行いました。
理由としては、t.Parallel
を使用した並列化よりも-p
を使用した並列化の方がCPUの使用効率が良かったからです。
詳細を以降で説明します。
私のチームでは、github actionsで起動したDockerコンテナ上で自動テストを実行することが多いです。
下記のようなイメージです。
name: Tests
on:
push:
branches:
- main
jobs:
tests:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Tests
run: docker compose -f docker-compose.test.yml run --rm app sh -c "go test -p 2 -v github.com/hoge/fuga-api/... -shuffle=on"
上記のubuntu-latestのデフォルトで割り当てられているCPUコア数は2です。「カスタムランナー」?として自前で用意したサーバを使用したりすればもっと多くのCPUコア数を設定できるとは思います。
今回は、コンテナ内で使用できるCPUコア数は2という前提で進めます。
まず、この時にテスト関数・サブテスト単位での並列化 (以降「t.Parallel」に含意) を行うことを考えてみます。
適当ですが、アプリケーション内にrepository_test, usecase_test, controller_testという3つのテストパッケージがあると仮定します。
それぞれのパッケージはプロダクションコード内で直接的あるいは間接的に (他パッケージを呼び出して) DBにアクセスしています。
t.Parallelを使用してみる
まず、この時にテスト関数・サブテスト単位での並列化 (以降「t.Parallel」に含意) を行うことを考えてみます。
package repository_test
func TestXxxRepositoryFind(t *testing.T) {
t.Parallel()
t.Run("case1", func(t *testing.T) {
t.Parallel()
...
})
t.Run("case2", func(t *testing.T) {
t.Parallel()
...
})
}
func TestXxxRepositoryCreate(t *testing.T) {
t.Parallel()
...
}
(以降の説明はフローチャートを見るとわかりやすいかもしれません。)
上記のXxxRepositoryのテスト関数で、テスト関数・テストケース毎にt.Parallelを呼び出すとします。
こうするとt.Parallelごとにゴルーチンは生成され、TestXxxRepositoryFind
とTestXxxRepositoryCreate
、"case1"
と"case2"
がそれぞれ並列に実行され..てほしいですがされません。それはGOMAXPROCS (ゴルーチンを実行するOSスレッドの上限) =2だからです。
厳密には、TestXxxRepositoryFind
とTestXxxRepositoryCreate
は並列に実行されます。GOMAXPROCS=2なので、それぞれのテスト関数(ゴルーチン)にスレッドが割り当てられ、この2つのゴルーチンは並列で実行されます。
しかし、子ゴルーチンの "case1"
と"case2"
は、TestXxxRepositoryFind
が動くスレッド内で「並行」に実行 されます。
つまり、単一のスレッド内で複数のゴルーチンが実行されているだけなので、こちらのように複数のDBにアクセスできるようになっていても、1スレッド内では1つのDBにしかアクセスできない (ゴルーチン間で同じdbName
変数の値を共有している) わけです。
こうなると、結局 "case1"
がDBアクセスしている際は"case2"
は接続待ち のような状態となり、DBアクセスは直列に行われることになります。また、"case2"のような待機するゴルーチンを複数並行に実行してしまうと、CPUの無駄使いとなります。
go test -p
を使用してみる
次に、パッケージごとに並列化してみます。
github actionsでDockerコンテナを起動後、下記のようなコマンドが実行できるようにしました。
go test -p 2
(以降の説明はフローチャートを見るとわかりやすいかもしれません。)
本記事ではrepository_testとusecase_testとcontroller_testの3つのテストパッケージがあると仮定しました。
これらのパッケージのうち最大2つが並列で実行されます。順番には特に言及しませんが、repository_testとusecase_testが並列に実行されるとしましょう。
2つのスレッドそれぞれの上で並列にrepository_test、usecase_testパッケージのテストが実行されていきます。どちらかのパッケージのテストが全て終了すれば、controller_testもいずれかのスレッド上で実行されるでしょう。
また、repository_testとusecase_testは異なるスレッド上で実行されているので、それぞれ異なるDBに並列にアクセスすることができます。
パッケージ内のテストケースは直列に実行されていくので、待機時間の長いゴルーチンを発生させてしまうこともなくCPUを無駄遣いしません。
以上のことから、t.Parallel
でも-p
オプションでもどちらもGOMAXPROCS分の並列化は可能ですが、-p
オプションを使用したパッケージ単位の並列化の方がCPU使用効率がいいということになります。
おわりに
今回は、「複数のDBを用意し、パッケージごとにテストを並列実行することで、DBアクセスするテストでもCPU使用効率よく並列実行が可能になりました」という話でした。
まとめても分かりづらいですね。すみません。
※1つ注意していただきたいのは、今回テスト関数やサブテスト関数の並列化が適さなかったのは、共有リソース(DB)に複数のテストケースがアクセスしていたからということです。
DBのような共有リソースにアクセスしないテストケースの場合は、t.Parallel
で並行実行するだけでも十分高速になる可能性があります。
長文で色々ダラダラと書きましたが、間違っていることを平気で書いている可能性も多分にあります。
ぜひご指摘いただけると幸いです
また、全体的に文章や構成が冗長だったり分かりづらい箇所もあるかと思います。すみません
誰かの役に立ったり「おもろいなー」と思ってもらえると幸いです!
参考記事
非常に分かりやすかったです。