※本記事は、Juliette de Rancourt、Matthias Merdes による “Beyond the simple: An in-depth look at JUnit 5’s nested tests, dynamic tests, parameterized tests, and extensions” を翻訳したものです。
人気のJUnitフレームワークは、新しいJUnit Jupiterテスト・エンジンの登場により、柔軟性が格段に増し、テストのニーズを満たすための自在なカスタマイズも可能となっている
August 28, 2020
オリジナル版のJUnitは、1997年に飛行機でペアプログラミングされたという伝説があります。Kent Beck氏とErich Gamma氏はその開発から離れましたが、JUnitがとても活発である点は変わりません。それから時は流れ、いくつかのバージョンのJUnitが登場しました。最新版のJUnit 5は、完全に新しいアーキテクチャになっているだけでなく、テスト作成者向けの新機能が多数導入されています。
JUnit 5で刷新されたアーキテクチャにより、JUnitプラットフォームという考え方が登場しています。簡単に言えば、この新しいプラットフォームには、IDEおよびビルド・ツールにテスト・フレームワークを組み込むためのAPIや、テスト・エンジンを実装するための抽象化が含まれています。
このプラットフォームでは、1回のテスト実行で複数のテスト・エンジンを実行できます。代表は、古いJUnit Vintageテスト・エンジン(JUnit 3とJUnit 4のテスト用)と、まったく新しいJUnit Jupiterエンジンです。この新しいエンジンでは、JUnit 5のプログラミング・モデルを実装しています。本記事では、JUnit Jupiterテストの作成と実行に関するいくつかの高度な機能に注目します。具体的には、ネストしたテスト、動的テスト、パラメータ化テストです。
本記事は、日々の作業において何らかの形態でコードをテストしているプログラマーを対象にしています。たとえば、JUnit 4やTestNGのテストなどです。JUnit 5を使った経験があれば理解しやすくなりますが、絶対に必要なものではありません。
一般的な情報については、JUnit 5を特集したJava Magazineをご覧ください。JUnit 5の最近の進化については、Mert Çalişkan氏の記事で詳しく説明されています。
ネストしたテスト
ネストしたテストについてJUnit 5チームが初めて検討したのは2015年であり、当時はJUnit Jupiterテスト・エンジンの初期計画段階でした。しかし、全員がすぐに納得したわけではありません。このテストにはネスト・クラスが必要です。そのため、標準のネストしていないクラスで定義したテストよりも、はるかに複雑な実装になります。最終的に、チームはネストしたテストをフレームワークに含めることにしました。他の手段では容易には実現できない特別な表現力が提供されるからです。
ネストしたテストは、機能を階層的に分割する方が、直線的に分割するよりも見通しがよくなるユースケースすべてに適しています。特に、後ほど紹介する例のような、実装のプロトコルや状態オートマトンをチェックするテストなどです。JUnit Jupiterでは、@Nestedアノテーションを使うことで、ネストしたテストの定義を直接的にサポートしています。大まかに言えば、この機能はJUnit 4の拡張機能であるHierarchicalContextRunnerに似ています。
インナー・クラスを使ってテストをネストさせると、テスト構造を階層的に考えやすくなりますが、この仕組みはまず何よりもレイアウト・ツールです。内側のテストは当然ながら外側のテストに依存するため、原理的には、内側のテストでは外側のテストが設定した事前条件を2つの方法で再利用できます。
一番シンプルに実行できるのは、外側のライフサイクル・メソッドによるセットアップを内側のテストから再利用する方法です。パラメータ付きで@BeforeEachなどのアノテーションを付加した、外側のライフサイクル・メソッドは、包含階層のすべてのレベルに配置でき、常に内側のテストのために実行されます。この状況では、ネストしたテストにおいてクラス・レベルの@BeforeAllメソッドはサポートされないことに注意してください。Javaではインナー・クラス内の静的メソッドが許可されないためです。
階層的なセットアップ・メソッドを持つネストしたテストの例を示します。
class NestedTestWithHierarchicalSetupMethods {
String state = "";
@BeforeEach
void outerSetup() {
state = state + "outer";
}
@Nested
class InnerClass {
@BeforeEach
void innerSetup() {
state = state + "-inner";
}
@Test
void checkSetup() {
assertEquals("outer-inner", state);
}
}
}
上記の例で使われている標準のインスタンス化ライフサイクルでは、テスト・メソッドを実行するたびにテスト・クラスが最初からインスタンス化されることになります。これは通常のテストでは確かに優れた方法で、すべてのバージョンのJUnitでデフォルトの動作になっています。別のやり方として、テスト・クラス全体を一度だけインスタンス化することもできます。これを行うには、@TestInstance(TestInstance.Lifecycle.PER_CLASS)を使ってテスト・インスタンスのライフサイクルを調整します。これにより、内包される側のテストにおいて、内包する側のテストで設定された状態を使用できるようになります。次の例では、内側のテストにおいて、外側のテストで設定された状態を参照し、その値のアサートを行うことができます。
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class NestedTestWithInstantiationPerClass {
String outerState = null;
@Test
void setState() {
this.outerState = "outer";
}
@Nested
class InnerClass {
@Test
void checkState() {
assertEquals("outer", outerState);
}
}
}
上記の例では、クラスごとのライフサイクルを使っています。こうすると、Jupiterテスト・エンジンで、ネストしたテスト全体のインスタンスが一度だけ作成されるようになります。テスト・メソッドが実行されるたびに何度も作成されることはありません。このやり方は、テスト・メソッドの順番が影響すべきでない通常のテストでは推奨されませんが、ネストしたテストでは役立つ場合があります。セットアップ・コードなしに、プロトコル的な呼出しシーケンスを階層中に広げることができるからです。なお、順番は包含階層によってのみ決まり、ネストしたクラスの中では固定されない点に注意してください。どちらのテクニックも、プロトコル的な呼出しシーケンスの単純で自然なモデリングに使用できます。
以上の例はかなり理論的で作為的なものでしたが、次はもう少し現実的なユースケースを取り上げましょう。次の例は、セットアップ・コードで階層的ライフサイクル・メソッドを使用することにより、外側のテストの事前条件を内側のテストで使用できることを示しています。この例には、customerオブジェクトのCRUD(作成、読取り、更新、削除)メソッドを提供するデータ・アクセス・オブジェクト(DAO)が登場します。簡略化するため、最初に格納するオブジェクトにID 1を割り当てています。
@DisplayName("A customer object")
class DAOTest {
CustomerDAO dao = new CustomerDAO();
@Test @DisplayName("can be created with the dao")
void canBeCreated() { dao.create("Alice"); }
@Nested @DisplayName("when created")
class WhenCreated {
Customer customer;
@BeforeEach
void setup() { customer = dao.create("Alice"); }
@Test @DisplayName("it must be saved to the dao")
void mustBeSaved() { assertEquals(1L, dao.save(customer)); }
@Nested @DisplayName("after saving a customer")
class AfterSaving {
@BeforeEach
void setup() { dao.save(customer); }
@Test @DisplayName("it can be fetched from the dao")
void canBeFetched() { assertTrue(dao.fetch(1L).isPresent()); }
@Test @DisplayName("it cannot be deleted by wrong id")
void cannotBeDeletedByWrongId() {
assertThrows(IllegalArgumentException.class, () -> dao.delete(-77L));
}
}
}
}
上記の例は、内側のテストが実行される前に、外側のテストのセットアップ・コードが実行されることを示しています。外側のテストにすでに存在している機能が内側のテストに含まれる場合、内側のテストのセットアップ・コードで何らかの重複が生じてしまう可能性はありますが、すべてのテストを独立して実行することができるようになります。外側のテストのセットアップ・コードは常に実行されるので、外側のテストを実行せずに内側のテストだけを実行することもできます。外側のテスト・メソッドで設定された状態を内側のテストで使いたい場合は、セットアップ・コードを省略できます。しかし、前述した、クラスごとのライフサイクル・セマンティックを使用する必要があります。
この例では、@DisplayNameアノテーションを多用しています。このアノテーションはJUnit 5の汎用ツールであり、Javaのメソッドの厳格なネーミング規則よりも柔軟にテストの名前付けを行うことができます。ネストしたテストを実行すると、結果は見やすい形でIDEに表示されます(図1参照)。適切な表示名を設定すれば、この階層構造はかなり仕様に近い表現になり、ビヘイビア駆動型開発フレームワークの表現力に匹敵します。
図1:IDEでのネストしたテストの実行
ネストしたテストを使うと、関連した複数のテストを階層的にまとめることができますが、関連した複数のテストを作成する仕組みは他に2つあります。具体的には、動的テストとパラメータ化テストですが、どちらも階層構造は持ちません。以降のセクションでは、この2つについて説明します。
動的テストと TestFactory
パラメータ化テストとネストしたテストはJUnit 4でも何らかの形で使えましたが、動的テストという概念はJUnit 5でまったく新しく登場しました。この新機能は、@TestFactoryアノテーションという形態になっています。
このアノテーションが付加されたメソッドの中にテストはなく、メソッド自体もテストではありません。このメソッドでは、テスト・ケースのストリームまたは集合を生成します。TestFactoryという名前はそこから来ています。このストリームは実行時にのみ生成されます。作成時やコンパイル時には長さがわからないからです。ただしIDEでは、JUnitプラットフォームで公開されるリスナーを通じて、実行ツリーのストリームに含まれるテスト・ケース1つにつき、1つのエントリを表示できます。
JUnitプラットフォームのイベント・リスナーは、ツール・ベンダー向けの新しいJUnit 5 APIの一部であり、JUnitプラットフォームとIDEなどのツールとの統合が容易になります。動的テストでは名前も動的にできるので、柔軟で表現力が高いIDE表示を実現できます。IDEには動的テストの数を事前に知る手段はなく、動的な名前も当然わからないので、実行表示ツリーのエントリを実行時に生成しなければなりません。この処理は、それぞれの動的テストの開始イベントで始まります。
実行時にイベントを受け取るという柔軟性を実現できるのは、検出フェーズと実行フェーズを区別するというJUnitプラットフォームの考え方があるからです。検出時にはテスト・ファクトリのみが認識されますが、個々のテストは実行フェーズの、あるタイミングで表示されます。
ここではまず、非常にシンプルな例を紹介します。次に示すリストのコードは、2つのテストを生成するテスト・ファクトリを示しています。1つ目は成功するテスト、2つ目は失敗するテストです。この場合、2つのDynamicTestインスタンスのリストは固定であり、テストを書くときにわかっています。ここではあえて短い例にしましたが、動的テストの作成の基本を示すには十分です。実際のユースケースでは、少数の固定のテストを作成するなら標準(@Test)のテストを使う方がシンプルでしょう。
@TestFactory
List<DynamicTest> simple() {
return Arrays.asList(
dynamicTest("first", () -> assertTrue(true)),
dynamicTest("second", Assertions::fail)
);
}
次の例もまだ多少作為的ではありますが、長さがわからないストリームを使ってテスト・ケースのリストを生成する機能を示しています。乱数のストリームを生成し、そのストリームの数値が10億を超えない限り取り出し続けます。その後に作成する実際のテストでは、正の数かどうかのチェックだけを行います。
Random random = new Random();
@TestFactory
Stream<DynamicTest> withStream() {
return Stream.generate(random::nextInt)
.takeWhile(n -> n < 1000000000)
.map(n -> dynamicTest("is positive? " + n, () -> isPositive(n)));
}
private void isPositive(Integer n) {
assertTrue(n > 0);
}
このテスト・ファクトリを1回実行したときの結果を図2に示します。テスト・ファクトリは純粋に(そして多少恣意的に)動的に作成したので、もう一度実行すると、テスト結果は多くなるかもしれませんし、まったくなくなるかもしれません。

