エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

JUnit5を使おう

f:id:taknakamura:20181218221804p:plain

エムスリー エンジニアの中村です。 この記事は エムスリー Advent Calendar 2018 の22日目の記事です。

JavaでのテストフレームワークといえばJUnitですが、最新版のJUnit5がかなり使いやすくなっています。 既に多くのJUnit5紹介記事がありますので、改めて私が細かく説明することもないかと思いますが、個人的に嬉しいと思った機能を中心に紹介したいと思います。

JUnit5

junit.org

JUnit5 は初版がリリースされてから1年がすぎて、普通に使うには十分な環境になっているのではないかと思います。 Intellij IDEAでもサポートされ、メソッド単位のテスト実行や実行したテストケースの一覧表示など、これまでのJUnit4と変わらずに使うことができます。 Springのテストモージュルでも対応され、JUnit5向けのテスト拡張クラスが提供されています。私が簡易に試した限りではMockMVCなども問題なく使えました。

経緯

  • 2015年 開発開始
  • 2017年9月 v5.0.0 リリース
  • 2018年12月現在 v5.3.2リリース中

状況

  • Java8以降をサポート
  • Intellij IDEAでもサポート
  • Springのテストモジュールは標準はJUnit4だが、JUnit5対応もあり
    • MockMVCなども利用可能

JUnit5のおすすめ機能紹介

JUnit5はJUnit4に比べて色々な機能が変わっています。 どの機能も、JUnit4のときに書きづらかったことを上手く改善するもので、「これがやりたかった」と思えるようなものです。 多くの機能があるので全ては説明できませんが、個人的に特にありがたいと思えるものを以下で紹介します。

階層化 @Nested

テストクラスの中で、インナークラスを使ってテストを階層化することができるようになりました。 JUnit4でも Enclosedを使うことで階層化自体はできていました。 ただ、JUnit4の階層化は親クラスと子クラスが独立していて、別々のファイルに書くのと変わりがないようなものでした。

それに対して、JUnit5の階層化は親クラス側のメンバ変数やBefore/After系メソッドなどが、子クラス側でも利用できます。 例えば、下記にあるサンプルコードだと、FindOneクラスのテストメソッドにおいて、親クラス(HelloTest)のservice変数を使っています。また、テスト実行時の処理はHelloTest.setup()FindOne.setup()FindOne.test1()FindOne.teardown()HelloTest.teardown()の順番で実行されます。 これにより、個別のコンテキストだけの変数や事前処理/事後処理を構造化して記述できるようになり、テストコードをかなり読みやすく作成できるようになっています。

個人的には、この機能のためだけでもJUnit5を使いたいと思えるものです。

@SpringBootTest
class HelloTest {

    @Resource
    private GreetingService service;

    @BeforeEach
    void setup() { log("HelloTest.setup"); }

    @AfterEach
    void teardown() { log("HelloTest.teardown"); }

    @Nested  // ← このアノテーションをつける
    @SpringBootTest
    class FindOne { // GreetingService.findOne メソッドに対するテストケース

        @BeforeEach
        void setup() { log("FindOne.setup"); }

        @AfterEach
        void teardown() { log("FindOne.teardown"); }

        @Test
        void test1(@Autowired Integer value) {
            assertThat(service.findOne(value)).isNotNull();
        }
    }
}

Parameterized Test

Parameterized Testは、一部の値だけが違うテストケースを一括して定義できる機能です。 JUnit4でも同様の機能はありましたが、それよりもかなり簡単に、読みやすく実装できるようになっています。

JUnit4のParameterized Testは、1つのクラスで1メソッドだけしか定義できないような感じのものでした。 それがJUnit5では、テスト用のパラメータのリストをアノテーションで簡潔に記述し、テストメソッドの引数でパラメータを受け取るという方式になっています。 @CsvSourceでテストケースを1行ずつ記述し、実行時にはその文字列を引数の型にあわせて型変換してくれます。 以下では @CsvSourceを用いた例ですが、これ以外にもテスト用パラメータを渡す方法は、何パターンも用意されています。

// csv の一行ごとにテストが実行される
@ParameterizedTest
@CsvSource({
    "foo,        1, 2018-12-12",
    "bar,        2, 2010-10-22",
    "'baz, qux', 3, 1999-03-09"
})
void testWithCsvSource(String first, int second, LocalDate date) {
    assertNotNull(first);
    assertNotEquals(0, second);
    assertEquals(2018, date.getYear());
}

