Spring Bootのテストについて検索すると、@RunWith(SpringRunner.class) や @Before を使ったサンプルがまだたくさん出てきます。手元のプロジェクトで真似してみたら、テストが1件も実行されなかった。そんな経験をした人も多いのではないでしょうか。

この記事では、spring-boot-starter-test に入っているJUnit Jupiter(JUnit 5以降のテストAPI)の基本的な書き方をまとめます。@DisplayName・@ParameterizedTest・@Nested でテストを読みやすくする方法と、JUnit 4のサンプルを読み替えるための対応表も載せました。Mockitoと組み合わせたテストは Spring Bootでテストを書こう - JUnitとMockitoで始める単体テスト入門 で扱っているので、ここではJUnit Jupiterそのものに絞ります。

本記事はSpring Boot 3.x(JUnit Jupiter 5.x)を前提にしています。Spring Boot 4.xの spring-boot-starter-test ではJUnit 6が使われますが、この記事で紹介する org.junit.jupiter.api のアノテーションやAssertionsの書き方はそのまま使えます。

spring-boot-starter-testに含まれるJUnit Jupiter

JUnit 5は、次の3つで構成されています。

  • JUnit Platform(テストを実行する基盤)
  • JUnit Jupiter(@Test などの新しいAPIと実行エンジン)
  • JUnit Vintage(JUnit 4のテストを動かすための互換エンジン)

spring-boot-starter-test にはJUnit Jupiterのほか、AssertJ、Hamcrest、Mockitoなどがまとめて入っています。JUnitのために依存を追加する必要はありません。Starterの仕組みそのものは Spring Boot Starterって何? で解説しています。

注意したいのは、Spring Boot 2.4以降はVintageエンジンが含まれていない点です。JUnit 4の書き方のテストは、そのままでは実行されません。

Mavenなら次の記述だけでOKです。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Gradleでは useJUnitPlatform() を忘れずに書きましょう。

dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

Spring Initializrで作ったプロジェクトなら、最初からこの形になっているはずです。

最小構成のテストクラスを書く

テスト対象として、税込価格と割引を計算する小さなクラスを用意します。

public class PriceCalculator {

    public int withTax(int price) {
        return price * 110 / 100;
    }

    public int discount(int price, int percent) {
        if (percent < 0 || percent > 100) {
            throw new IllegalArgumentException("割引率は0〜100で指定してください: " + percent);
        }
        return price * (100 - percent) / 100;
    }

    public int parsePrice(String text) {
        if (text == null || text.isBlank()) {
            throw new IllegalArgumentException("価格が空です");
        }
        return Integer.parseInt(text.trim());
    }
}

これに対する一番シンプルなテストはこうなります。

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class PriceCalculatorTest {

    @Test
    void withTaxAddsTenPercent() {
        PriceCalculator calc = new PriceCalculator();
        assertEquals(1100, calc.withTax(1000));
    }
}

JUnit 5ではテストクラスもメソッドも public にする必要がなく、package-privateで十分です。importするのは org.junit.jupiter.api.Test のほうです。

Springのコンテキストを起動しないので、実行は一瞬で終わります。@SpringBootTest や @WebMvcTest を使う場合も、アノテーション自体に @ExtendWith(SpringExtension.class) が含まれているので @RunWith は不要です。どのテストアノテーションを選ぶかは テストアノテーション使い分け早見表 にまとめています。

ライフサイクルアノテーション

前処理と後処理は、次の4つのアノテーションで書きます(以降のコード断片ではimportを省略しています)。

class LifecycleTest {

    @BeforeAll
    static void beforeAll() { System.out.println("beforeAll"); }

    @BeforeEach
    void beforeEach() { System.out.println("beforeEach"); }

    @Test
    void test1() { System.out.println("test1"); }

    @Test
    void test2() { System.out.println("test2"); }

    @AfterEach
    void afterEach() { System.out.println("afterEach"); }

    @AfterAll
    static void afterAll() { System.out.println("afterAll"); }
}

実行すると、beforeAll → 「beforeEach → テスト → afterEach」×2回 → afterAll の順に出力されます。test1とtest2のどちらが先に動くかは保証されません。

@BeforeAll と @AfterAll は、デフォルトでは static メソッドにする必要があります。クラスに @TestInstance(TestInstance.Lifecycle.PER_CLASS) を付けるとstaticは不要になりますが、まずはstaticで書くのが無難です。

JUnit 5は、テストメソッドごとにテストクラスのインスタンスを新しく作ります。フィールドの値は次のテストに引き継がれないので、テスト同士が影響し合う心配はありません。

@DisplayNameで日本語のテスト名をつける

