sbtのtestGroupingを使った並列化や高速化手法の解説

以下の記事の続き的な意味があるので、まずはそちらを読んでください

xuwei-k.hatenablog.com

そちらの記事で、以下のようなコメントをもらっていましたが

fork := true のときの testForkedParallel := true の挙動、同じプロセス内で(マルチスレッドで)実行するようなので"A1, A2, B1すべてが並列実行"は実際そうなんですが、A1, A2は同一プロセスで、B1とは(Tags.ForkedTestGroupを増やしていれば)別となるんじゃないかな、と思います。A1とA2を別プロセスに割り当てるにはtestGroupingをなんら自分で設定する必要がありそうに見えます(逆にこれを設定すればparallelExecutionとForkedTestGroupの設定だけで同じsub project内のテストを並列実行できる)。

(手元のプロジェクトでテストプロセスごとに接続先のDBなどを別々に隔離しているためtestForkedParallelはfalseにしているのですが、この点をいつも忘れてしまって、「なんでだっけ」「これでいいんだっけ」となるので補足してあると助かる人もいるかもしれない、というコメントでした。)

主にそのあたりの話をします。

もちろん公式サイトにも説明はありますが、そこまで詳しくは書いてない気がします(見落としてたらすいません)

https://www.scala-sbt.org/1.x/docs/Testing.html#Forking+tests

sbtのversionは一定以上新しければ特に問題ないと思いますが、とりあえずこれを書いてる2024年5月時点の最新安定版の1.9.9とします。

以下のようなbuild.sbtがあった時に、テストは何並列で実行されるでしょうか?

前回の記事の復習的な内容ですね。

実験用というか説明用なので、テストコードを全てコード生成で済ませていて、かつ、時間がわかりやすいようにsleepしたり直接printして表示してます。

Test / sourceGenerators += task {
  val dir = (Test / sourceManaged).value
  (1 to 40).map { n =>
    val src =
      s"""|package example
        |
        |class A${n} extends org.scalatest.freespec.AnyFreeSpec {
        |  def time(): String = java.time.format.DateTimeFormatter.ISO_LOCAL_TIME.format(java.time.LocalTime.now())
        |  def key: String = "test-database-name"
        |  "A${n}" in {
        |    println("start A${n} " + time() + ", " + key + " = " + sys.props.get(key))
        |    Thread.sleep(1000)
        |    println("end A${n} " + time())
        |  }
        |}
        |""".stripMargin
    val f = dir / s"A${n}.scala"
    IO.write(f, src)
    f
  }
}

scalaVersion := "3.3.3"

libraryDependencies += "org.scalatest" %% "scalatest-freespec" % "3.2.18" % Test

Test / fork := true

Test / javaOptions += "-Dtest-database-name=sample"

これは以下のように直列、かつ、別プロセスで実行されます。

それぞれ1秒sleepしていて、40個テストがあるため、約40秒かかりますね。

start A24 09:48:42.69435, test-database-name = Some(sample)
end A24 09:48:43.699796
[info] - A24
start A13 09:48:43.706461, test-database-name = Some(sample)
[info] A13:
end A13 09:48:44.711861
[info] - A13
[info] Run completed in 41 seconds, 52 milliseconds.
[info] Total number of tests run: 40
[info] Suites: completed 40, aborted 0
[info] Tests: succeeded 40, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 41 s, completed May 3, 2024, 12:48:44 AM

