Spring BootJUnit 5
Spring Boot

JUnit 5

JUnit 5 is the default test framework in Spring Boot, included via spring-boot-starter-test. It comprises three modules: JUnit Platform (launcher), JUnit Jupiter (API and engine), and JUnit Vintage (JUnit 3/4 compatibility). This entry covers the full JUnit 5 API — lifecycle annotations, assertions, assumptions, dynamic tests, extensions, conditional execution, and test ordering.

Lifecycle Annotations

JUnit 5 lifecycle annotations control setup and teardown at the method and class level. @BeforeEach and @AfterEach run before and after every test. @BeforeAll and @AfterAll run once per class — their methods must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).
Java
class OrderServiceLifecycleTest {

    private OrderRepository    orderRepo;
    private InventoryService   inventoryService;
    private OrderService       orderService;

    // ── @BeforeAll — once before any test in the class ────────────────
    @BeforeAll
    static void initSharedResources() {
        // One-time setup: start containers, load fixtures, etc.
        System.out.println("Setting up shared resources");
    }

    // ── @BeforeEach — before every @Test method ───────────────────────
    @BeforeEach
    void setUp() {
        orderRepo        = mock(OrderRepository.class);
        inventoryService = mock(InventoryService.class);
        orderService     = new OrderService(orderRepo,
            inventoryService);
    }

    @Test
    void create_savesOrder() {
        when(orderRepo.save(any())).thenReturn(buildOrder(1L));
        OrderResponse response = orderService.create(
            buildRequest(), 1L);
        assertThat(response).isNotNull();
    }

    // ── @AfterEach — after every @Test method ─────────────────────────
    @AfterEach
    void tearDown() {
        // Clean up per-test state
        verifyNoMoreInteractions(orderRepo);
    }

    // ── @AfterAll — once after all tests in the class ─────────────────
    @AfterAll
    static void cleanUpSharedResources() {
        System.out.println("Tearing down shared resources");
    }
}

// ── @TestInstance — avoid static for @BeforeAll ───────────────────────
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class OrderServicePerClassTest {

    // Non-static shared state — safe with PER_CLASS lifecycle
    private final List<String> log = new ArrayList<>();

    @BeforeAll
    void initAll() {
        log.add("init");   // non-static — works with PER_CLASS
    }

    @AfterAll
    void cleanAll() {
        log.clear();
    }

    @Test
    void firstTest() {
        assertThat(log).contains("init");
    }
}

Assertions and Assumptions

JUnit 5 provides assertAll() for grouped assertions, assertThrows() for exception testing, and assertTimeout() for performance assertions. Assumptions abort a test (not fail it) when a precondition is not met — useful for environment-dependent tests.
Java
class JUnit5AssertionsTest {

    // ── assertAll — all assertions evaluated regardless of failures ────
    @Test
    void userResponse_hasAllFields() {
        UserResponse response = buildUserResponse();

        assertAll("user response fields",
            () -> assertThat(response.id())
                      .isEqualTo(1L),
            () -> assertThat(response.email())
                      .isEqualTo("alice@example.com"),
            () -> assertThat(response.name())
                      .isEqualTo("Alice"),
            () -> assertThat(response.roles())
                      .containsExactly("USER"),
            () -> assertThat(response.createdAt())
                      .isNotNull()
        );
        // Reports ALL failures, not just the first
    }

    // ── assertThrows — capture and inspect the exception ─────────────
    @Test
    void findById_throws_orderNotFoundException() {
        when(orderRepo.findById(99L))
            .thenReturn(Optional.empty());

        OrderNotFoundException ex = assertThrows(
            OrderNotFoundException.class,
            () -> orderService.findById(99L, 1L));

        assertThat(ex.getMessage())
            .contains("99");
        assertThat(ex.getOrderId())
            .isEqualTo(99L);
    }

    // ── assertDoesNotThrow ────────────────────────────────────────────
    @Test
    void findById_doesNotThrow_whenFound() {
        when(orderRepo.findByIdAndUserId(1L, 1L))
            .thenReturn(Optional.of(buildOrder(1L)));

        assertDoesNotThrow(() ->
            orderService.findById(1L, 1L));
    }

    // ── assertTimeout — verify performance ───────────────────────────
    @Test
    void findAll_completesWithinDeadline() {
        when(orderRepo.findAll(any(Pageable.class)))
            .thenReturn(Page.empty());

        assertTimeout(Duration.ofMillis(100), () ->
            orderService.findAll(PageRequest.of(0, 20)));
    }