メソッド名だけだと、何を確認しているテストなのか分かりにくいものです。@DisplayName を使うと、好きな表示名を付けられます。

@DisplayName("PriceCalculator")
class PriceCalculatorTest {

    PriceCalculator calc;

    @BeforeEach
    void setUp() {
        calc = new PriceCalculator();
    }

    @Test
    @DisplayName("税込価格は本体価格に10%を加算した金額になる")
    void withTax() {
        assertEquals(1100, calc.withTax(1000));
    }
}

IntelliJ IDEAのテスト結果ツリーでは、メソッド名の代わりにこの日本語名が表示されます。メソッド名は英語のままにして、仕様は @DisplayName に日本語で書く運用にすると読みやすくなります。以降の例でも、この calc フィールドを使います。

Assertionsの基本

よく使うのは org.junit.jupiter.api.Assertions のstaticメソッドです。

assertEquals(expected, actual) は、 期待値が先 です。失敗すると「expected: <1100> but was: <999>」のように表示されるので、逆に書くと意味が反対になって混乱します。assertTrue・assertFalse・assertNull なども使え、最後の引数で失敗時のメッセージを渡せます。

例外の検証には assertThrows を使います。戻り値が投げられた例外なので、メッセージまで確認できます。

@Test
void discountRejectsInvalidPercent() {
    IllegalArgumentException ex = assertThrows(
            IllegalArgumentException.class,
            () -> calc.discount(1000, 120));
    assertTrue(ex.getMessage().contains("120"));
}

@Test
void discountResults() {
    assertAll("割引計算",
            () -> assertEquals(900, calc.discount(1000, 10)),
            () -> assertEquals(1000, calc.discount(1000, 0)),
            () -> assertEquals(0, calc.discount(1000, 100)));
}

assertAll は途中で1つ失敗しても残りの検証を続け、失敗をまとめて報告してくれます。

なお、spring-boot-starter-test にはAssertJも入っているので、assertThat(calc.withTax(1000)).isEqualTo(1100) のような書き方もすぐ使えます。チーム内ではどちらかに揃えておくといいですね。

@ParameterizedTestで入力パターンをまとめる

入力だけ変えて同じ処理を何度もテストしたいとき、メソッドをコピペしがちです。@Test の代わりに @ParameterizedTest を付けると、1つのメソッドに複数のパターンを渡せます。必要な junit-jupiter-params もstarter-testに含まれています。

@ValueSourceで単一の値を渡す

一番シンプルなのが @ValueSource です。

