sbtにおけるテストの並列実行の設定詳細解説

いきなり本題というか、一番言いたいことを書くと、まず

テストの fork の設定によってぜんぜん違う。

という点があまり知られていない気がします。 *1

というか、自分も今回調べるまで、微妙に古い知識のままで完璧に知らなかったので、今一度理解した現時点での詳細を今書いています。

sbtにおいて、テストがどう並列化されるか?に関して、関係するというか、今回話すのは、以下の点です

  • Test / parallelExecutionというkey
  • Test / fork が true か falseか
  • concurrentRestrictionsというkey
  • testForkedParallelというkey

また、今から話すのはsbt 1.3.8時点の情報です。

さらに前提として、マシンのCPUのコア数によって挙動が異なる可能性がありますが、ひとまずそれなりに十分にコア数がある、として話を進めます。(少なくとも4以上) *2

まず、実験のために、以下のようなbuild.sbtを考えます

val commonSettings = Def.settings(
  scalaVersion := "2.13.1",
  libraryDependencies ++= "org.scalatest" %% "scalatest" % "3.1.1" % "test"
)

commonSettings

val a = project.settings(commonSettings)
val b = project.settings(commonSettings)

このとき、以下のように3つのScalaのテストファイルを置いておきます。

├── a
│ └── src
│   └── test
│     └── scala
│       ├── A1.scala
│       └── A2.scala
├── b
│ └── src
│   └── test
│     └── scala
│       └── B1.scala

中身は何でも良いのですが、並列化されているかどうか?がわかるように、わざとprintしたりsleepするようなものにしておきます。

package example

import org.scalatest.funspec.AnyFunSpec

class A1 extends AnyFunSpec {
  describe("A1") {
    it("test") {
      println("start " + getClass + " " + new java.util.Date)
      Thread.sleep(3000)
      println("end " + getClass + " " + new java.util.Date)
    }
  }
}

さて、まずこの状態で、単に sbt test:compile test とするとどうなるかわかりますか? *3

答えは、

A1, A2, B1すべてが並列実行される

です。

このとき、つまりデフォルトでは、最初に挙げた各種設定がどうなっているか?というと

  • Test / parallelExecution は true
  • Test / fork は false

です。ただ、少し前に書きましたが、これは "十分にコア数がある" 場合であって、コア数が2や1の場合は、すべては並列実行されない可能性があります。 *4

この状態で並列数を制御したい場合は、例えば以下のように書くと、最高2並列までになるはずです。

ThisBuild / concurrentRestrictions := {
  List(
    Tags.limitAll(2), // ここが独自にカスタマイズした部分。ほかはsbtのデフォルトをコピペ
    Tags.limit(Tags.ForkedTestGroup, 1),
    Tags.exclusiveGroup(Tags.Clean)
  )
}

https://github.com/sbt/sbt/blob/v1.3.8/main/src/main/scala/sbt/Defaults.scala#L1253-L1261

sbtの大まかな歴史からするとconcurrentRestrictionsというのは多少後半になって導入されたものです。 これを全部解説すると脱線するというか長くなるので、詳細は公式ドキュメントなどを読んでください。

さて、次に、(concurrentRestrictionsの設定はもとに戻して) Test / parallelExecution を false にした場合はどうなるでしょうか?(つまりbuild.sbtは以下の状態)

val commonSettings = Def.settings(
  Test / parallelExecution := false,
  scalaVersion := "2.13.1",
  libraryDependencies += "org.scalatest" %% "scalatest" % "3.1.1" % "test"
)

// ここから下のsub project定義はずっと同じなので次回以降省略

その場合は

A1とB1(あるいはA2とB1)は並列に実行されるが、A1とA2は同時に実行されない

という挙動になります。

つまり

Test / parallelExecution というのは、(forkしていない場合)

あくまで各sub project内のテスト同士を並列実行するかどうか?を制御するもの

であり

異なるsub project(今回の場合、aとb)の並列実行は Test / parallelExecution := false にしただけでは無効化されない

ということです。

さて、次はforkした場合の挙動を見てみましょう。(parallelExecutionはデフォルトに戻します)

val commonSettings = Def.settings(
  Test / fork := true,
  scalaVersion := "2.13.1",
  libraryDependencies += "org.scalatest" %% "scalatest" % "3.1.1" % "test"
)

この状態では

すべて直列実行されます

これが最初に書いた "テストの fork の設定によってぜんぜん違う" の1つ目ですね。

では、fork有効のまま、次は Test / parallelExecution をtrueにしてみましょう・・・と言いたいところですが、parallelExecutionはデフォルトではtrueです。 なのに全部直列実行されました。これは一体どういうことでしょう?

