テストの粒度
sbt、Bazel、その他多くのビルドツールにおいて、「テスト」という用語が多様なレベルにまたがることが多いため、 それを曖昧無く定義しておくことは特に事前、事後処理、並列処理などを考えるときに役に立つのではないかと思う。
先に書いてしまうと、テストには以下の 4つのレベルがある:
- テスト・コマンド
- テスト・モジュール
- テスト・クラス
- テスト・メソッドまたはテスト式
コマンドライン・インターフェイスとしてのテスト
最上位のレベルはビルド・ツールがユーザに test
コマンドととして提供するものだ。
- ユーザが sbt シェルに
test
と打ち込むか、ターミナルからsbt --client test
と打ち込むと、sbt のコマンド・エンジンは「test」を集約リストに列挙されたサブプロジェクト内でのタスク実行へと持ち上げる。 例えばroot
サブプロジェクトがcore
とutil
というサブプロジェクトを集約する場合、test
はroot/Test/test
、core/Test/test
、util/Test/test
の並列実行だと解釈される。 僕はこの振る舞いをコマンド・ブロードキャストと呼んでいる。 - Bazel ではこのブロードキャストはより明示的にユーザによって行われる。
例えば、ユーザが
bazel testl example/...
と打ち込むと、Bazel はexample1/
ディレクトリ以下の全てのテスト・ターゲットを再帰的にクエリして、発見されたテスト・ターゲットを並列的にテストする。
モジュールとしてのテスト
共通しているのは「コマンドとしてのテスト」がテストモジュールを集約して、それらを並列実行することだ。
- sbt は典型的にテスト・モジュールをサブプロジェクトと
Test
コンフィグレーションのペアとして表す。 - Bazel はテスト・モジュールを
scala_test(...)
のような何らかのターゲットとして表す。 Bazel は rules_scala のscala_test_suite(...)
のような名前付きのテスト集約も提供する。
Bazel に関して少し補足しておくと、テスト・モジュールの処理は非常に優秀だということだ。
デフォルトでテストの結果はキャッシュされ、キャッシングはリモート・キャッシュへと設定することができ、
実行環境をリモート・マシンへと設定することもできる。
そのため、ラップトップ上から環境を整えれば何百ものジョブを起動することができる。
また、ターゲットは従来のビルドツールよりも細かく作られ、理論上 .scala
ファイルごとに
scala_test(...)
ターゲットを宣言して(別のマシンで)並列実行することができる。
クラスとしてのテスト
JUnit、MUnit、ScalaTest、Specs2、Hedgehog、Verify などの JVM テストフレームワークは関連するテスト・メソッドをクラスやオブジェクトを用いてグループ化する。
Scala では、これらのテスト・クラスが FunSuite
のように「suite」と名付けられることがあるが、JUnit における Suite
は
複数のテスト・クラスを集約する特殊なテスト・クラスを指す。
- sbt は継承ベース及びアノテーション・ベースのテスト・クラスという概念を標準化し、各々を内部タスクに割り当てるため、テスト・クラスは追加設定をすること無く並列に評価される。
- Bazel では、rules_scala が提供するランナーはテスト・クラスを並列実行しないが、別のランナーへとカスタマイズすることができる。
メソッドもしくは式としてのテスト
JVM テストフレームワークでは、テストコードはメソッドもしくは test("...") { ... }
といった形の式に書かれる。
テスト・クラスと区別するために、テスト・メソッドは「テスト例」と呼ばれることもある。
テスト・メソッド実行の並列度はランナーの実装による。
-
例えば、ScalaTest はメソッドをデフォルトでは逐次実行する。 これは
ParallelTestExecution
trait をミックスインすることで並列に変更することができる。 -
逆に Specs2 はデフォルトでテスト・メソッドを並列実行する。 この振る舞いは
def is
にsequential
を追加することで変更することができる。
特定のテスト・メソッドを選択して実行する機能は未解決の問題として残されていてKamil Podsiadło さんが Metals におけるこの機能の追加作業を行っているみたいだ。
sbt/sbt#911 はテスト・メソッドの選択の標準化のためにリオープンされるべきかもしれない。現行ではテストフレームワークが testOnly --
経由で渡される引数を処理する形でこの機能を実装しているものもある:
- junit-interface:
testOnly -- example.HelloTest.testHello
- Scala Test:
testOnly example.HelloTest -- -z testHello
- Specs2:
testOnly example.HelloTest -- ex testHello
並列度
これまで見てきたように、4つのレベルそれぞれにおいて並列度を考察することができる。
コマンドレベルでは、テスト・コマンドの並列化は別の CI ワーカーから実行することで複数の JDK を同時にテストするといったことだと考えることができる。
ビルドツールとしては、モジュールレベルの並列度が最も粒度が低く、sbt と Bazel の両者とも独立なテスト・モジュールはデフォルトで並列実行される。
この並列実行が実際にどのようにスケジュールされるかは実装によるが、sbt ではタスクを並列度の予算
を表すタグに関連付けて、全体では Global / concurrencyRestrictions
を設定できる実験的機能がある。
この機構を使って特定のモジュールを排他的に実行して他は並列に走らせるといったことが可能になる。
実行環境としては、Bazel は別プロセスに fork させるが、sbt はサンドボックス化されたスレッド内で実行されるのがデフォルトだ。
クラスレベルでは、sbt はテストクラスをタスクにマッピングすることで自動的に並列実行を行う。 一般論として、このようなテストフレームワーク特定の知識が必要になる場面では、JVM 特定のビルドツールが優位になる。
メソッドレベルの並列実行はテストフレームワークによって実装されている。そのため、メソッドレベルでの並列処理は fork では無くスレッドのみとなっている。
参考までに、Maven の Surefire Plugin
には parallel
という属性があり、methods
、classes
、both
、
suites
、suitesAndMethods
、classesAndMethods
、all
のいずれかの値を取ることができる。
事前および事後処理
事前および事後処理も 4つのレベルで考察することができる。
コマンドレベルでの事前処理は存在しないが、pretest;test;posttest;
のようなものだと思う。
ここでは、pretest
は何らかのテスト環境を準備するコマンドで、test
を実行し、posttest
で片付けを行う。
モジュールレベルでは、sbt は Test / testOptions
に Tests.Setup( loader => ... )
を追加することができる。
理論上はこれを用いて何らかのテスト環境を準備できる。
クラスレベルでは、事前処理は fixture と呼ばれることがあり、テストフレームワークがその機能を提供していることが多い。
例えば、JUnit は @BeforeClass
と @AfterClass
というアノテーションを提供する。
メソッドレベルの事前処理は、各テストメソッドの前に呼ばれる。
JUnit は @Before
と @After
アノテーションを提供する。
まとめ
テストについて考えるとき、テスト・コマンド、テスト・モジュール、テスト・クラス、そしてテスト・メソッドもしくはテスト式という 4つのレベルがある。
それぞれのレベルにおいて潜在的に並列処理が可能であり、ビルドツールやテストフレームワークの runner によって別プロセスに fork されたりスレッドによって実装されていたりする。 ビルドツールの多くはテスト・モジュールの並列実行をこなすことができるが、テスト・クラスやテスト・メソッドの選択および並列化のサポートはまばらである。
Bazel は testOnly
こそ無いが、より細かいテスト・ターゲット、名前付きの集約、リモート・キャッシュ、リモート実行など強力な機能を持っている。