Spring Boot

JUnit 5

JUnit 5 is the test framework behind every Spring Boot test, pulled in by spring-boot-starter-test. It has three parts: the Platform (launcher), Jupiter (the annotations, assertions, and engine you write tests against), and Vintage (a JUnit 3/4 engine that is no longer included by default since Boot 2.4). This entry covers the per-method lifecycle, @Nested and @DisplayName, assertions and timeouts, parameterized tests, extensions, conditions and tags, ordering, dynamic tests, @TempDir, parallel execution, migration from JUnit 4, and the pitfalls that cause tests to be silently skipped or flaky.

Test Lifecycle

By default JUnit creates a new instance of the test class for every test method, so instance fields never leak from one test to the next. That is the single most useful guarantee in the framework, and it is why you can keep simple state in fields. @BeforeEach and @AfterEach run around every test. @BeforeAll and @AfterAll run once per class and must be static, because no instance exists yet. Annotate the class with @TestInstance(Lifecycle.PER_CLASS) to get one shared instance, which lets those methods be non-static, but then field state is shared across tests and you take on the isolation work yourself. Use PER_CLASS sparingly, mostly for expensive read-only setup. Prefer @BeforeEach for anything a test could mutate.
Java
class LifecycleTest {

    private final List<String> events = new ArrayList<>();   // fresh for every test

    @BeforeAll
    static void startSharedResources() {
        // once per class: must be static under the default lifecycle
    }

    @BeforeEach
    void setUp() {
        events.add("setUp");
    }

    @Test
    void first() {
        assertThat(events).containsExactly("setUp");
    }

    @Test
    void second() {
        assertThat(events).containsExactly("setUp");   // not ["setUp", "setUp"]
    }

    @AfterEach
    void tearDown() { }

    @AfterAll
    static void stopSharedResources() { }
}

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassLifecycleTest {

    private ExpensiveFixture fixture;   // shared by every test: keep it read-only

    @BeforeAll
    void init() {                       // non-static is allowed here
        fixture = ExpensiveFixture.load();
    }
}

@Nested and @DisplayName

Test classes grow quickly. @Nested inner classes group tests by scenario so the report reads like a specification, and @DisplayName replaces method names with readable sentences. Outer @BeforeEach methods run before inner ones, so shared setup lives in the outer class and scenario-specific setup in the inner one. Nested classes must be non-static inner classes. A good rule: one outer class per unit under test, one @Nested class per state or method.
Java
@DisplayName("OrderService")
class OrderServiceTest {

    private OrderRepository orderRepo;
    private OrderService orderService;

    @BeforeEach
    void setUp() {
        orderRepo = mock(OrderRepository.class);
        orderService = new OrderService(orderRepo);
    }

    @Nested
    @DisplayName("findById")
    class FindById {

        @Test
        @DisplayName("returns the order when it exists")
        void returnsOrder() {
            when(orderRepo.findById(1L)).thenReturn(Optional.of(buildOrder(1L)));

            assertThat(orderService.findById(1L).id()).isEqualTo(1L);
        }

        @Test
        @DisplayName("throws OrderNotFoundException when it does not exist")
        void throwsWhenMissing() {
            when(orderRepo.findById(99L)).thenReturn(Optional.empty());

            assertThrows(OrderNotFoundException.class,
                () -> orderService.findById(99L));
        }
    }
}

Assertions, Timeouts and Assumptions

Jupiter ships its own Assertions class, but most Spring projects use it only for what AssertJ does not cover well: assertThrows, assertAll, and assertDoesNotThrow. For everything else, AssertJ's fluent assertThat gives better failure messages. Note that JUnit's assertEquals takes (expected, actual), and swapping them produces a misleading message. assertAll runs every lambda and reports all failures together; AssertJ's SoftAssertions does the same for fluent assertions. For time limits, prefer the @Timeout annotation over assertTimeout. assertTimeout never interrupts the code, it just fails after the fact. assertTimeoutPreemptively does abort, but runs in another thread, which loses ThreadLocal state such as the Spring SecurityContext or an open transaction. @Timeout interrupts the test thread itself. Keep timing assertions generous, since tight limits are the classic source of flaky CI builds. Assumptions abort a test (reported as skipped, not failed) when a precondition is false.
Java
@Test
void user_hasExpectedFields() {
    UserResponse user = new UserResponse(1L, "Alice", "alice@example.com");

    assertAll("user",
        () -> assertEquals(1L, user.id()),               // (expected, actual)
        () -> assertEquals("Alice", user.name()),
        () -> assertEquals("alice@example.com", user.email()));

    // Fluent equivalent with AssertJ
    SoftAssertions.assertSoftly(soft -> {
        soft.assertThat(user.id()).isEqualTo(1L);
        soft.assertThat(user.name()).isEqualTo("Alice");
    });
}