つまり Test / parallelExecution はforkがtrueの場合は、意味がない、関係ないようです(若干自信がない・・・)

そこで登場するのがtestForkedParallelとconcurrentRestrictionsです。

https://github.com/sbt/sbt/blob/v1.3.8/main/src/main/scala/sbt/Keys.scala#L279

testForkedParallelという名前の通りなのですが、これを以下のようにforkはtrueにしつつ、これもtrueに設定して実行してみましょう。

(testForkedParallelはデフォルトfalse)

val commonSettings = Def.settings(
  Test / fork := true,
  testForkedParallel := true,
  scalaVersion := "2.13.1",
  libraryDependencies += "org.scalatest" %% "scalatest" % "3.1.1" % "test"
)

すると

A1とA2は並列に実行されるが、A1とB1、あるいはA2とB1は並列に実行されません。

つまり、testForkedParallelは、各sub project内のテスト同士の関係にのみ影響を与えるので

各sub project内のテストクラス同士の並列化は

  • forkした場合はtestForkedParallel
  • forkしていない場合はparallelExecution

と言い換えるというか、まとめることができます。

では、最後にconcurrentRestrictionsですが、ここまでしっかり読んだ方は、もう残りの選択肢というかあり得る挙動として、ほぼ1つしか残っていない勘付かれると思いますが、つまり

"forkした場合に、A1とB1のような、異なるsub project間のテストも並列実行したい場合"

にはconcurrentRestrictionsのkeyを独自に設定する必要があります。

concurrentRestrictionsはデフォルトでは Tags.limit(Tags.ForkedTestGroup, 1) となっている部分がありますが、ここを増やす必要があります。

つまり以下の設定だと "A1, A2, B1すべてが並列実行" されます

val commonSettings = Def.settings(
  Test / fork := true,
  testForkedParallel := true, // これもtrueに設定したまま
  concurrentRestrictions := {
    val par = parallelExecution.value
    val max = EvaluateTask.SystemProcessors
    List(
      Tags.limitAll(if (par) max else 1),
      Tags.limit(Tags.ForkedTestGroup, 2), // ここ増やした
      Tags.exclusiveGroup(Tags.Clean)
    )
  },
  scalaVersion := "2.13.1",
  libraryDependencies += "org.scalatest" %% "scalatest" % "3.1.1" % "test"
)

またconcurrentRestrictionsのTags.ForkedTestGroupは増やすが、testForkedParallelはfalse(デフォルト)だと

val commonSettings = Def.settings(
  Test / fork := true,
  testForkedParallel := false, // デフォルトfalseなので省略しても同様
  concurrentRestrictions := {
    val par = parallelExecution.value
    val max = EvaluateTask.SystemProcessors
    List(
      Tags.limitAll(if (par) max else 1),
      Tags.limit(Tags.ForkedTestGroup, 2), // ここ増やした
      Tags.exclusiveGroup(Tags.Clean)
    )
  },
  scalaVersion := "2.13.1",
  libraryDependencies += "org.scalatest" %% "scalatest" % "3.1.1" % "test"
)

A1とB1、あるいはA2とB1は並列実行される可能性があるが、A1とA2は並列実行されない。

となります。

これで、sbtにおけるテストの並列実行の設定に関して、大体のことを解説したつもりですが、なにか抜けやあきらかな間違いがあったら、コメントやtwitterで教えて下さい。

ちなみに、なぜこのようにforkした場合とそうでない場合で色々違って、ある意味複雑に設定が増えたのか?の歴史は調べてません。 興味がある人はこれらの機能が入ったgithubのpull requestなどを見てみましょう。

追記: コメントでも指摘が一部ありましたが、ここまでずっと並列としか書いていませんが、複数JVMプロセスによる並列実行なのか、1プロセス内の複数スレッドによる並列実行なのか?を特に区別なく書いてましたね。どっちのことを言っているのか?は、今さら追記する気力がないので、読者への宿題にしておきます、すいません…

*1:もちろん公式ドキュメントには書いてあるのでそれは読みましょうというか、完璧に読んだことある人は、このblog読む必要ないはずです https://www.scala-sbt.org/1.x/docs/Testing.html

*2:specs2などは、更に別の概念として、1つのテストclass内のテスト並列実行する機能があったはずですが、今回はsbt自体の機能の話なので、そこには触れません

*3:test:compileをはさんでいるのは、test:compileとtestが並列実行されるとややこしいので、純粋にtestが並列実行されるのか?をわかりやすくするため

*4:コア数というか java.lang.Runtime.getRuntime.availableProcessors で取得できる値