    // ── Assumptions — skip test when precondition not met ─────────────
    @Test
    void externalApiCall_skipped_inCiEnvironment() {
        // Skip if running in CI where external calls are blocked
        assumeTrue(System.getenv("CI") == null,
            "Skipping external API test in CI");

        // This only runs locally
        ExternalApiResponse response =
            externalApiService.call();
        assertThat(response).isNotNull();
    }

    @Test
    void postgresSpecific_skipped_onH2() {
        assumingThat(
            databaseUrl.contains("postgresql"),
            () -> {
                // Only runs on PostgreSQL
                assertThat(jsonbQueryResult).isNotEmpty();
            }
        );
    }
}

JUnit 5 Extensions

Extensions replace JUnit 4 runners and rules. A single extension can implement multiple extension points: BeforeEachCallback, AfterEachCallback, ParameterResolver, and more. @ExtendWith registers an extension; @RegisterExtension registers a programmatic instance with full access to constructor parameters.
Java
// ── Custom extension ──────────────────────────────────────────────────
public class TimingExtension
        implements BeforeEachCallback, AfterEachCallback {

    private final Map<String, Long> startTimes =
        new ConcurrentHashMap<>();

    @Override
    public void beforeEach(ExtensionContext context) {
        startTimes.put(context.getUniqueId(),
            System.currentTimeMillis());
    }

    @Override
    public void afterEach(ExtensionContext context) {
        long start = startTimes.remove(context.getUniqueId());
        long elapsed = System.currentTimeMillis() - start;
        System.out.printf("Test '%s' took %dms%n",
            context.getDisplayName(), elapsed);
    }
}

// ── Parameter resolver extension ──────────────────────────────────────
public class RandomUserExtension
        implements ParameterResolver {

    @Override
    public boolean supportsParameter(
            ParameterContext paramCtx,
            ExtensionContext extCtx) {
        return paramCtx.getParameter().getType()
            .equals(User.class);
    }

    @Override
    public Object resolveParameter(
            ParameterContext paramCtx,
            ExtensionContext extCtx) {
        return TestFixtures.buildUser(
            (long) (Math.random() * 1000),
            "user" + System.nanoTime() + "@test.com");
    }
}

// ── Use extensions via annotation ─────────────────────────────────────
@ExtendWith({MockitoExtension.class, TimingExtension.class})
class OrderServiceTest {

    @Mock  private OrderRepository orderRepo;
    @InjectMocks private OrderService orderService;

    @Test
    void create_isPerformant(User injectedUser) {
        // injectedUser provided by RandomUserExtension if added
        when(orderRepo.save(any())).thenReturn(buildOrder(1L));
        orderService.create(buildRequest(), 1L);
    }
}

// ── @RegisterExtension — programmatic with state ─────────────────────
class WireMockExtensionTest {

    @RegisterExtension
    static WireMockExtension wireMock =
        WireMockExtension.newInstance()
            .options(wireMockConfig().port(9090))
            .build();

    @Test
    void callsExternalService() {
        wireMock.stubFor(get("/api/stock/1")
            .willReturn(okJson("{"available": true}")));

        StockResponse response = client.checkStock(1L);
        assertThat(response.available()).isTrue();
    }
}

Conditional Tests and Tagging

Conditional annotations skip tests based on the operating system, JVM version, environment variable, system property, or a custom condition. @Tag groups tests for selective execution — run only fast tests in CI, skip slow tests in development, or filter by feature area.
Java
// ── @Tag — group tests for selective execution ───────────────────────
@Tag("fast")
@Tag("unit")
class UserServiceFastTest {
    @Test void create_validates_email() { ... }
}

@Tag("slow")
@Tag("integration")
@Tag("database")
class UserRepositoryIntegrationTest {
    @Test void findByEmail_queriesDatabase() { ... }
}

// ── Run only tagged tests ─────────────────────────────────────────────
// Maven: mvn test -Dgroups="fast"
// Maven: mvn test -DexcludedGroups="slow"
// Gradle: test { useJUnitPlatform { includeTags "fast" } }

// ── @EnabledOnOs — OS-specific tests ─────────────────────────────────
@Test
@EnabledOnOs(OS.LINUX)
void linuxSpecificFilePermissions() { ... }

@Test
@DisabledOnOs({OS.WINDOWS})
void posixPermissionTest() { ... }

// ── @EnabledIfEnvironmentVariable ────────────────────────────────────
@Test
@EnabledIfEnvironmentVariable(named = "RUN_EXPENSIVE_TESTS",
                              matches = "true")