@Test
void findById_throwsWithOrderId() {
    OrderNotFoundException ex = assertThrows(OrderNotFoundException.class,
        () -> orderService.findById(99L));

    assertThat(ex.getOrderId()).isEqualTo(99L);
}

@Test
@Timeout(value = 2, unit = TimeUnit.SECONDS)   // interrupts the test thread if exceeded
void report_generatesQuickly() {
    reportService.generate();
}

@Test
void externalCall_onlyWhenAllowed() {
    // abort (skip) the whole test when the precondition is false
    assumeTrue(externalApiEnabled(), "external API tests are disabled");
    assertThat(client.ping()).isTrue();
}

@Test
void extraChecks_onlyOnCi() {
    // assumingThat skips only the lambda; the rest of the test always runs
    assertThat(service.run()).isNotNull();
    assumingThat("true".equals(System.getenv("CI")),
        () -> assertThat(service.metrics()).isNotEmpty());
}

Parameterized and Repeated Tests

When several tests differ only by input, use @ParameterizedTest instead of copying methods or building dynamic tests. It runs the same method once per argument set and reports each as its own test. The artifact junit-jupiter-params is part of spring-boot-starter-test. Choose the source by shape: @ValueSource for a single list of literals, @CsvSource for small tables (values are converted to the parameter types automatically), @MethodSource for objects or enums, @EnumSource to iterate an enum, and @NullAndEmptySource for the classic null/empty/blank trio. Set name = "..." to make the report readable. @RepeatedTest(n) reruns the same test n times, which is occasionally useful for hunting race conditions, but it is not a substitute for fixing them.
Java
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "not-an-email", "a@", "@b.com"})
void emailValidator_rejectsInvalid(String input) {
    assertThat(validator.isValid(input)).isFalse();
}

@ParameterizedTest(name = "{0} tier on {1} gives discount {2}")
@CsvSource({
    "STANDARD, 100, 0",
    "SILVER,   100, 5",
    "GOLD,     100, 10",
    "PLATINUM, 200, 30"
})
void pricing_appliesTierDiscount(String tier, BigDecimal price, BigDecimal expected) {
    assertThat(pricingService.discount(price, tier))
        .isEqualByComparingTo(expected);
}

static Stream<Arguments> statusTransitions() {
    return Stream.of(
        arguments(OrderStatus.PENDING,   OrderStatus.CONFIRMED, true),
        arguments(OrderStatus.PENDING,   OrderStatus.SHIPPED,   false),
        arguments(OrderStatus.CONFIRMED, OrderStatus.SHIPPED,   true),
        arguments(OrderStatus.CONFIRMED, OrderStatus.PENDING,   false));
}

@ParameterizedTest(name = "{0} -> {1} allowed={2}")
@MethodSource("statusTransitions")
void orderStatus_transitions(OrderStatus from, OrderStatus to, boolean allowed) {
    assertThat(from.canTransitionTo(to)).isEqualTo(allowed);
}

Extensions

Extensions replace JUnit 4 runners and rules, and unlike runners you can combine as many as you need. You already use them without noticing: @SpringBootTest is meta-annotated with @ExtendWith(SpringExtension.class), and MockitoExtension powers @Mock. An extension implements one or more callback interfaces (BeforeEachCallback, AfterEachCallback, ParameterResolver, ExecutionCondition, and others). Keep per-test state in the ExtensionContext Store rather than in fields, because a single extension instance serves the whole run. Register extensions declaratively with @ExtendWith. Use @RegisterExtension on a field when the extension needs configuration, a builder, or you want a handle on it in the test. A static field lives for the whole class like @BeforeAll; an instance field lives per test. Extensions registered with @ExtendWith must be on the test class (or a parameter or method) to apply, so a resolver you write but forget to register will cause a ParameterResolutionException.
Java
public class TimingExtension implements BeforeEachCallback, AfterEachCallback {

    private static final ExtensionContext.Namespace NS =
        ExtensionContext.Namespace.create(TimingExtension.class);

    @Override
    public void beforeEach(ExtensionContext ctx) {
        ctx.getStore(NS).put("start", System.nanoTime());
    }

    @Override
    public void afterEach(ExtensionContext ctx) {
        long start = ctx.getStore(NS).remove("start", Long.class);
        long millis = (System.nanoTime() - start) / 1_000_000;
        System.out.printf("%s took %d ms%n", ctx.getDisplayName(), millis);
    }
}

// Deterministic fixture injection: avoid random data in tests
public class UserParameterResolver implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext p, ExtensionContext e) {
        return p.getParameter().getType() == User.class;
    }

    @Override
    public Object resolveParameter(ParameterContext p, ExtensionContext e) {
        return new User(1L, "alice@example.com");
    }
}

@ExtendWith({MockitoExtension.class, TimingExtension.class, UserParameterResolver.class})
class OrderServiceTest {

    @Mock OrderRepository orderRepo;
    @InjectMocks OrderService orderService;

    @Test
    void create_usesInjectedUser(User user) {      // resolved by UserParameterResolver
        when(orderRepo.save(any())).thenAnswer(inv -> inv.getArgument(0));
        orderService.create(buildRequest(), user.id());
    }
}

class StockClientTest {

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())   // no port clashes in CI
        .build();

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

        StockClient client = new StockClient(wireMock.baseUrl());

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

Conditions and Tags

Conditional annotations disable a test when its environment does not fit, and the report shows it as skipped rather than silently passing. Built-in conditions cover operating system, JRE range, environment variables, and system properties. For anything else, @EnabledIf and @DisabledIf point at a boolean method: unqualified names refer to a method in the test class (static unless the lifecycle is PER_CLASS), and a fully qualified name such as com.acme.Conditions#dockerAvailable refers to one elsewhere. If you use Testcontainers, @Testcontainers(disabledWithoutDocker = true) is simpler than writing your own check. @Tag labels tests so you can include or exclude them at the build level. A meta-annotation like @IntegrationTest keeps tag strings out of your code and avoids typos. Run tagged tests with Maven Surefire using -Dgroups and -DexcludedGroups, or with Gradle's includeTags and excludeTags.
Java
class ConditionalTests {

    @Test @EnabledOnOs(OS.LINUX)
    void linuxOnly() { }

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

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

    @Test @EnabledIfEnvironmentVariable(named = "RUN_SLOW_TESTS", matches = "true")
    void slowTest() { }

    @Test @DisabledIfEnvironmentVariable(named = "CI", matches = "true")
    void notOnCi() { }

    @Test @EnabledIf("dockerAvailable")
    void needsDocker() { }

    static boolean dockerAvailable() {
        try {
            Process p = new ProcessBuilder("docker", "info")
                .redirectErrorStream(true).start();
            return p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0;
        } catch (Exception e) {
            return false;
        }
    }
}

// Meta-annotation: one place defines the tag
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Tag("integration")
public @interface IntegrationTest { }

@IntegrationTest
class UserRepositoryIT { }

// Maven:  mvn test -Dgroups=fast      mvn test -DexcludedGroups=integration
// Gradle: test { useJUnitPlatform { includeTags 'fast'; excludeTags 'integration' } }

Test Ordering

Test methods run in a deterministic but intentionally unobvious order, so never rely on it. Tests that depend on each other (create, then log in, then order) fail unpredictably when run alone, when one is skipped, or under parallel execution. Fix the design first: make each test set up its own data. Explicit ordering is legitimate for slow end-to-end scenarios where setup is too expensive to repeat, and then it needs PER_CLASS lifecycle if state is held in fields. Use JUnit's @Order from org.junit.jupiter.api, not Spring's @Order with the same name. The wrong import compiles and is silently ignored.
Java
import org.junit.jupiter.api.Order;   // NOT org.springframework.core.annotation.Order

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CheckoutScenarioIT {

    private Long orderId;     // state shared on purpose across ordered steps

    @Test @Order(1)
    void placeOrder() {
        orderId = checkout.place(cart());
        assertThat(orderId).isNotNull();
    }

    @Test @Order(2)
    void payOrder() {
        assertThat(checkout.pay(orderId).status()).isEqualTo(OrderStatus.PAID);
    }
}

// Usually better: one test method with named steps, or independent tests with own data.
// Other orderers: MethodOrderer.DisplayName, MethodOrderer.MethodName, MethodOrderer.Random

Dynamic Tests

@TestFactory builds tests at runtime and returns them as a Stream, Iterable, or Collection of DynamicNode. Reach for it only when the cases are computed at runtime or form a tree (dynamicContainer); for a table of inputs, @ParameterizedTest is simpler and better supported by IDEs. Two behaviours surprise people. First, @BeforeEach and @AfterEach run once around the factory method, not once per dynamic test, so dynamic tests share state. Second, if you read from a file or database, read eagerly and close the resource before returning, since the stream is consumed lazily after the factory returns. @TestTemplate is the lower-level mechanism that @ParameterizedTest and @RepeatedTest are built on; you only need it to write your own invocation style.
Java
@TestFactory
Stream<DynamicTest> validationRulesFromFile() throws IOException {
    List<String[]> rows;
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(
            getClass().getResourceAsStream("/test-data/validation.csv"),
            StandardCharsets.UTF_8))) {
        rows = reader.lines().skip(1).map(l -> l.split(",")).toList();   // read eagerly
    }

    return rows.stream().map(row ->
        DynamicTest.dynamicTest(row[0], () ->
            assertThat(validator.isValid(row[1]))
                .isEqualTo(Boolean.parseBoolean(row[2]))));
}

