Searching for Spring Boot testing still turns up plenty of examples that use @RunWith(SpringRunner.class) and @Before. Many developers have copied one of these into their own project and found that not a single test ran.

This article covers the basics of writing tests with JUnit Jupiter (the testing API of JUnit 5 and later), which comes with spring-boot-starter-test. It shows how to make tests easier to read with @DisplayName, @ParameterizedTest, and @Nested. It also includes a mapping table for converting JUnit 4 examples. Tests that use Mockito are covered in Spring Bootでテストを書こう - JUnitとMockitoで始める単体テスト入門, so this article sticks to JUnit Jupiter itself.

This article assumes Spring Boot 3.x (JUnit Jupiter 5.x). In Spring Boot 4.x, spring-boot-starter-test uses JUnit 6. The org.junit.jupiter.api annotations and Assertions shown here work the same way there.

JUnit Jupiter in spring-boot-starter-test

JUnit 5 has three parts:

  • JUnit Platform (the foundation that runs tests)
  • JUnit Jupiter (the new API, such as @Test, plus its test engine)
  • JUnit Vintage (a compatibility engine for running JUnit 4 tests)

spring-boot-starter-test bundles JUnit Jupiter with AssertJ, Hamcrest, Mockito, and other libraries. You don’t need to add any extra dependencies for JUnit. For how starters work, see Spring Boot Starterって何?.

One thing to watch out for: since Spring Boot 2.4, the Vintage engine is no longer included. Tests written in JUnit 4 style won’t run unless you change something.

With Maven, this is all you need:

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

With Gradle, make sure you include useJUnitPlatform():

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

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

Projects generated with Spring Initializr should already be set up this way.

Writing a Minimal Test Class

As the class under test, we’ll use a small class that calculates tax-inclusive prices and discounts.

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());
    }
}

The simplest possible test for it looks like this:

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));
    }
}

In JUnit 5, test classes and methods don’t have to be public. Package-private is enough. Make sure you import org.junit.jupiter.api.Test.

This test doesn’t start a Spring context, so it runs almost instantly. When you use @SpringBootTest or @WebMvcTest, you still don’t need @RunWith, because those annotations already include @ExtendWith(SpringExtension.class). For help choosing a test annotation, see テストアノテーション使い分け早見表.

Lifecycle Annotations

Setup and teardown use these four annotations (imports are omitted from the code snippets from here on):

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"); }
}

When you run it, the output appears in this order: beforeAll → (“beforeEach → test → afterEach”) × 2 → afterAll. JUnit doesn’t guarantee whether test1 or test2 runs first.

By default, @BeforeAll and @AfterAll methods must be static. If you add @TestInstance(TestInstance.Lifecycle.PER_CLASS) to the class, they no longer need to be static. When you’re starting out, though, it’s safest to keep them static.

JUnit 5 creates a new instance of the test class for each test method. Field values don’t carry over from one test to the next, so tests can’t affect each other through fields.

Giving Tests Readable Names with @DisplayName

A method name alone often doesn’t make it clear what a test checks. @DisplayName lets you give a test any display name you like.

@DisplayName("PriceCalculator")
class PriceCalculatorTest {

    PriceCalculator calc;

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

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

IntelliJ IDEA’s test results tree shows this display name in place of the method name. The display name here is in Japanese and says “the tax-inclusive price is the base price plus 10%.” A useful convention is to keep method names in English and write the specification in @DisplayName in your team’s natural language. The rest of the examples use this calc field.

Assertion Basics

The most commonly used methods are the static methods in org.junit.jupiter.api.Assertions.

In assertEquals(expected, actual), the expected value comes first. A failure is reported as something like “expected: <1100> but was: <999>”. If you swap the arguments, the message says the opposite of what happened, which is confusing. You can also use assertTrue, assertFalse, assertNull, and others, and pass a failure message as the last argument.

Use assertThrows to verify exceptions. It returns the exception that was thrown, so you can check its message too.

@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)));
}

If one assertion inside assertAll fails, the rest still run, and all failures are reported together.

spring-boot-starter-test also includes AssertJ, so you can use style like assertThat(calc.withTax(1000)).isEqualTo(1100) right away. Pick one style and use it consistently across your team.

