かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot 2.0.x の Web アプリを 2.1.x へバージョンアップする ( その10 )( @Rule を使用していないテストを JUnit 5 のテストに書き直す+JUnit 5 の Parallel 実行を試してみる )

概要

記事一覧はこちらです。

Spring Boot 2.0.x の Web アプリを 2.1.x へバージョンアップする ( その9 )( JUnit 5 へバージョンアップ。。。@RunWith(Enclosed) + @Unroll + useJUnitPlatform() の組み合わせでテストが終わらない問題を解決する ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • JUnit 4 で書いているテストの内 @Rule を使用しているテスト以外を JUnit 5 で書き直します。
    • Spring Framework 5.0+JUnit 5 だと @SpringBootTest アノテーションが付与されたテストも parallel 実行できるようになったらしいので、試してみます。

参照したサイト・書籍

  1. Spring 5でSpring Testのここが変わる_公開版
    https://www.slideshare.net/HasegawaDanna1/spring-5spring-test

  2. FINALLY JUNIT 5 HAS NATIVE SUPPORT IN GRADLE
    https://alexanderontesting.com/2018/04/07/finally-junit-5-has-native-support-in-gradle/

  3. Junit 5 parallel test execution via Gradle fails with "Received a completed event for test with unknown id".
    https://github.com/gradle/gradle/issues/6453

  4. 劇的改善 Ci4時間から5分へ〜私がやった10のこと〜
    https://www.slideshare.net/aha_oretama/ci-82258405

  5. Spring Framework Documentation - Testing
    https://docs.spring.io/spring/docs/current/spring-framework-reference/testing.html#integration-testing

  6. JUnit 5 User Guide - 2.18. Parallel Execution
    https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution

  7. AssertJ Core features highlight
    http://joel-costigliola.github.io/assertj/assertj-core-features-highlight.html

    • 「Collect all errors with soft assertions」に JUnit 5 の assertAll と同じように1つの assert がエラーになっても全ての assert を実行する方法が記載されています。

目次

  1. JUnit 5 への変更方法
  2. テストの parallel 実行を試してみる
  3. テストを書き直してから build タスクを実行する
  4. 次回は。。。
  5. メモ書き
    1. テストクラスを parallel 実行の対象にするが、クラス内のテストメソッドは parallel 実行しないようにするには?

手順

JUnit 5 への変更方法

  • @RunWith(SpringRunner.class)JUnit 5 では不要なので削除します。
  • Spring Boot 2.1 から @SpringBootTest が付与されていると @ExtendWith(SpringExtension.class) も付与されるようになったので、テストクラスには記述しません。@SpringBootTest アノテーションのソース(org.springframework.boot.test.context.SpringBootTest)を見ると @ExtendWith(SpringExtension.class) が記述されています。 f:id:ksby:20190302155056p:plain
  • @Test アノテーションの import 文を import org.junit.Testimport org.junit.jupiter.api.Test; に変更します。
  • テストメソッドから public を削除します。また throws Exception も例外が throw されない場合には削除します。
  • Assertion Library は AssertJ のままにします(使い慣れていて機能も豊富で便利なので)。org.junit.jupiter.api.Assertions.* の Assertion Library には変更しません。
  • 例外発生のテストも assertThrows には変更せず AssertJ の assertThatThrownBy 等のメソッドを使用したままにします(これについては JUnit 5 の assertThrows にした方がわかりやすいような気もしています)。
  • AssertJ の assertThat を連続で記述している場合には JUnit 5 の assertAll を使用するよう変更します。
  • @Rule を使用しているテストは JUnit 5 User Guide - 5. Extension Model で書き直す必要があるので、今回は変更しません。次回変更します。

テストの parallel 実行を試してみる

全体を変更する前に、Spring Framework 5+JUnit 5 から @SpringBootTest アノテーションが付与されたテストクラスも parallel 実行できるようになったらしいので試してみます。

以下の仕様で試します。

  • parallel 実行対象のテストクラスには @Tag("parallel") アノテーションを付与します。
  • testJUnit5InParalell タスクを追加し、このタスクで @Tag("parallel") アノテーションが付与されたテストを parallel 実行します。
  • test タスクは @Tag("parallel") アノテーションが付与されていないテストを順次実行します。

まずは build.gradle を変更します。

task testJUnit4AndSpock(type: Test) {
    jvmArgs = jvmArgsForTask + ["-Dspring.profiles.active=unittest"]
    testLogging {
        afterSuite printTestCount
    }
}
//task testJUnit5InParalell(type: Test, dependsOn: "testJUnit4AndSpock") {
task testJUnit5InParalell(type: Test) {
    jvmArgs = jvmArgsForTask + ["-Dspring.profiles.active=unittest"]

    // for JUnit 5
    useJUnitPlatform {
        includeTags "parallel"
    }
    systemProperties["junit.jupiter.execution.parallel.enabled"] = true
    systemProperties["junit.jupiter.execution.parallel.mode.default"] = "concurrent"
    maxParallelForks 2

    testLogging {
        events "STARTED", "PASSED", "FAILED", "SKIPPED"
        afterSuite printTestCount
    }
}
test.dependsOn testJUnit5InParalell
test {
    jvmArgs = jvmArgsForTask + ["-Dspring.profiles.active=unittest"]

    // for JUnit 5
    useJUnitPlatform {
        excludeTags "parallel"
    }

    testLogging {
        events "STARTED", "PASSED", "FAILED", "SKIPPED"
        afterSuite printTestCount
    }
}
  • task testJUnit5InParalell(type: Test) { ... } を追加します。このタスクで @Tag("parallel") アノテーションが付与されたテストを parallel 実行します。
    • includeTags "parallel" を記述して @Tag("parallel") アノテーションが付与されたテストだけを実行対象にします。
    • 以下の3行を記述して parallel 実行を有効にします。
      • systemProperties["junit.jupiter.execution.parallel.enabled"] = true
      • systemProperties["junit.jupiter.execution.parallel.mode.default"] = "concurrent"
      • maxParallelForks 2
    • events "STARTED", "PASSED", "FAILED", "SKIPPED" を記述してテストが parallel 実行されていることが確認できるようにします。
  • test.dependsOn testJUnit4AndSpocktest.dependsOn testJUnit5InParalell に変更します。testJUnit4AndSpock タスクは依存関係から外して JUnit 4 と Spock のテストが実行されないようにします。
  • test タスクの以下の点を変更します。
    • useJUnitPlatform()useJUnitPlatform { excludeTags "parallel" } に変更して @Tag("parallel") アノテーションが付与されたテストを実行対象外にします。
    • testLogging { ... } 内に events "STARTED", "PASSED", "FAILED", "SKIPPED" を追加して、test タスクではテストが parallel 実行されていないことを確認できるようにします。

parallel 実行のテストクラスは LibraryHelperTest、Mail001HelperTest、Mail002HelperTest、Mail003HelperTest の4つを使用します。

f:id:ksby:20190302213525p:plain

src/test/java/ksbysample/webapp/lending/helper/library/LibraryHelperTest.javaJUnit 5 のテストクラスに変更します。このクラスには @Tag("parallel") アノテーションは付与しません。

@SpringBootTest
public class LibraryHelperTest {

    @Autowired
    private LibraryHelper libraryHelper;

    @MockBean
    private LibraryForsearchDao libraryForsearchDao;

    @Test
    void testGetSelectedLibrary_図書館が選択されていない場合() {
        given(libraryForsearchDao.selectSelectedLibrary()).willReturn(null);

        String result = libraryHelper.getSelectedLibrary();
        assertThat(result).isNull();
    }

    @Test
    void testGetSelectedLibrary_図書館が選択されている場合() {
        LibraryForsearch libraryForsearch = new LibraryForsearch();
        libraryForsearch.setSystemid("System_Id");
        libraryForsearch.setFormal("図書館名");
        given(libraryForsearchDao.selectSelectedLibrary()).willReturn(libraryForsearch);

        String result = libraryHelper.getSelectedLibrary();
        assertThat(result).isEqualTo("選択中:図書館名");
    }

}

src/test/java/ksbysample/webapp/lending/helper/mail/Mail001HelperTest.javaJUnit 5 のテストクラスに変更します。このクラスには @Tag("parallel") アノテーションを付与します。

@Tag("parallel")
@SpringBootTest
public class Mail001HelperTest {

    @Autowired
    private Mail001Helper mail001Helper;

    @Value("ksbysample/webapp/lending/helper/mail/assertdata/001/message.txt")
    ClassPathResource messageTxtResource;

    @Test
    void testCreateMessage() throws Exception {
        MimeMessage message = mail001Helper.createMessage("test@sample.com", 1L);
        assertAll(
                () -> assertThat(message.getRecipients(Message.RecipientType.TO))
                        .extracting(Object::toString)
                        .containsOnly("test@sample.com"),
                () -> assertThat(message.getContent())
                        .isEqualTo(FileCopyUtils.copyToString(Files.newReader(
                                messageTxtResource.getFile(), StandardCharsets.UTF_8)))
        );
    }

}

src/test/java/ksbysample/webapp/lending/helper/mail/Mail002HelperTest.javaJUnit 5 のテストクラスに変更します。このクラスには @Tag("parallel") アノテーションを付与します。

@Tag("parallel")
@SpringBootTest
public class Mail002HelperTest {

    @Autowired
    private Mail002Helper mail002Helper;

    @Value("ksbysample/webapp/lending/helper/mail/assertdata/002/message.txt")
    ClassPathResource messageTxtResource;

    @Test
    void testCreateMessage() throws Exception {
        MimeMessage message = mail002Helper.createMessage(new String[]{"test@sample.com", "sample@test.co.jp"}, 1L);
        assertAll(
                () -> assertThat(message.getRecipients(Message.RecipientType.TO))
                        .extracting(Object::toString)
                        .containsOnly("test@sample.com", "sample@test.co.jp"),
                () -> assertThat(message.getContent())
                        .isEqualTo(FileCopyUtils.copyToString(Files.newReader(
                                messageTxtResource.getFile(), StandardCharsets.UTF_8)))
        );
    }

}

src/test/java/ksbysample/webapp/lending/helper/mail/Mail003HelperTest.javaJUnit 5 のテストクラスに変更します。このクラスには @Tag("parallel") アノテーションは付与しません。

@SpringBootTest
public class Mail003HelperTest {

    @Autowired
    private Mail003Helper mail003Helper;

    @Value("ksbysample/webapp/lending/helper/mail/assertdata/003/message.txt")
    ClassPathResource messageTxtResource;

    @Test
    void testCreateMessage() throws Exception {
        List<LendingBook> lendingBookList = new ArrayList<>();
        // 1件目
        LendingBook lendingBook = new LendingBook();
        lendingBook.setBookName("x");
        lendingBook.setApprovalResult(APPROVAL.getValue());
        lendingBookList.add(lendingBook);
        // 2件目
        lendingBook = new LendingBook();
        lendingBook.setBookName(Strings.repeat("X", 128));
        lendingBook.setApprovalResult(REJECT.getValue());
        lendingBookList.add(lendingBook);

        MimeMessage message = mail003Helper.createMessage("test@sample.com", 1L, lendingBookList);
        assertAll(
                () -> assertThat(message.getRecipients(Message.RecipientType.TO))
                        .extracting(Object::toString)
                        .containsOnly("test@sample.com"),
                () -> assertThat(message.getContent())
                        .isEqualTo(FileCopyUtils.copyToString(Files.newReader(
                                messageTxtResource.getFile(), StandardCharsets.UTF_8)))
        );
    }

}

clean タスク実行 → Rebuild Project 実行 → build タスクを実行してみると、testJUnit5InParalell タスクでは parallel 実行されており(複数のテストが同時に STARTED になる)、test タスクでは順次実行されています(1つのテストが STARTED、PASSED になってから次のテストが STARTED になっています)。

f:id:ksby:20190303075911p:plain

ただし parallel 実行する testJUnit5InParalell タスクの開始、終了時に少し止まる時間があり(内部で何か準備と終了の処理をしていると思われる)、3回追加で実行してみたところ平均 2m 50s だったのですが、build.gradle の parallel 実行の設定をコメントアウトして3回実行してみたところ 2m 10s 前後で 30~40s 程 parallel 実行しない方が速い結果でした。parallel 実行した時としない時を比較して時間短縮のメリットが得られると判断した時だけ parallel 実行を選択した方がよいのかな。。。?(少なくとも今回のようにテストクラスが少ない場合には何のメリットもありません)。

task testJUnit5InParalell(type: Test) {
    jvmArgs = jvmArgsForTask + ["-Dspring.profiles.active=unittest"]

    // for JUnit 5
    useJUnitPlatform {
        includeTags "parallel"
    }
//    systemProperties["junit.jupiter.execution.parallel.enabled"] = true
//    systemProperties["junit.jupiter.execution.parallel.mode.default"] = "concurrent"
//    maxParallelForks 2

    testLogging {
        events "STARTED", "PASSED", "FAILED", "SKIPPED"
        afterSuite printTestCount
    }
}

テストを書き直してから再度 parallel 実行を試してみたいと思いますので、parallel 実行可能なテストには @Tag("parallel") アノテーションを付与します。

テストを書き直してから build タスクを実行する

今回は青字のテストを JUnit 5 に書き直しました。

f:id:ksby:20190303214815p:plain f:id:ksby:20190303214932p:plain

clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると BUILD SUCCESSFUL が表示されました。問題ないようです。

f:id:ksby:20190303220045p:plain

parallel 実行ありの場合と parallel 実行なしの場合を比較してみると、40s ~ 1m くらい差が出ました。まだ parallel 実行の方が時間がかかるようです。

parallel実行あり parallel実行なし
1回目 6m 5s 5m 3s
2回目 6m 8s 5m 13s
3回目 5m 51s 5m 12s

build.gradle から parallel 実行用の testJUnit5InParalell タスクを削除した場合も試してみます。

task testJUnit4AndSpock(type: Test) {
    jvmArgs = jvmArgsForTask + ["-Dspring.profiles.active=unittest"]
    testLogging {
        afterSuite printTestCount
    }
}
test.dependsOn testJUnit4AndSpock
test {
    jvmArgs = jvmArgsForTask + ["-Dspring.profiles.active=unittest"]

    // for JUnit 5
    useJUnitPlatform()

    testLogging {
        afterSuite printTestCount
    }
}
1回目 4m 51s
2回目 4m 49s
3回目 4m 41s

更に速くなりました。テスト数が少ないのか parallel 実行に全然メリットが感じられません。。。 testJUnit5InParalell タスクは削除することにします。テストクラスに付与した @Tag("parallel") アノテーションも削除します。

次回は。。。

テストの高速化を図るなら parallel 実行を使うよりは @SpringBootTest を使用しないテストに書き直したり、テストの実行環境自体を分ける方が現実的なのかもしれません。

次回は @Rule を使用しているテストを JUnit 5 の Extension で書き直します。

メモ書き

テストクラスを parallel 実行の対象にするが、クラス内のテストメソッドは parallel 実行しないようにするには?

build.gradle に testJUnit5InParalell タスクを記載している状態で、

task testJUnit5InParalell(type: Test) {
    jvmArgs = jvmArgsForTask + ["-Dspring.profiles.active=unittest"]

    // for JUnit 5
    useJUnitPlatform {
        includeTags "parallel"
    }
    systemProperties["junit.jupiter.execution.parallel.enabled"] = true
    systemProperties["junit.jupiter.execution.parallel.mode.default"] = "concurrent"
    maxParallelForks 2

    testLogging {
        events "STARTED", "PASSED", "FAILED", "SKIPPED"
        afterSuite printTestCount
    }
}

src/test/java/ksbysample/webapp/lending/helper/library/LibraryHelperTest.java@Tag("parallel") を付与して parallel 実行すると、

@Tag("parallel")
@SpringBootTest
public class LibraryHelperTest {

    @Autowired
    private LibraryHelper libraryHelper;

    @MockBean
    private LibraryForsearchDao libraryForsearchDao;

    @Test
    void testGetSelectedLibrary_図書館が選択されていない場合() {
        given(libraryForsearchDao.selectSelectedLibrary()).willReturn(null);

        String result = libraryHelper.getSelectedLibrary();
        assertThat(result).isNull();
    }

    @Test
    void testGetSelectedLibrary_図書館が選択されている場合() {
        LibraryForsearch libraryForsearch = new LibraryForsearch();
        libraryForsearch.setSystemid("System_Id");
        libraryForsearch.setFormal("図書館名");
        given(libraryForsearchDao.selectSelectedLibrary()).willReturn(libraryForsearch);

        String result = libraryHelper.getSelectedLibrary();
        assertThat(result).isEqualTo("選択中:図書館名");
    }

}

以下のキャプチャのようにテストが失敗することがありました。原因はこのテストクラスでは @MockBean をフィールドに付与し各テストメソッドで given(...).willReturn(...); で異なる挙動を定義しているため、testGetSelectedLibrary_図書館が選択されていない場合() でモックを定義する(まだテストは未実施) → testGetSelectedLibrary_図書館が選択されている場合() でモックを定義しテストも完了 → testGetSelectedLibrary_図書館が選択されていない場合() でテストを実施するがモックの定義が変更されておりテスト失敗、となる場合があるためです。必ず失敗する訳ではなく成功する時もあります。

f:id:ksby:20190305012436p:plain

これを防止にするにはテストクラスに @Execution(ExecutionMode.SAME_THREAD) アノテーションを付与して、テストクラスのテストが1つのスレッド内で実行されるようにします。

..........
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
..........

@Execution(ExecutionMode.SAME_THREAD)
@Tag("parallel")
@SpringBootTest
public class LibraryHelperTest {

build タスクを実行すると LibraryHelperTest クラス内のテストメソッドは順次実行されまることが確認できます。

f:id:ksby:20190305013448p:plain

履歴

2019/03/05
初版発行。