Intellij で実行すると以下のように表示され、CSVの1行ずつが個別のテストケースとして扱われていることがわかります。

f:id:taknakamura:20181218222003p:plain

動的なテスト生成

@Testアノテーションの代わりに@TestFactoryをつけたメソッドでテストケースをプログラムで生成することができます。 メソッドの戻り値としてDynamicTest.dynamicTest()メソッドで生成したテストケース(DynamicTestクラスインスタンス)のStreamやコレクションを返すと、そのテストケース群を実行してくれます。 ParameterizedTestはパラメータの違いだけでしたが、より柔軟なテストバリエーションを生成することができます。

ただし注意点として、生成した個々のテストケースの実行時にはテストクラスが個別には生成されず、@Before系、@After系も個別には実行されません。@TestFactoryの実行が@Testと同等に1つのテストケースとして実行されます。

@TestFactory
Stream<DynamicTest> test() {
    // メソッド内クラスでテストデータを構造化すると使いやすそう
    class Pattern {
        String str;
        boolean expect;
        Pattern(String str, boolean expect) {
            this.str = str;
            this.expect = expect;
        }
    }

    // ラムダ外のスコープの一時変数を維持するためにテストクラスを個別には生成しないのだと思う
    final List<String> list = new ArrayList<>();

    return Stream.of(
            new Pattern("A", true),
            new Pattern("B", false)

    ).map(p -> dynamicTest("test" + p.str, () -> {
        list.add(p.str);

        assertThat(list.size() == 1, is(p.expect));
    }));
}

f:id:taknakamura:20181218222302p:plain

Assertion

JUnit5のassert系メソッドは基本的なものだけを提供し、適宜好きなアサーションモジュール(AssertJ、Hamcrestなど)を選択して利用することを推奨しています。 基本的といっても、その中でも Lambdaを使ったアサーションは便利なものがあります。以下はその一例です。

assertAllは複数のアサーションの結果を一括で確認することができます。これでアサーションの結果を1つ直しては次のアサーションが失敗し、それを直すとまた次が、といったことを回避できます。

assertThrowsは例外の発生を確認するassertメソッドです。JUnit4ではテストメソッドから例外で抜け出たときに検証する方式だったので、例外発生後の状態を確認するようなテストが書きにくかった面がありました。assertThrowsを使えばこの問題を解消することができます。

@Test
void groupedAssertions() {
    // 全てのassertを実行し、結果を一括表示
    assertAll("person",
        () -> assertEquals("John", person.getFirstName()),
        () -> assertEquals("Doe", person.getLastName())
    );
}

@Test
void exceptionTesting() {
    // 例外発生を期待する。例外発生後も処理ができる
    Throwable exception = assertThrows(RuntimeException.class, () -> {
        throw new RuntimeException("a message");
    });
    assertEquals("a message", exception.getMessage());
}

テストの拡張機能

JUnit4ではテストの事前処理や事後処理などを汎用的に実装するための機能として、@RunWith@Ruleなどのテスト拡張機能がありました。 JUnit5では、これらのテスト拡張機能が廃止され、@ExtendWithに置き換わっています。

@RunWithは、1クラスに1つしか指定できなかったため、複数の拡張を同時に使用することができませんでした。そのため、Springのための@RunWith(SpringRunner.class)を使うと@RunWith(Parameterized.class)が使えないなど、便利な機能を上手く活かしずらいことがありました。 @ExtendWithは同じクラスに複数指定することができ、この手の問題が解消されました。

JUnit4の拡張方法

  • @RunWIth
    • 1つのテストクラスに対して1つだけ設定可能
  • @Rule
    • テストクラスのメンバ変数として定義
    • 拡張っぽく見えず、動作がちょっとトリッキー
// JUnit4での例
@RunWith(SpringRunner.class)
public class GreetingController_Junit4_Test {

    @Rule
    public TestWatcher surroundLog =
             new SurroundLogTestWatcher();
}

JUnit5の拡張方法

  • @ExtendWith
    • アノテーションとして指定
    • 1つのテストクラスに複数指定可能
@ExtendWith(SpringExtension.class)
@ExtendWith(MyAppExtension.class)
class GreetingControllerTest {

}

メタ アノテーション

JUnit5では、アノテーションで設定をする方式になっています。このとき、複数のアノテーションをひとまとめにしたアノテーションを作ることができます。 プロジェクト内でテスト実装時のルールを「決まったアノテーションをつける」というように活用することができます。 テストクラスに対して、クラス継承を使わずに同じ設定を指定できるのも利点となります。

