Android のユニットテストが終わる気がしないある日の作業ログ

に公開

こんにちは!アルダグラムでエンジニアをしている内倉です。

テストは好きでも、実行時間が長いとつらい気持ちになってしまうことがありますね。

弊社では現在、React Native から KMP(Kotlin Multiplatform)への移行を進めていることもあり、Android のユニットテストが続々と増えてきています。

比例して、テストの実行時間もだいぶ長くなってきたので、高速化できる余地がないか調べてみることにしました。

はじめに

前提として、今回のプロジェクトは以下のようになっています。

  • マルチモジュール構成(KMPのモジュールと、Androidネイティブのモジュールが混在)
  • CI では GitHub Actions を利用し、./gradlew でユニットテストを実行
  • テストカバレッジの計測には Kover を使用

結論から言うと、今回ぜーんぜん速くならなかったので、この記事は単なる作業ログに成り下がったというわけです。

でも、また別の方法を試せばいいだけのこと。「これじゃなかった」も積み重ねれば、いつか爆速CIにたどり着くと信じて、ここに記録を残します。

1. GitHub Actions の実行ログの確認

まずは、実際の GitHub Actions のログをみて、どのステップが長いのか確認します。
やはり、テスト実行 + カバレッジレポート作成のステップがほとんどの時間を占めています。

この workflow の yaml ファイルを某AIに見せて「ぱっと見、実行時間を短縮するために改善できる箇所はないか?」雑に聞いたところ

↓ これを

jobs:
  unit_test_android:
    ...
    timeout-minutes: 45
    ...

↓ このようにする変更をサジェストされ

jobs:
  unit_test_android:
    ...
    timeout-minutes: 30
    ...

気力が無になったので、とりあえず Run unit tests & generate coverage report のステップで実行している gradle タスクの Profile report を出力してみることにしました。

2. gradle タスクの Profile report の確認

workflow の Run unit tests & generate coverage report のステップで実行していたのは、次の gradle タスクたちです。

./gradlew testDevDebugUnitTest testDebugUnitTest koverHtmlReportDevDebug

※ XxxUnitTest が2つありますが、それぞれ実行するテストクラスが異なるので、今回は両方実行する必要があります。

これに --profile オプションを追加して、ローカルで実行していきます。

なるべくキャッシュの影響を受けないように、毎回以下の手順でレポートを出力しました。

  1. ./gradlew clean を実行
  2. ./gradlew testDevDebugUnitTest testDebugUnitTest koverHtmlReportDevDebug —profile --no-build-cache --return-tasks を実行

初回実行

結果がこちらです。

実際にタスクを実行している時間が殆どを占めています。

Task Execution の詳細を確認したところ、最大でも4分程度で目立って時間がかかっているようなタスクはありませんでした。

gradle.properties には、 org.gradle.parallel=true の指定がすでにあるけれど、あんまり並列みがない…

--parallel オプションを追加してみる

org.gradle.parallel と同様、Gradleタスクやサブプロジェクトを並列で実行できるようにするオプションです。

gradle タスク実行時にも、 --parallel オプションをつけてほしいのかも?と思い、追加して実行してみました。

こちらは、初回の実行結果でたまたま一番速かったものです。数回実行してみたところ、初回より速いときもあれば遅いときもあり、完全に誤差の範囲でした。

サブモジュール同士の依存的に、あんまり並列処理できるとこがないのかな?

maxParallelForks を指定してみる

テストクラス(またはタスク)を複数のJVMプロセスで並列実行するための最大プロセス数を指定するオプションです。

ターミナルで sysctl -n hw.ncpu を実行し、論理コア数: 10 あることを確認しました。

まずは max 半分くらいで設定してみます。
build.gradlesubprojects ブロックに以下を追加します。

subprojects {
		...
		tasks.withType(Test).configureEach {
        int halfCores = Runtime.runtime.availableProcessors().intdiv(2)
        // CPUコア数の半分 or 最低でも1フォーク
        int cores = Math.max(1, halfCores)
        maxParallelForks = cores
	  }
}

結果がこちらです。

逆に倍近くかかるようになったうえに、以下のエラーが発生するようになりました。

Picked up _JAVA_OPTIONS: -Djava.net.preferIPv6Addresses=true
System.logW: A resource was acquired at attached stack trace but never released. See java.io.Closeable for information on avoiding resource leaks.

このプロジェクトではDB操作を含むテストは、 @Before で毎回新しいDBを Room.inMemoryDatabaseBuilder(...).build() で作成して使用しています。

inMemoryDatabaseBuilder を使っているテストクラスを確認したところ、 database.close() し忘れているようなものはありませんでした。
また、名前をつけて Room.inMemoryDatabaseBuilder(...).build() しているような箇所も特に見つかりませんでした。

並列化したので、テスト終了時に別のテストで利用中の database があるとダメなのかも。
(せっかく inMemoryDatabase なのに…🥲 )

maxParallelForks = 2 を指定してみる

どちらにしろ、フォーク数5 だと多すぎるみたいなので、2に固定して試していきます。

結果がこちら。

微増です。しかも、相変わらずエラーが出ています。

もう、このプロジェクトで、手軽に並列は無理なのかも。
GitHub Actions の workflow 側で、 strategy で並列実行して、最後に kover の結果をマージするあれをやるしかないのかもしれない…(でも、今回は時間の兼ね合いでパス!)

koverHtmlReportDevDebug をやめてみる

Task Execution の詳細を見た限りでは、サブモジュール数が結構多いのでよくわかりませんでしたが、もしかしたらカバレッジレポートの作成が大変なのかもしれません。
いったん ./gradlew testDevDebugUnitTest testDebugUnitTest のみで、計測してみます。

せっかく導入した から、できたらやめたくないな〜。

思ったより、速くなりませんでした!
この程度だったら、カバレッジレポート残しておいてもいいですね!

手軽にできる方法で、 Task Execution を短縮できそうなものがもう思いつかない…!!

おわりに

テストで inMemoryDatabase を使っているので、簡単に並列化できて爆速になると期待していましたが、結局方針を変えて workflow 側で並列化を行うしかなさそうでした。

Task Execution で実行しているタスク数自体がとても多かったので、いったん並列のことは置いておいて、余分なタスク実行を避ける方向で試していきたいと思います。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion