search term:

テストの粒度

sbt、Bazel、その他多くのビルドツールにおいて、「テスト」という用語が多様なレベルにまたがることが多いため、 それを曖昧無く定義しておくことは特に事前、事後処理、並列処理などを考えるときに役に立つのではないかと思う。

先に書いてしまうと、テストには以下の 4つのレベルがある:

  1. テスト・コマンド
  2. テスト・モジュール
  3. テスト・クラス
  4. テスト・メソッドまたはテスト式

コマンドライン・インターフェイスとしてのテスト

最上位のレベルはビルド・ツールがユーザに test コマンドととして提供するものだ。

モジュールとしてのテスト

共通しているのは「コマンドとしてのテスト」がテストモジュールを集約して、それらを並列実行することだ。

Bazel に関して少し補足しておくと、テスト・モジュールの処理は非常に優秀だということだ。 デフォルトでテストの結果はキャッシュされ、キャッシングはリモート・キャッシュへと設定することができ、 実行環境をリモート・マシンへと設定することもできる。 そのため、ラップトップ上から環境を整えれば何百ものジョブを起動することができる。 また、ターゲットは従来のビルドツールよりも細かく作られ、理論上 .scala ファイルごとに scala_test(...) ターゲットを宣言して(別のマシンで)並列実行することができる。

クラスとしてのテスト

JUnit、MUnit、ScalaTest、Specs2、Hedgehog、Verify などの JVM テストフレームワークは関連するテスト・メソッドをクラスやオブジェクトを用いてグループ化する。 Scala では、これらのテスト・クラスが FunSuite のように「suite」と名付けられることがあるが、JUnit における Suite は 複数のテスト・クラスを集約する特殊なテスト・クラスを指す。

メソッドもしくは式としてのテスト

JVM テストフレームワークでは、テストコードはメソッドもしくは test("...") { ... } といった形の式に書かれる。 テスト・クラスと区別するために、テスト・メソッドは「テスト例」と呼ばれることもある。

テスト・メソッド実行の並列度はランナーの実装による。

特定のテスト・メソッドを選択して実行する機能は未解決の問題として残されていてKamil Podsiadło さんが Metals におけるこの機能の追加作業を行っているみたいだ。

sbt/sbt#911 はテスト・メソッドの選択の標準化のためにリオープンされるべきかもしれない。現行ではテストフレームワークが testOnly -- 経由で渡される引数を処理する形でこの機能を実装しているものもある:

並列度

これまで見てきたように、4つのレベルそれぞれにおいて並列度を考察することができる。

コマンドレベルでは、テスト・コマンドの並列化は別の CI ワーカーから実行することで複数の JDK を同時にテストするといったことだと考えることができる。

ビルドツールとしては、モジュールレベルの並列度が最も粒度が低く、sbt と Bazel の両者とも独立なテスト・モジュールはデフォルトで並列実行される。 この並列実行が実際にどのようにスケジュールされるかは実装によるが、sbt ではタスクを並列度の予算 を表すタグに関連付けて、全体では Global / concurrencyRestrictions を設定できる実験的機能がある。 この機構を使って特定のモジュールを排他的に実行して他は並列に走らせるといったことが可能になる。 実行環境としては、Bazel は別プロセスに fork させるが、sbt はサンドボックス化されたスレッド内で実行されるのがデフォルトだ。

クラスレベルでは、sbt はテストクラスをタスクにマッピングすることで自動的に並列実行を行う。 一般論として、このようなテストフレームワーク特定の知識が必要になる場面では、JVM 特定のビルドツールが優位になる。

メソッドレベルの並列実行はテストフレームワークによって実装されている。そのため、メソッドレベルでの並列処理は fork では無くスレッドのみとなっている。

参考までに、Maven の Surefire Plugin には parallel という属性があり、methodsclassesbothsuitessuitesAndMethodsclassesAndMethodsall のいずれかの値を取ることができる。

事前および事後処理

事前および事後処理も 4つのレベルで考察することができる。

コマンドレベルでの事前処理は存在しないが、pretest;test;posttest; のようなものだと思う。 ここでは、pretest は何らかのテスト環境を準備するコマンドで、test を実行し、posttest で片付けを行う。

モジュールレベルでは、sbt は Test / testOptionsTests.Setup( loader => ... ) を追加することができる。 理論上はこれを用いて何らかのテスト環境を準備できる。

クラスレベルでは、事前処理は fixture と呼ばれることがあり、テストフレームワークがその機能を提供していることが多い。 例えば、JUnit は @BeforeClass@AfterClass というアノテーションを提供する。

メソッドレベルの事前処理は、各テストメソッドの前に呼ばれる。 JUnit は @Before@After アノテーションを提供する。

まとめ

テストについて考えるとき、テスト・コマンド、テスト・モジュール、テスト・クラス、そしてテスト・メソッドもしくはテスト式という 4つのレベルがある。

それぞれのレベルにおいて潜在的に並列処理が可能であり、ビルドツールやテストフレームワークの runner によって別プロセスに fork されたりスレッドによって実装されていたりする。 ビルドツールの多くはテスト・モジュールの並列実行をこなすことができるが、テスト・クラスやテスト・メソッドの選択および並列化のサポートはまばらである。

Bazel は testOnly こそ無いが、より細かいテスト・ターゲット、名前付きの集約、リモート・キャッシュ、リモート実行など強力な機能を持っている。