Gradleのユニットテストを早くするためにやったこと

プロジェクト管理ツールにGradleを使ったSpring Bootプロジェクトのユニットテストを、GitLab CIのパイプラインで実行しているが、完了するのに45分程度かかっていた。

その後のデプロイなど含めると、トータル1時間程度かかってしまう。

短縮できないか調べてみたのでメモ。

問題

Gradleを使ったSpring Bootプロジェクトのユニットテストが遅く、GitLab CIパイプラインでの実行に平均45分程度かかっている。

ユニットテストにはSpock 2 + JUnit 5を使用し、DBは application.properties にてH2DBをインメモリで実行するよう設定している。

調査

当初はGradleのメモリを増やせばよいかと思い、 ./gradlew test 前に export GRADLE_OPTS="-Dorg.gradle.daemon=false -XX:MaxRAMPercentage=75" を設定してみたが、まったく改善されなかった。

Gradleとは別に、ユニットテスト用のJVMが起動しているのかと思い、Test DSLを見てみると、以下の記載を見つけた。

Test are always run in (one or more) separate JVMs

テストは1つ以上のJVMで実行されるとある。

Gradleのtestタスクのプロパティ

テストの並列処理に関連しそうなプロパティは以下。

プロパティ名 説明
maxParallelForks int 並列実行するテストプロセス数。
指定した数または max-workers の小さい値まで、テストプロセスを作成して並列実行する。
デフォルト 1
forkEvery long 1つのテストプロセスで実行する、テストクラスの最大数。
1 以上の値を指定すると、指定した数のテストクラスを実行した後、テストプロセスの再起動が行われる。
0 の場合はテストプロセスの再起動は行われない。
デフォルト 0L
maxHeapSize String プロセスの最大ヒープサイズ。 512m1g など、単位付きで指定。
-Xmx に展開される模様。
デフォルト null
minHeapSize String プロセスの最小ヒープサイズ。指定方法は maxHeapSize と同様。
-Xms に展開される模様。
デフォルト null

jvmArgs もプロパティに存在するが、そちらに最大/最小ヒープサイズは含めず、 maxHeapSize および minHeapSize を利用する模様。

テストプロセスの生成について

「テストは1つ以上のJVMで実行される」とあるが、このJVMmaxParallelForks で作成されるテストプロセスとイコールなのかが読み取れなかった。

試しにPC上で maxParallelForks をCPU論理プロセッサ数、 maxHeapSize を物理メモリ容量の半分に設定したところ、テスト開始時にスレッド生成に失敗したことから、テストプロセスはそれぞれ個別のJVMとして起動される模様。

また、インメモリで起動しているH2DBは、 application.propertiesspring.datasource.url が同じ値でも、各テストプロセス内で固有のデータを保持することが確認できた。JVMごとにメモリ領域が分かれていると思われる。

max-workers について

maxParallelForks の上限になっている max-workers は、Gradleの最大ワーカー数。

ビルド環境によると、デフォルトではCPUプロセッサ数。おそらく java.lang.Runtime#availableProcessors() が使われている。

プロパティ org.gradle.workers.max=... を指定したり、Gradleコマンド実行時に --max-workers=... で設定可能だが、明示的に値を設定するより、未設定にしてデフォルト値を使うのがよさそう。

forkEverymaxHeapSize について

マルチコアプロセッサの場合、 maxParallelForks に大きな値を設定し、 forkEvery0LmaxHeapSize を未設定とした場合、実行時にフォークされた各JVMがメモリを食いつぶし、OutOfMemoryError が発生した。

forkEvery を適当な値に設定し、定期的にテストプロセス(JVM)を再起動させるか、 maxHeapSize を指定してヒープサイズに上限を設ける必要がある。

現状確認

現時点の値を確認すると、以下のようになっていた。

  • maxParallelForks: 4
  • forkEvery: 1
  • maxHeapSize, minHeapSize: 未設定

GitLab CI RunnerではAWS EC2でvCPU 16のインスタンスを使用しているため、 maxParallelForks が少なく設定されている。また、 forkEvery が1になっており、1テストクラスごとにテストプロセスの再起動が行われる状態だった。

対応

公式のテスト実行に関する記述をもとに、パラメータを調整する。

1点、公式では forkEvery の使用が記載されているが、適切な値設定ができなかったため、 maxHeapSize を指定している。

// CIパイプラインからの実行時に環境変数を設定し、ローカル実行かパイプライン実行かを判定する
// CI/CDにはGitLab CIを利用しており、その場合CIがtrueに設定されるので、それを判定に利用
// https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
final boolean onPipeline = System.env.CI != null

test {
  // 中略

  if (onPipeline) {
    maxParallelForks = Runtime.runtime.availableProcessors()
    forkEvery = 0L
    maxHeapSize = "1g"
  }
}

maxHeapSize には、当初 free した際の空きメモリから、Gradleが利用するメモリ容量を引いた値を availableProcessors() で割った値を渡していたが、それだとメモリ不足でスレッド生成に失敗した。

H2DBが別途メモリを使用しているのか、スレッド生成にOS側でもメモリを使うのかなど推測したが、詳細は不明。

計算値から512MB~768MB程度引いた値で設定すると実行できたため、取り急ぎ切りのいい1GBで設定した。

結果

テストの失敗が多発したが、原因のほとんどは、データ件数不整合、主キー重複、外部キー制約違反。これまでは1テストクラスごとにJVMが再起動し、それに合わせてH2DBのデータもクリアされていたが、再起動しないよう変更し、データ不整合が生じていた。

テストデータ作成は抽象クラスにまとめていたため、そのクラスの cleanup メソッドに、全テーブルのデータを削除するよう処理を追加することで解消。

データ削除処理のコスト増があるものの、テスト実行時間は45分から10分と大幅に減少した。

振り返り

並列実行時にインメモリのH2DBが各テストで共有されると面倒だなと思っていたので、各テストプロセスで独立して動いてくれたのはありがたかった。

また、現状Gradle Wrapperのダウンロードや、依存Jarのダウンロードが都度実行されており、約4分程度かかっているため、このあたりをGitLab CIのキャッシュ機能で改善できそう。