// MySpringTest.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)

// Testクラス用設定
@ExtendWith(SpringExtension.class)
@ExtendWith(MyAppExtension.class)
@Transactional // Springでのトランザクション指定

public @interface MySpringTest {
}
// GreetingControllerTest.java

@MySpringTest
class GreetingControllerTest {

    @Test
    void test1() {
        assertThat(service).isNotNull();
    }
}

JUnit4からの移行

JUnit5を新規のプロジェクトで使い始めるのは困ることはないと思います。

それに対して、ここでは既にJUnit4を使っている既存のプロジェクトへの導入方法を紹介します。

JUnit4との共存

JUnit5とJUnit4の共存状態を作るのは簡単です。以下のモジュールをすべて依存に含めるだけで対応完了です。 共存する状態では、あるテストクラスはJUnit4で記述し、また別のクラスはJUnit5で記述する、ということができます。

  • JUnit4 モジュール
    • junit:junit:4.x
  • JUnit5 モジュール
    • org.junit.jupiter:junit-jupiter-api:5.x.x
    • org.junit.jupiter:junit-jupiter-params:5.x.x (Optional)
    • org.junit.jupiter:junit-jupiter-engine:5.x.x
    • org.junit.platform:junit-platform-launcher:1.x.x
  • JUnit5 上で JUnit4のテストを実行するモジュール
    • org.junit.vintage:junit-vintage-engine:5.x.x

JUnit5モジュールを依存に含めるだけで、mvn testなどのテスト実行コマンドでJUnit5が実行されるようになります。しかし、そのままではJUnit4で記述したテストクラスは実行されません。 そこにjunit-vintage-engineモジュールを依存に含めると、JUnit4のテストも拾って実行してくれるようになります。 この時のJUnit4のテストは、JUnit4のままで実行されるのでコードの書き換えなどは不要です。

これにより、既存のJUnit4のテストコードはそのままで、新規テストクラスはJUnit5で実装できるという環境ができあがります。

JUnit4からJUnit5へ書き換える

JUnit5の記述方法を丁寧に説明すると量が多くなりすぎるので、ざっくりとJUnit4を書き換えるイメージでその違いを説明します。

テストのアノテーションを書き換える

JUnit5のアノテーションは、JUnit4のものとは別物になっています。これは共存して利用できるようにするために意図的に流用しなかったのだと思います。 そのため、今までのJUnit4をJUnit5に書き換えるには、アノテーションを変更していく必要があります。 基本的にはパッケージ名が変わっているのでそれを一括で置換します。その上で、一部のアノテーションの名称が変わっているのでそれを修正します。 JUnit5のorg.junit.jupiter.api.Testアノテーションがついていれば、そのテストメソッドはJUnit5で動くことになります。

  • org.junit.Test → org.junit.jupiter.api.Test
  • org.junit.Before → org.junit.jupiter.api.BeforeEach など

@RunWith、@Ruleを@ExtendWithで置き換える

@RunWith@RuleはJUnit5では無くなったので、@ExtendWithに置き換える必要があります。 これらのアノテーションの引数に指定するテスト拡張クラスには互換性がないので、拡張クラス自体をJUnit5流で実装しなおす必要があります。 Springなどのフレームワークが提供していた拡張クラスは、フレームワーク側がJUnit5版の拡張クラスを提供してくれているかを確認し、置き換えることになります。

Springの場合

// JUnit4
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("/testApplicationContext.xml")
public class SomeControllerTest { 

↓

// JUnit5
@ExtendWith(SpringExtension.class)
@ContextConfiguration("/testApplicationContext.xml")
public class SomeControllerTest { 

まとめ

JUnit5の中で個人的に有り難い機能を挙げてみましたが、実際にはもっと多くの機能があります。 公式のUser Guideがかなり丁寧で内容も充実しているので、サンプルコードにざっと目を通してみるのをおすすめします。

JUnit5は、今までのJUnit4でちょうど困っていたようなポイントが解消され、とても書きやすく、また読みやすくテストを実装できるようになっていますので、ぜひ触ってみてください。

エンジニア募集

エムスリーでは自身で手を動かし、技術で医療の課題を解決するエンジニアを募集しています。 この記事(or 他の記事も)を読んで興味を持った方はぜひ下記リンクよりご応募ください!