🎃

DBのユニットテストを4倍速くした

2023/10/03に公開

CastingONEでバックエンドエンジニアをやっている辻と申します。

最近子供がポケモンメザスタにハマっています。
一心不乱にボタンを叩き続けるその姿が、情けないようでたくましくもあります。

はじめに

DB(MySQL)へのアクセスを伴うユニットテストに時間がかかっており、開発体験を損なっている問題が起きていました。
当時のUnit Testの結果です。開発言語はGoです。

ok  	path/to/pkg/mysql	250.344s

およそ4分ほどかかっていました。
CastingONEに参画してすぐ、こちらの問題の改善するというお題に取り組み

ok  	path/to/pkg/mysql	61.072s

と、4倍超速くすることができました。
以下、どのように改善していったのかを書いていきたいと思います。

原因の特定

まず、なぜ遅いのか。どこに時間が掛かっているのかを調べました。

テストケースが全部で582件、1パッケージのテストとしては結構な量ですが、
さらに目を引いたのはテーブルの数です。

show tables;
...
493 rows in set (0.15 sec)

これも自分の感覚ではかなり多く、
このテーブルの数、というのが一つポイントになるのではないかと考えましました。

続いてテストコード(Ginkgoを使っています)を読んでいくと
テストケースの実施ごとに prepareTestDatabase() という関数が実行されてました。

var _ = Describe("XxxxRepository", func() {
	BeforeEach(func() {
		prepareTestDatabase()
	})

prepareTestDatabaseのコード自体はシンプルです。 testfixtures を使って、
あらかじめ用意したtest用のfixtures yamlファイルをロードしています。



var (
	fixtures *testfixtures.Context
)

func prepareTestDatabase() {
	if err := fixtures.Load(); err != nil {
		logger.Fatal(err)
	}
}

// Load wipes and after load all fixtures in the database.
//     if err := fixtures.Load(); err != nil {
//         log.Fatal(err)
//     }
func (c *Context) Load() error {

そのfixturesファイルの数は、テーブルの数(493件)分用意されていました。
つまり、このファイル(≒テーブル)の数の分だけデータのロード/アンロードが、テストケースの実施ごとに行われていたということになります。

この時点でほぼこれが問題だよね、とは思いましたが、
推測するな、計測せよということで
テストケースを絞って実行し、簡易的にトレースを仕込んで実行して見ました。すると、

Ran 23 of 23 Specs in 37.653 seconds
SUCCESS! -- 23 Passed | 0 Failed | 0 Pending | 0 Skipped

テストケース23件 x prepareTestDatabase() が目視でおおよそ平均1.4秒程度 = 32.2 秒
全体の85%くらいの時間がprepareTestDatabaseで使われていました。
推測通り、テーブルの数が多いことによりそのロード/アンロードの処理による時間がかかっていることが問題と考えて間違いなさそうでした。

解決すべき問題が明らかになったところで、対応を検討しました。結果、取りうる対応として、

  1. prepareTestDatabase を呼び出す回数を減らす(複数のテストケースをまとめる)
  2. prepareTestDatabase 自体の処理を速くする

の二つを考えました。1は参画まもない自分にはちょっと荷が重いと考えたのと、
そもそも速度という非機能的な要求のためにテストケースを改変するというのは好ましくありません。

したがって、2の方針で対応を進めることにしました。

対応

tmpfsを使う

テスト用のDBはdocker (Docker Compose)で動作していました。
ということでまずはtmpfsを使うように変更しました。

  db:
    image: mysql:latest
    ports:
      - "3306:3306"
+    tmpfs:
+      - /var/lib/mysql

tmpfsを使うことでディスクIOが減り、その分速度の向上が期待できます。
コンテナが停止すると保存されていたデータは全て消えてしまいますが、ユニットテスト実行の用途であれば特に問題はないはずです。

これで先ほどと同様のユニットテストを実行したところおよそ15%程度速度向上しました。

ok  	path/to/pkg/mysql	212.795s

transactionを使う (不採用)

次に、テストケース毎にトランザクションを張ってロールバックをする を参考にさせて頂き、速度改善を試みました。・・が下記の理由により、今回は採用を見送りました

  • testfixturesが対応してない
    • testfixturesが内部でトランザクションを使用しており、ロード完了後にコミットまで行っていました。トランザクションを外部から注入するだけであれば方法はあったのですが、ロードおよびテスト実行後にロールバック、という手段を取ることができませんでした。
  • 関数内でトランザクションを発行している箇所があった
    • Go、特にクリーンアーキテクチャを採用しているプロジェクトにおいては、トランザクション処理が必要な関数にはcontext.Contextを使ってトランザクションオブジェクトを受け渡すことが多いと思います。CastingONEにおいてもほとんどがそうなっていたのですが、歴史的な経緯により一部そうなっておらず、関数内で直接トランザクションを発行してしまっている箇所があり、それらすべての修正を行うことは、影響範囲を考えるとすぐの対応が難しい状態でした。

上記の理由から、今回は採用をしませんでしたが、少し試してみたところ、5-10%程の速度改善が確認できました。
ですので、前述のような事情がなければ速度向上の有効な手段であると思います。

読み込むfixturesを絞り込む

最後に取り組んだのは、読み込むfixturesを絞り込むことでした。
前述の通り、もともとすべてのテストケースの実施ごとに全てのテーブルのロード/アンロードが実施されていましたが、
そもそも、1つのユニットテストで全てのテーブルが必要になることはありません。

ですので、テストで読み書きするテーブルとそれらのリレーションテーブルだけに読み込むfixturesを絞り込むことができれば、
それにかかる時間は改善すると考えました。

もともとはpackageの直下にfixturesディレクトリがあり、そこにすべてのfixturesが入っていてそれを読み込んでいました。

fixtures
├── a.yml
├── b.yml
├── c.yml
├── ...

これを、さらにテストケースごとに階層を掘って、必要なfixturesだけをロードするように変更しました。

fixtures
├── case_a
│   ├── a.yaml
│   └── a2.yaml
├── case_b
│   ├── b.yaml
│   └── b2.yaml
├── ...

こう書くと簡単なのですが、どのテストでどのテーブルが使用されているか?を知る必要があり、
実際には非常に大変な作業となりました。

sqldb-logger等を使って、テスト実行時に実際に発行されているSQLを見て、必要なものだけの選分けを進めていきましたが、
外部キーやトリガーなど目に見えないところで使用されるテーブルがあり、トライアンドエラーの繰り返し、苦しい戦いとなりました・・

結果

再掲となりますが、結果下記の通り当初の4倍超の速度改善をすることができました。
地道な作業の中、心が折れそうなこともありましたが、なんとか無事お題を解決することができてよかったです。

ok  	path/to/pkg/mysql	61.072s

テストケースごとにfixturesが独立したことにより、他のテストケースへの影響を気にすることなく
それらに変更を加えられるようになったのも、今回の対応で良くなった点かなと思います。

おわりに

弊社ではいっしょに開発者体験の向上に努めるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!

Discussion