Combining Input Patterns with @ParameterizedTest

When you want to run the same logic with different inputs, it’s tempting to copy and paste test methods. If you use @ParameterizedTest instead of @Test, one method can take many input patterns. The junit-jupiter-params library it needs is also included in starter-test.

Passing Single Values with @ValueSource

The simplest option is @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));
}

Adding @NullAndEmptySource also adds null and the empty string as inputs. Together with the whitespace-only strings, the test checks four empty-input cases at once.

Passing Inputs and Expected Values Together with @CsvSource

When you want to pass inputs together with their expected values, @CsvSource is a good fit.

@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));
}

With a text block, the cases are laid out like a table, which makes them easy to scan. Placeholders such as {index} and {0} in the name attribute produce display names like “1: 1000円の10%引きは900円” (“1: 10% off 1000 yen is 900 yen”).

When passing strings, be careful about the difference between an empty string and null. In apple, '' the second value is an empty string. In apple, the second value is null. If you specify nullValues = "NULL", any value written as NULL is treated as null.

Passing Complex Arguments with @MethodSource

When arguments are awkward to express in CSV, such as lists or objects, use @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));
}

Make the factory method static, and use Arguments.of(...) to build the arguments for each invocation.

Grouping Tests by Scenario with @Nested

As your tests grow, @Nested helps you organize them, for example into success and failure cases. Here is the complete example, imports included:

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 goes on non-static inner classes. The outer class’s @BeforeEach also runs for the inner tests, so you can keep shared setup in the outer class. Combined with @DisplayName, the test results form a tree that reads like a specification.

For an example that combines @SpringBootTest with @Nested, see @SpringBootTestのwebEnvironment使い分け.

Filtering Tests with @Disabled and @Tag

Use @Disabled to switch off a test temporarily, and @Tag to categorize tests.

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

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

Always give a reason in @Disabled. Without one, nobody can tell later whether it’s safe to turn the test back on.

To filter by tag in Gradle, write something like useJUnitPlatform { excludeTags 'slow' }. In Maven, use mvn test -Dgroups=slow or -DexcludedGroups=slow.

JUnit 4 to JUnit 5 Mapping Table

Use this table to convert JUnit 4 examples you find online.

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) (already included in Spring Boot’s test annotations)
@Test(expected = XxxException.class)assertThrows(XxxException.class, () -> ...)
org.junit.Assert.assertEqualsorg.junit.jupiter.api.Assertions.assertEquals

This article doesn’t cover migration steps for large projects. The main rule to remember is to write all new tests in Jupiter style.

What to Check When Tests Don’t Run

If your tests aren’t running, check these four things first:

  • You imported org.junit.Test. If JUnit 4 is on the classpath through a transitive dependency such as Testcontainers 1.x, the code still compiles. But since there’s no Vintage engine, none of those tests run.
  • Your @BeforeAll method isn’t static. The test fails with an error saying the method “must be static.”
  • You made a @Nested class static. It can’t access fields of the outer class (calc in the example above), so you get a compile error. JUnit also won’t treat it as a nested test.
  • You forgot useJUnitPlatform() in Gradle. No tests are discovered, and nothing runs.

The first one is especially easy to miss because it produces no error. Get into the habit of checking how many tests actually ran, not just whether the build reports that tests passed.

Next Steps

Once you have the basics down, move on to tests that use Spring. To mock Service-layer dependencies, see Service層テストでMockitoを使いこなす. For Controller tests, see MockMvcを使ったControllerの単体テスト.

JUnit Jupiter has other features too, including the Extension API, @RepeatedTest, and dynamic tests with @TestFactory. Check the official User Guide when you need them.

Summary

You don’t need any extra dependencies to use JUnit Jupiter (JUnit 5 and later) with Spring Boot. Add spring-boot-starter-test and use the annotations from org.junit.jupiter.api.

Start with @Test, @BeforeEach, and assertEquals. Once you’re comfortable, try describing specifications with @DisplayName, cutting copy-paste with @ParameterizedTest, and grouping scenarios with @Nested. Your tests will be much easier to read.