図2:IDEでの動的テストの実行
初歩的な例は純粋に技術的なものでしたが、次は金融領域でのユースケースについて考えてみましょう。動的テストは、多くの場合、外部リソースとの相互作用を検証する結合テストに利用できます。次の例では、支払いの有効性をチェックするタスクを与えられたと考えてください。まず、List<Customer> getCustomers()を使い、リモートの可能性があるサービスから複数のCustomerオブジェクトをフェッチします。次に、それぞれのカスタマーについて、Stream<Payment> findAllPayments(Customer customer)を使ってデータベース・リポジトリから個々の支払いのストリームを受け取ります。
次に示すのは、2つのストリームを動的に結合するテスト・ファクトリのコードです。
CustomerService customerService = new CustomerService();
PaymentRepository paymentRepository = new PaymentRepository();
@TestFactory
Stream<DynamicTest> withCombinedStreams() {
return customerService.getCustomers()
.stream()
.flatMap(c -> paymentRepository.findAllPayments(c))
.map(p -> dynamicTest("valid payment? " + p, () -> validate(p)));
}
void validate(Payment payment) {
//いくつかのアサート
}
上記のテスト・ファクトリ・メソッドでは、flatMap()を使ってカスタマーのストリームと支払いのストリームを結合しています。リモート・サービスから受け取るカスタマーの数も、各カスタマーの支払いの数もわからない可能性があるので、当然ながら結合ストリームのサイズはわかりません。つまり、この例は、生成されるテストの数が作成時にもコンパイル時にもテスト生成時にもわからない結合テストのユースケースを示していることになります。
もちろん、アサートを行うメソッドを繰り返すだけで、こういったテストに近いことを実現できます。しかし、その方法では、一連の論理テストが1つの物理テストにまとまってしまうことになります。単一テストで多数のアサートを繰り返せば、結果表示の本質が失われ、失敗時のデバッグははるかに難しくなります。
以上の例はすべて、このような機能はラムダ式を使ってエレガントかつ簡潔に実装できることを示しています。JUnit 5の初期計画フェーズにおいて、Java 5との下位互換性を維持しない理由の1つになったのが、この機能に代表される機能の表現力です。実のところ、JUnitの新バージョンの元になったプロジェクトは、まさに「JUnit Lambda」と呼ばれていました。
結局のところ、実行時に動作し、数が不明なテストにおいて、名前を柔軟に生成したいというユースケースに対応した軽量ツールが動的テストです。テスト・ファクトリで生成される、ストリーム内の個々のテスト・ケースは、成熟した標準のテストとして使うことを意図したものではありません。そのため、きめ細かなライフサイクル・メソッドはサポートされず、ライフサイクル・メソッドはテスト・ファクトリ・メソッドのみに適用されます。複数のテストのシーケンスでそれぞれのライフサイクル・メソッドを使う必要がある場合は、次に紹介するパラメータ化テストの方がよいでしょう。
パラメータ化テスト
パラメータ化テストは、テスト・フレームワークにかかわらず頻繁に使われ、重宝されている機能です。朗報なのは、JUnit Jupiterテスト・エンジンがこの分野に絶大な進化をもたらしたことです。この種のテストはJUnit 4よりも冗長でなくなり、はるかに書きやすく、読みやすくなっています。また、新たな方法でパラメータ化テストを作成することもできるので、機能全体が緻密で強力なものになっています。
パラメータ化テストを試してみたい場合、その第一歩はjunit-jupiter-params依存性を追加することです。それよりも簡単なのが、バージョン5.4以降でjunit-jupiterアグリゲートを使用する方法です。このアグリゲートには、junit-jupiter-apiおよびjunit-jupiter-paramsのアーティファクトが両方とも含まれます。
繰り返しますが、新しいパラメータ化テストは、以前よりもはるかに冗長でなくなっています。ランナーもフィールドへのアノテーションも必要ありません。必要なのは@ParameterizedTestだけで、パラメータはメソッドの引数として渡します。次に示すのは、考えられる最小のパラメータ化テストの例です。
@ParameterizedTest
@ValueSource(strings = {"Java", "JUnit"})
void a_very_concise_test(String name) {
assertTrue(name.startsWith("J"));
}
値は@ValueSourceアノテーションで提供されていることに注意してください。このアノテーションは、1つのプリミティブ・パラメータがある場合に便利です。値としてnullを使う必要があるテストでは、テストに@NullSourceアノテーションを付加します。また、空の値(文字列の場合は””)をテストする場合は、@EmptySourceや@NullAndEmptySourceを使うこともできます。
複数のパラメータを使うテストでも心配は無用です。@CsvSourceが役立ちます。このアノテーションを使うと、CSVのような形式で必要な数だけパラメータを渡すことができます。次の例をご覧ください。データが実際のファイルに格納されている場合は、@CsvFileSourceで注入することもできます。
@ParameterizedTest
@CsvSource({
"Java, 4",
"JUnit, 5"
})
void a_slightly_more_complex_test(String name, int expectedLength) {
assertEquals(expectedLength, name.length());
}
ここでおもしろい点は、expectedLength引数の型です。@CsvSourceで渡せるのはStringパラメータだけであるにもかかわらず、この引数の型はintです。ここには、JUnit内部のちょっとしたマジックが働いています。この仕組みには、Stringをintなどの別の型として解釈できる暗黙的なコンバータが使われています。この例はさほど優れているようには見えないかもしれませんが、この仕組みは、File、Path、BigDecimal、URLなど、よく使われる多くの便利な型でも機能します。さらに、Java Data and Time APIのほとんどをサポートし、列挙型にも対応しています。すべての暗黙的コンバータのリストは、ユーザー・ガイドに掲載されています。
@ParameterizedTest
@CsvSource({
"2020-06-01, JUNE",
"2019-04-15, APRIL"
})
void putting_those_converters_to_good_use(LocalDate date,
Month expectedMonth) {
assertEquals(expectedMonth, date.getMonth());
}
概して言えば、必要な定型コードがコンバータのおかげで少なくなり、テストは実際のテスト・ロジックに的が絞られるようになります。しかし、これが暗黙的コンバータであるなら、明示的コンバータもあるはずです。
実は、次の例のように、SimpleArgumentConverterを拡張して@ConvertWithの引数として渡すと、独自のコンバータを作成できます。
同様に、ArgumentsAggregatorを実装して@AggregateWithとともに使用すると、複数のパラメータを1つの引数にまとめるアグリゲータを書くこともできます。
@ParameterizedTest
@ValueSource(strings = {"JANUARY", "FEBRUARY", "MARCH"})
void a_test_with_explicit_conversion(
@ConvertWith(MonthToNumberConverter.class) int month) {
assertTrue(month <= 3);
}
class MonthToNumberConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) {
Month month = Month.valueOf((String) source);
return month.getValue();
}
}
パラメータのソースに話を戻します。さらに複雑なオブジェクトを作成する必要がある場合でも、メソッドを経由してパラメータのソースを提供するという方法があります。具体的には、Stream<Arguments>を返すメソッドを指すようにして@MethodSourceを使用します。Argumentsは、Jupiterでのパラメータのラッパーです。このソースは、前のセクションで行ったように、動的にテスト実行を生成するために使用できます。主な違いは、パラメータ化したテスト実行は動的テストとは違って、それぞれがライフサイクル・メソッドに対してスタンドアロンのテストとして振る舞うことです。
パラメータ化したテスト実行の表示名やテスト名は、カスタマイズすることができます。@ParameterizedTestのname属性をそのために利用できます。次の例に示すように、波括弧で囲んで位置を指定することで、名前にパラメータを挿入することもできます。@EnumSourceを使っている点にも注意してください。この方法もパラメータ化テストの作成に便利です。
@DisplayName("There are 12 months in a year")
@ParameterizedTest(name = "Month #{index} --> {0}")
@EnumSource(Month.class)
void all_the_months(Month month) {
// ...
}
There are 12 months in a year
├─Month #1 --> JANUARY
├─Month #2 --> FEBRUARY
...
├─Month #12 --> DECEMBER
拡張機能
これまでとは違い、JUnit Jupiterには、フレームワークとテスト作成者の両方が利用できる独自の拡張ポイントが存在します。その拡張ポイントがExtension APIです。このAPIは開発者にとって非常に使いやすいものになっており、直接実装できるいくつかのインタフェースで構成されています。実装が済んだら、あとはテスト・クラスで@ExtendWithを使って実装した拡張機能を登録するだけです。内部的には、このモデルの実装が相当複雑なのは間違いありません。しかしここでは、テストを書くときにどのように活用できるかについて紹介します。
結合テストで、メモリ内データベースを起動したり、外部サーバーをモックしたりする必要があるとしましょう。そのようなことは、おそらく@BeforeAllメソッドか@BeforeEachメソッドの中で行うでしょう。しかし、同じセットアップを必要とするテストが多数あるとしたらどうでしょうか。そのような場合は、ライフサイクル・コールバックが便利です。ライフサイクル・コールバックとは、BeforeEachCallbackやAfterTestExecutionCallbackといったもので、テスト・ライフサイクルの1ステップごとに1つの拡張機能が提供されます。次の例では、最初のテストの前と最後のテストの後にそれぞれフックするライフサイクル・コールバックを使用しています。
@ExtendWith(MyCoolExtension.class)
class MyIntegrationTests {
@Test
void test() {
// この時点で、すべてがすでに起動し、実行されている
}
}
class MyCoolExtension implements BeforeAllCallback, AfterAllCallback {
@Override
public void beforeAll(ExtensionContext context) {
// 実際のセットアップを行う
}
@Override
public void afterAll(ExtensionContext context) {
// すべてをシャットダウンする
}
}
ParameterResolverも非常に便利な拡張機能です。この拡張機能は、オブジェクトをテストやライフサイクル・メソッドに引数として注入するものです。
class MyObjectResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
return parameterContext.getParameter().getType() == MyObject.class;
}
@Override
public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
return new MyObject();
}
}
@ExtendWith(MyObjectResolver.class)
class MyTests {
@Test
void test(MyObject myObject) {
assertNotNull(myObject);
}
}
ランナーとは違い、1つのクラスに対して複数の拡張機能を登録できます。もちろん、カスタムの拡張機能を、他のフレームワークが実装するサードパーティ製の拡張機能とともに使うこともできます。現在のJUnitはメタアノテーションをサポートしているので、こういった拡張機能すべてを再編成したカスタム・アノテーションを作ることもできます。次に例を示します。
@ExtendWith({
MyCoolExtension.class,
MyObjectResolver.class,
SpringExtension.class
})
@Retention(RetentionPolicy.RUNTIME)
@interface CustomTest {
}
まとめ
本記事は、JUnitの拡張機能で実現できるすべてのことのうち、ごく一部を紹介したにすぎません。この簡単な概要によって好奇心を刺激されたという方は、ユーザー・ガイドをご覧になるとよいでしょう。すべての拡張機能が完全にドキュメント化されています。
本記事では、JUnit 5の3つの特別なテストである、ネストしたテスト、動的テスト、パラメータ化テストについて説明しました。そのすべては、テストをまとめたり、整理したりするために使用されます。さらに、表示名などのJUnit Jupiterの新機能と完全に統合されています。
JUnit 5では、数多くの重要なテスト機能が導入されたり、刷新されたりしています。JUnit 5チームは、このフレームワークを外部に開放するポイントを設けた一方で、APIを見つけることができて使いやすいという状態を保っています。その結果、フレームワークの柔軟性が格段に増し、テストのニーズを満たすための自在なカスタマイズが可能となっています。
まだJUnit 4を使っていてJUnit 5に移行したい方は、最近のJava Magazineに掲載された、Brian McGlauflin氏による移行に関する記事をご覧になるとよいでしょう。ぜひテストを楽しみましょう。
本記事のレビューを担当し協力してくれたJohannes Link氏に感謝をささげたいと思います。
さらに詳しく
• JUnit 5ホーム・ページ
• テストを容易にするJUnit 5.6の新機能
• JUnit 4からJUnit 5に移行する:重要な違いと利点
• JUnit 5:特別号
• コード・レビューでの5つのアンチパターン
• PactでJavaマイクロサービスをテストする
• 活動規則:ソフトウェアのテストを成功させるためのヒント
• クラウド・インフラストラクチャの目に見えないコスト
• テストを自動化すべきタイミング(と見送るべきタイミング)
フランスのパリを拠点とするCarbon ITのソフトウェア・エンジニア。好奇心旺盛で、日々新しいことを学ぼうとしている。JUnit 5チームのメンバーであり、Java、テスト、オープンソース、そしてすばらしい人々がそろったお気に入りの環境に囲まれている。
ドイツのハイデルベルクに拠点を置く、物理学者から転じたエンジニア。決済プロバイダheidelpayのシニア・ソフトウェア・アーキテクトで、5年前からコアJUnit 5チームに参加している。20年超にわたってサーバー・サイドJavaに携わり、Webシステムやストリーミング・システムのテスト中心開発に注力。プログラミングでは、ソース・コードのシンプルさと可読性をもっとも重視している。