さて、単に並列実行させたいなら、前回の記事に書いたようにそもそも色々な方法があるわけですが、testGrouping使って並列化したい場合とは、 (前回の指摘されたコメントでほぼ答え書いてありますが)、結構条件が複雑で、改めて整理すると、例えば以下のようなものです。

  • そもそもsbtのsub projectを分けるなどの別の手法が取れてそれで十分なら、あるいは別に特定のsub projectが多少遅くても全体のボトルネックになっていなければ、必ずしもtestGroupingでの並列化による高速化は必要ない
    • 言い換えると、1つのsub projectだけすごくコードもテストも多くて巨大か、そもそもほぼsub projectが分割出来ていない、というパターン
  • あるいは、sub project分けなくても、databaseなどの外部リソース使ったテストが、そもそも(同じdatabase向いていても)並列実行が可能になっていれば、必ずしもtestGroupingでの並列化による高速化は必要ない
    • 頑張れば原理上不可能ではないとは思うが、例えば、特定のtableのsize取得するようなコードがあるだけで破滅するので、そういう規則でテストを書くのはかなりハードルが高いと思う
  • 接続先databaseが異なる、などの理由で、その単位でテスト用のJVM自体を分けたい
  • プログラムやconfigの仕組みによっては「接続先databaseをいい感じに分ける」という目的を達成するだけならば、テストコード側だけ工夫すればJVMそのものを分けなくても原理上不可能ではない可能性はあるが、JVM丸ごと分けて -Dkey=value の形でdatabase接続先などを渡してしまった方が楽である
  • ローカルでは困ってなくて、CIのみで困ってる場合で、CIでマシン丸ごと分ける手法(例: https://blog.flinters.co.jp/entry/2018/11/23/170627 )でいいならば、それでも良い
    • マシン丸ごと分割の方が、とても楽ではあるが、一般的にはお金かかったり微妙な無駄が多そう?
  • https://testcontainers.com 使ってそれぞれのテストごとに丸ごと毎回database使い捨てる手法もあるが、毎回起動だと遅いし、メモリなども消費し過ぎる

例としてdatabaseを挙げていますが、databaseに限らず、外部のミドルウェア使うようなものは一般的にこの問題が発生すると思います。

さて、testGrouping使って並列化したい場合に、上記のbuild.sbtにどういう変更をすればいいか?というと、以下です

gist.github.com

  • testGroupingを設定
  • concurrentRestrictionsでTags.ForkedTestGroupを増やす

という変更が必要です。 こうした場合の実行例は以下で、4並列にしたので、理論上は元の40秒の4分の1近い時間の10秒くらいで終わります。 ただ、今回の例ではhash使って分散させているので、それのばらけ具合によっては10秒ぴったりではなく、もう少し増えますね。 hashではなく雑にテスト名でsortして手動分割などでも(それで問題にならない場合は)いいでしょう。

[info] A38:
[info] A21:
[info] A10:
[info] A16:
start A38 10:05:21.360482, test-database-name = Some(1)
start A21 10:05:21.36494, test-database-name = Some(3)
start A10 10:05:21.36553, test-database-name = Some(0)
start A16 10:05:21.36632, test-database-name = Some(2)
end A38 10:05:22.368527
end A16 10:05:22.37179
end A10 10:05:22.373826
end A21 10:05:22.37328
[info] - A38
[info] - A16
[info] - A21
[info] - A10
[info] A36:
start A36 10:05:22.409257, test-database-name = Some(2)
[info] A7:
[info] A32:
start A7 10:05:22.411827, test-database-name = Some(1)
start A32 10:05:22.412079, test-database-name = Some(3)
[info] A5:
start A5 10:05:22.413837, test-database-name = Some(0)

途中省略

end A17 10:05:33.567344
[info] - A17
[info] Run completed in 12 seconds, 579 milliseconds.
[info] Total number of tests run: 40
[info] Suites: completed 40, aborted 0
[info] Tests: succeeded 40, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 13 s, completed May 3, 2024, 1:05:33 AM

今回は、並列化や高速化を例にして説明しましたが、このtestGroupingという機能は、あくまでテストを完全に任意の単位で、別プロセスか?同一プロセスか?含めて全て制御可能なので、他の用途にも使えます。

あまり多くの例思いつきませんが、例えば

  • 一部のテストはforkする必要があるけど、残りはforkして実行する必要がないので、その単位でgroup分ける
  • 一部のテストは、特定のコードを書くとメモリが足りなくなることそのもの(その際のエラーハンドリング)をテストしたいので、そのテストだけ隔離した別JVMにして、Xmxなどを小さくしてテスト

といった感じでしょうか。 もちろん、繰り返しになりますが、それを頑張り過ぎても複雑になり過ぎて、あとから見た人がわけわからなくなると思うので、本当に必要な場合以外は、そこまで凝った設定はやらなくていいとは思います。