@ParameterizedTest
@ValueSource(ints = {-1, 101, 200})
void invalidPercent(int percent) {
    assertThrows(IllegalArgumentException.class,
            () -> calc.discount(1000, percent));
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void blankPriceIsRejected(String text) {
    assertThrows(IllegalArgumentException.class,
            () -> calc.parsePrice(text));
}

@NullAndEmptySource を付けると、nullと空文字も入力に加わります。空白だけの文字列と合わせて、空入力のパターンを4件まとめて確認できます。

@CsvSourceで入力と期待値をセットで渡す

入力と期待値を組で渡したいときは @CsvSource が便利です。

@ParameterizedTest(name = "{index}: {0}円の{1}%引きは{2}円")
@CsvSource(textBlock = """
        1000, 10,  900
        1000,  0, 1000
        1980, 50,  990
        """)
void discount(int price, int percent, int expected) {
    assertEquals(expected, calc.discount(price, percent));
}

テキストブロックで書くと表のように並べられて、見通しが良くなります。name 属性に {index} や {0} を書くと、表示名は「1: 1000円の10%引きは900円」のようになります。

文字列を渡すときは、空文字とnullの違いに注意しましょう。apple, '' なら2つ目は空文字、apple, なら2つ目はnullになります。nullValues = "NULL" を指定すると、NULL と書いた値をnullとして扱えます。

@MethodSourceで複雑な引数を渡す

リストやオブジェクトを渡したいなど、CSVでは書きにくいときは @MethodSource を使います。

@ParameterizedTest
@MethodSource("orders")
void totalWithTax(List<Integer> prices, int expected) {
    int total = prices.stream().mapToInt(calc::withTax).sum();
    assertEquals(expected, total);
}

static Stream<Arguments> orders() {
    return Stream.of(
            Arguments.of(List.of(1000), 1100),
            Arguments.of(List.of(1000, 500), 1650),
            Arguments.of(List.of(), 0));
}

ファクトリメソッドはstaticにして、Arguments.of(...) で1回分の引数を組み立てます。

@Nestedでシナリオごとにテストを分ける

テストが増えてきたら、@Nested で正常系と異常系などに分けると整理しやすくなります。こちらはimportも含めた完全な形です。

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@DisplayName("PriceCalculator#discount")
class DiscountTest {

    PriceCalculator calc;

    @BeforeEach
    void setUp() {
        calc = new PriceCalculator();
    }

    @Nested
    @DisplayName("割引率が0〜100の場合")
    class ValidPercent {
        @Test
        @DisplayName("割引後の価格を返す")
        void returnsDiscountedPrice() {
            assertEquals(900, calc.discount(1000, 10));
        }
    }

    @Nested
    @DisplayName("割引率が範囲外の場合")
    class InvalidPercent {
        @ParameterizedTest
        @ValueSource(ints = {-5, 101})
        @DisplayName("IllegalArgumentExceptionを投げる")
        void throwsException(int percent) {
            assertThrows(IllegalArgumentException.class,
                    () -> calc.discount(1000, percent));
        }
    }
}

@Nested を付けるのは、staticでない内部クラスです。外側の @BeforeEach は内側のテストにも適用されるので、共通の準備は外側にまとめられます。@DisplayName と組み合わせれば、テスト結果が仕様書のようなツリーになります。

@SpringBootTest と @Nested を組み合わせる例は、@SpringBootTestのwebEnvironment使い分け で紹介しています。

@Disabledと@Tagで実行対象を絞る

一時的にテストを止めたいときは @Disabled、テストを分類したいときは @Tag を使います。

@Disabled("外部APIの仕様変更待ち(チケット#123)")
@Test
void callsExternalApi() { }

@Tag("slow")
@Test
void heavyCalculation() { }

@Disabled には理由を書いておきましょう。理由がないと、後から外していいのか誰も判断できなくなります。

タグで絞り込むには、Gradleなら useJUnitPlatform { excludeTags 'slow' } のように書きます。Mavenなら mvn test -Dgroups=slow や -DexcludedGroups=slow で指定できます。

JUnit 4とJUnit 5の対応表

ネットで見つけたJUnit 4のサンプルは、次の表を見ながら読み替えてください。

JUnit 4JUnit 5(Jupiter)
import org.junit.Testimport org.junit.jupiter.api.Test
@Before / @After@BeforeEach / @AfterEach
@BeforeClass / @AfterClass@BeforeAll / @AfterAll
@Ignore@Disabled
@Category@Tag
@RunWith(SpringRunner.class)@ExtendWith(SpringExtension.class)(Spring Bootのテストアノテーションに内包済み)
@Test(expected = XxxException.class)assertThrows(XxxException.class, () -> ...)
org.junit.Assert.assertEqualsorg.junit.jupiter.api.Assertions.assertEquals

大規模なプロジェクトの移行手順までは扱いません。新しく書くテストはJupiterの書き方で揃える、と覚えておけば十分です。

テストが実行されないときの確認ポイント

テストが動かないときは、まず次の4つを確認してみてください。

  • org.junit.Test をimportしている。Testcontainers 1.xなどの推移的依存でJUnit 4がクラスパスに入っていると、コンパイルは通ってしまいます。ですがVintageエンジンがないので、テストは1件も実行されません。
  • @BeforeAll がstaticになっていない。「must be static」という内容のエラーで失敗します。
  • @Nested クラスをstaticにしている。外側のフィールド(上の例では calc)を参照できず、コンパイルエラーになります。ネストしたテストとしても扱われません。
  • Gradleで useJUnitPlatform() を書き忘れている。テストが検出されず、何も実行されません。

特に1つ目はエラーにならないので気づきにくいです。「テストが通った」ではなく「テストが何件実行されたか」まで見る癖をつけておくと安心です。

次のステップ

基本を押さえたら、次はSpringと組み合わせたテストに進みましょう。Service層の依存をモック化するなら Service層テストでMockitoを使いこなす、Controllerのテストなら MockMvcを使ったControllerの単体テスト が参考になります。

JUnit Jupiterには、ほかにもExtension API、@RepeatedTest、@TestFactory を使ったDynamicTestなどの機能があります。必要になったら公式のユーザーガイドを見てみてください。

まとめ

Spring BootでJUnit Jupiter(JUnit 5以降)を使うのに、追加の依存はいりません。spring-boot-starter-test を入れて、org.junit.jupiter.api のアノテーションを使えばOKです。

まずは @Test と @BeforeEach と assertEquals で書き始めましょう。慣れてきたら、@DisplayName で仕様を日本語にしたり、@ParameterizedTest でコピペを減らしたり、@Nested でシナリオを分けたりしてみてください。テストがぐっと読みやすくなるはずです。