@TestFactory
Stream<DynamicNode> statusTransitionsByState() {
    return Arrays.stream(OrderStatus.values()).map(from ->
        dynamicContainer(from + " transitions",
            Arrays.stream(OrderStatus.values()).map(to ->
                dynamicTest("-> " + to, () ->
                    assertThat(from.canTransitionTo(to))
                        .isEqualTo(StatusRules.isAllowed(from, to))))));
}

Temporary Files and Parallel Execution

@TempDir injects a fresh temporary directory (as Path or File) and deletes it after the test. It replaces JUnit 4's TemporaryFolder rule and removes any reason to write to relative paths or the real file system. Declared on a static field it is shared by the whole class. Parallel execution is off by default. Turn it on in src/test/resources/junit-platform.properties and run classes concurrently while keeping methods within a class sequential, which is the safe starting point. Parallelism exposes hidden shared state: static fields, a shared database, fixed ports, and Spring context mutations. Mark tests that touch a shared resource with @ResourceLock, or @Isolated for tests that must run alone.
Java
@Test
void export_writesCsv(@TempDir Path tempDir) throws IOException {
    Path out = tempDir.resolve("orders.csv");

    exporter.export(List.of(buildOrder(1L), buildOrder(2L)), out);

    assertThat(out).exists();
    assertThat(Files.readAllLines(out)).hasSize(3);   // header + 2 rows
}

// src/test/resources/junit-platform.properties
// junit.jupiter.execution.parallel.enabled = true
// junit.jupiter.execution.parallel.mode.default = same_thread
// junit.jupiter.execution.parallel.mode.classes.default = concurrent

@ResourceLock("shared-database")
class LegacyDatabaseTest { }

Migrating from JUnit 4

Boot 2.4 and later no longer include the Vintage engine, so JUnit 4 tests are not discovered at all unless you add junit-vintage-engine. That is useful for a gradual migration: add the engine, convert class by class, then remove it. Most of the migration is a find-and-replace on imports and annotations. The semantic changes to watch are the assertion message moving to the last argument and rules becoming extensions.
Java
// JUnit 4                              JUnit 5
// org.junit.Test                        org.junit.jupiter.api.Test
// @Before / @After                      @BeforeEach / @AfterEach
// @BeforeClass / @AfterClass            @BeforeAll / @AfterAll   (static)
// @Ignore                               @Disabled
// @Category(Slow.class)                 @Tag("slow")
// @RunWith(SpringRunner.class)          not needed with @SpringBootTest
// @RunWith(MockitoJUnitRunner.class)    @ExtendWith(MockitoExtension.class)
// @Rule TemporaryFolder                 @TempDir
// @Rule / @ClassRule                    @ExtendWith / @RegisterExtension
// @Test(expected = X.class)             assertThrows(X.class, () -> ...)
// @Test(timeout = 500)                  @Timeout(value = 500, unit = MILLISECONDS)
// assertEquals("msg", exp, act)         assertEquals(exp, act, "msg")   // message last

Common Pitfalls

Most JUnit problems are tests that never ran or that depend on each other. A leftover org.junit.Test import with only the Jupiter engine means the test is not discovered and the build stays green. @BeforeAll on a non-static method fails with PER_METHOD lifecycle. @Test methods must not be private or static. Shared static state and ordering dependencies pass locally and fail in CI or under parallel execution. assertTimeout does not stop slow code. Dynamic tests do not get their own @BeforeEach. A wrong @Order import is silently ignored. Swapped (expected, actual) arguments give misleading failure messages. A reliable habit: when you add a test, break the code on purpose once and confirm the test fails.
Java
// 1. Wrong import: test is silently not run by the Jupiter engine
import org.junit.Test;                          // BAD (JUnit 4)
import org.junit.jupiter.api.Test;              // GOOD

// 2. @BeforeAll must be static under the default lifecycle
@BeforeAll void init() { }                      // BAD: JUnitException at runtime
@BeforeAll static void init() { }               // GOOD

// 3. Shared mutable static state couples tests
private static final List<Order> ORDERS = new ArrayList<>();   // BAD
private final List<Order> orders = new ArrayList<>();          // GOOD: new per test

// 4. assertTimeout only reports after the fact
assertTimeout(Duration.ofSeconds(1), () -> neverEndingCall()); // hangs for as long as it runs
@Timeout(1) @Test void bounded() { neverEndingCall(); }        // interrupted after 1s