void expensiveIntegrationTest() { ... }

// ── @EnabledIfSystemProperty ──────────────────────────────────────────
@Test
@EnabledIfSystemProperty(named    = "test.database",
                         matches  = "postgres")
void postgresOnlyTest() { ... }

// ── @EnabledForJreRange ───────────────────────────────────────────────
@Test
@EnabledForJreRange(min = JRE.JAVA_17,
                    max = JRE.JAVA_21)
void java17To21Feature() { ... }

// ── Custom @Condition annotation ──────────────────────────────────────
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@EnabledIf("isDockerAvailable")
public @interface EnabledIfDockerAvailable {}

public class DockerCondition {
    static boolean isDockerAvailable() {
        try {
            Process p = Runtime.getRuntime()
                .exec("docker info");
            return p.waitFor() == 0;
        } catch (Exception e) {
            return false;
        }
    }
}

// ── @TestMethodOrder — deterministic ordering ─────────────────────────
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderedIntegrationTest {

    @Test @Order(1)
    void createUser()  { ... }

    @Test @Order(2)
    void loginUser()   { ... }

    @Test @Order(3)
    void placeOrder()  { ... }

    @Test @Order(4)
    void cancelOrder() { ... }
}

Dynamic Tests and Test Templates

@TestFactory generates test cases at runtime from a data source — a database, file, or computed set. Each generated test appears individually in the test report. @TestTemplate defines a test that runs multiple times based on a TestTemplateInvocationContextProvider extension.
Java
class DynamicTestsExample {

    private final PricingService pricingService =
        new PricingService();

    // ── @TestFactory — generate tests from a list ─────────────────────
    @TestFactory
    Stream<DynamicTest> pricing_calculatesCorrectDiscounts() {
        record Scenario(String tier, BigDecimal price,
                        BigDecimal expected) {}

        List<Scenario> scenarios = List.of(
            new Scenario("STANDARD",  bd("100"), bd("0")),
            new Scenario("SILVER",    bd("100"), bd("5")),
            new Scenario("GOLD",      bd("100"), bd("10")),
            new Scenario("PLATINUM",  bd("100"), bd("15")),
            new Scenario("GOLD",      bd("50"),  bd("5")),
            new Scenario("PLATINUM",  bd("200"), bd("30"))
        );

        return scenarios.stream().map(s ->
            DynamicTest.dynamicTest(
                "Tier=" + s.tier() + " price=" + s.price(),
                () -> {
                    BigDecimal discount =
                        pricingService.discount(s.price(), s.tier());
                    assertThat(discount)
                        .isEqualByComparingTo(s.expected());
                }));
    }

    // ── @TestFactory from external data source ─────────────────────────
    @TestFactory
    Stream<DynamicTest> validationRules_fromCsvFile()
            throws Exception {
        List<String[]> rows = readCsvFile("/test-data/validation.csv");
        return rows.stream().map(row ->
            DynamicTest.dynamicTest(row[0], () -> {
                String  input    = row[1];
                boolean expected = Boolean.parseBoolean(row[2]);
                assertThat(validator.isValid(input))
                    .isEqualTo(expected);
            }));
    }

    // ── Nested dynamic container ───────────────────────────────────────
    @TestFactory
    Stream<DynamicNode> orderStatusTransitions() {
        return Stream.of(
            dynamicContainer("PENDING transitions",
                Stream.of(
                    dynamicTest("→ CONFIRMED is allowed",
                        () -> assertThat(
                            OrderStatus.PENDING.canTransitionTo(
                                OrderStatus.CONFIRMED)).isTrue()),
                    dynamicTest("→ SHIPPED is not allowed",
                        () -> assertThat(
                            OrderStatus.PENDING.canTransitionTo(
                                OrderStatus.SHIPPED)).isFalse())
                )),
            dynamicContainer("CONFIRMED transitions",
                Stream.of(
                    dynamicTest("→ SHIPPED is allowed",
                        () -> assertThat(
                            OrderStatus.CONFIRMED.canTransitionTo(
                                OrderStatus.SHIPPED)).isTrue()),
                    dynamicTest("→ PENDING is not allowed",
                        () -> assertThat(
                            OrderStatus.CONFIRMED.canTransitionTo(
                                OrderStatus.PENDING)).isFalse())
                ))
        );
    }

    private BigDecimal bd(String value) {
        return new BigDecimal(value);
    }
}