All topics
Testing · Learning hub

JUnit 5 notes for developers

Master JUnit 5 with a curated set of 1 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallTest yourself — JUnit 5 quizMore Testing notes
JUnit 5

JUnit 5 (Jupiter) Essentials

JUnit 5 (Jupiter) Essentials What Changed: The Platform / Jupiter / Vintage Split JUnit 5 is a ground-up rewrite of JUnit's architecture, not just a version bum

JUnit 5 (Jupiter) Essentials

What Changed: The Platform / Jupiter / Vintage Split

JUnit 5 is a ground-up rewrite of JUnit's architecture, not just a version bump on JUnit 4. The classic org.junit package (@Before, @RunWith, @Rule) is a completely different API from JUnit 5's org.junit.jupiter.api package — this page covers what is genuinely new in Jupiter, not a re-labeled walkthrough of JUnit 4 concepts. See the separate JUnit page for the classic model if that's what your project still uses.

The most important structural change is that "JUnit 5" is actually three components: the JUnit Platform (the launcher infrastructure that IDEs and build tools talk to), JUnit Jupiter (the new programming model — this is what people mean day-to-day by "JUnit 5"), and JUnit Vintage (a compatibility engine that runs old JUnit 3/4 tests unmodified). Vintage exists specifically so a large legacy codebase can adopt the Platform without rewriting every existing test up front.

// JUnit 5 is not one library but three, together called "JUnit Platform":
//
//   JUnit Platform  — the foundation: launches tests, discovers them, reports
//                      results. IDEs and build tools (Maven/Gradle) talk to this.
//   JUnit Jupiter    — the NEW programming model + extension API: @Test,
//                      @BeforeEach, @ParameterizedTest, @ExtendWith, etc.
//                      "Jupiter" IS what most people mean by "JUnit 5".
//   JUnit Vintage    — a compatibility engine that runs OLD JUnit 3/4 tests
//                      unmodified on top of the JUnit Platform, enabling
//                      gradual migration instead of a big-bang rewrite.
//
// Maven dependency (Jupiter only — add junit-vintage-engine too if you also
// have legacy JUnit 4 tests in the same module):
//
// <dependency>
//   <groupId>org.junit.jupiter</groupId>
//   <artifactId>junit-jupiter</artifactId>
//   <version>5.10.2</version>
//   <scope>test</scope>
// </dependency>

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

class InventoryServiceTest {

    private InventoryService inventory;

    // @BeforeEach / @AfterEach replace JUnit 4's @Before / @After — same
    // per-test semantics, renamed for clarity against @BeforeAll/@AfterAll.
    @BeforeEach
    void setUp() {
        inventory = new InventoryService();
    }

    @Test
    @DisplayName("reserving stock reduces the available quantity")
    void reservingStockReducesAvailableQuantity() {
        inventory.stock("sku-1", 10);
        inventory.reserve("sku-1", 3);
        assertEquals(7, inventory.available("sku-1"));
    }
}
  • @BeforeEach/@AfterEach and @BeforeAll/@AfterAll are Jupiter's renames of @Before/@After and @BeforeClass/@AfterClass — same per-test/per-class semantics, but @BeforeAll/@AfterAll methods can be non-static if the test class uses @TestInstance(Lifecycle.PER_CLASS).

  • @DisplayName lets a test report a human-readable name in IDE/CI output instead of the raw method name — purely cosmetic, but it makes failure reports far more scannable.

  • Jupiter requires Java 8+ and leans on lambdas throughout the API (assertions, extensions, dynamic tests) in a way JUnit 4's pre-lambda-era API never could.

The Extension Model: @ExtendWith Replaces Runners and Rules

JUnit 4 had two separate, limited mechanisms for plugging in behavior: @RunWith (exactly one runner per class) and @Rule/@ClassRule (composable, but a parallel, more awkward API). Jupiter unifies both into a single extension model: any class implementing one of the extension interfaces (BeforeEachCallback, ParameterResolver, TestInstancePostProcessor, and others) can be registered with @ExtendWith — and, unlike @RunWith, a test class can stack multiple extensions at once.

// @ExtendWith replaces JUnit 4's @RunWith — and crucially, a class can
// register MULTIPLE extensions, which a single @RunWith could never do.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class) // could also stack @ExtendWith(SpringExtension.class) here
class OrderServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @Test
    void chargesCustomerOnCheckout() {
        // Mockito's Jupiter extension initializes @Mock fields automatically —
        // no MockitoJUnitRunner, no MockitoAnnotations.initMocks() call needed.
        when(paymentGateway.charge(anyString(), any())).thenReturn(true);
        OrderService orderService = new OrderService(paymentGateway);

        assertTrue(orderService.checkout("cust-1", "sku-1"));
        verify(paymentGateway).charge(eq("cust-1"), any());
    }
}

// A custom extension implementing BeforeEachCallback — the general-purpose
// mechanism that replaced JUnit 4's separate Runner and @Rule/@ClassRule concepts.
class LoggingExtension implements org.junit.jupiter.api.extension.BeforeEachCallback {
    @Override
    public void beforeEach(org.junit.jupiter.api.extension.ExtensionContext context) {
        System.out.println("Starting: " + context.getDisplayName());
    }
}
  • This is the single biggest practical unlock over JUnit 4: combining Spring's SpringExtension and Mockito's MockitoExtension on the same class was awkward or impossible under the one-runner-per-class JUnit 4 model.

  • ParameterResolver extensions can inject values directly into test method parameters — this is how Jupiter supports things like injecting a TestInfo, a Spring bean, or a Mockito mock straight into a test method signature.

Parameterized Tests

JUnit 4 had a parameterized test mechanism too, but it required an entirely separate @RunWith(Parameterized.class) runner, awkward constructor injection, and a static @Parameters method — different enough in shape that it felt bolted on. Jupiter's @ParameterizedTest is a first-class annotation with multiple, more ergonomic argument sources living directly alongside @Test.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import static org.junit.jupiter.api.Assertions.*;

class DiscountCalculatorTest {

    // @ValueSource — a single literal per invocation, for simple input sweeps
    @ParameterizedTest
    @ValueSource(ints = {0, 1, 5, 100})
    void neverReturnsNegativeDiscount(int quantity) {
        assertTrue(DiscountCalculator.forQuantity(quantity) >= 0);
    }

    // @CsvSource — multiple correlated arguments per invocation, inline
    @ParameterizedTest(name = "{0} items → {1}% discount")
    @CsvSource({
        "1,   0",
        "10,  5",
        "50,  10",
        "100, 15"
    })
    void appliesTieredDiscount(int quantity, int expectedPercent) {
        assertEquals(expectedPercent, DiscountCalculator.forQuantity(quantity));
    }

    // @MethodSource — for cases too complex/dynamic to express as a literal table
    @ParameterizedTest
    @MethodSource("invalidQuantities")
    void rejectsInvalidQuantities(int quantity) {
        assertThrows(IllegalArgumentException.class, () -> DiscountCalculator.forQuantity(quantity));
    }

    static java.util.stream.Stream<Integer> invalidQuantities() {
        return java.util.stream.Stream.of(-1, -100, Integer.MIN_VALUE);
    }

    // @EnumSource — iterate every constant of an enum automatically
    @ParameterizedTest
    @EnumSource(CustomerTier.class)
    void everyTierHasANonNullLabel(CustomerTier tier) {
        assertNotNull(tier.getLabel());
    }
}
  • @ValueSource — a flat list of one primitive/String argument per run; simplest option for a single-parameter sweep.

  • @CsvSource — inline multi-argument rows, good for small correlated input/output tables without a separate method or file.

  • @MethodSource — points at a static method supplying a Stream/Collection of arguments, for cases too dynamic or complex for a literal table.

  • @EnumSource — automatically runs the test once per constant of a given enum, useful for exhaustively covering a small fixed set of states.

Nested Classes and Dynamic Tests

@Nested lets you group related tests into inner classes that share the same lifecycle machinery as top-level test classes — each @Nested class gets its own @BeforeEach chain (outer, then inner), which is genuinely useful for expressing "given this state... when X... then Y" structures that JUnit 4 could only approximate with naming conventions or separate classes entirely.

@TestFactory methods return a stream/collection of DynamicTest instances generated at runtime, instead of statically declaring one @Test method per case. This matters when the actual set of test cases is only known from data (a CSV of boundary values, a config file, an API response) rather than something you'd hand-write as individual methods.

import org.junit.jupiter.api.*;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

// @Nested groups related tests into inner classes — pure organizational
// sugar in JUnit 4 terms, but here it's a first-class Jupiter feature with
// its own lifecycle: each @Nested class gets its own @BeforeEach chain.
class ShoppingCartTest {

    private ShoppingCart cart;

    @BeforeEach
    void setUp() {
        cart = new ShoppingCart();
    }

    @Nested
    @DisplayName("when the cart is empty")
    class WhenEmpty {
        @Test
        void totalIsZero() {
            assertEquals(0, cart.getTotal());
        }

        @Test
        void checkoutIsDisabled() {
            assertFalse(cart.canCheckout());
        }
    }

    @Nested
    @DisplayName("when items have been added")
    class WhenItemsAdded {
        @BeforeEach
        void addItems() {
            cart.addItem("sku-1", 2); // runs AFTER the outer @BeforeEach
        }

        @Test
        void totalReflectsAddedItems() {
            assertTrue(cart.getTotal() > 0);
        }
    }

    // @TestFactory — generates tests at runtime rather than declaring them
    // statically, useful when the set of cases comes from data, not code.
    @TestFactory
    Stream<DynamicTest> discountBoundaryTests() {
        return Stream.of(9, 10, 11, 49, 50, 51)
            .map(qty -> dynamicTest("quantity " + qty, () -> {
                boolean expectedDiscount = qty >= 10;
                assertEquals(expectedDiscount, DiscountCalculator.forQuantity(qty) > 0);
            }));
    }
}

Assertion Improvements

Jupiter's assertions live in org.junit.jupiter.api.Assertions — a similarly named but distinct class from JUnit 4's org.junit.Assert, and not a drop-in import replacement (argument order and available methods differ in places). The two additions worth calling out specifically: assertAll runs a group of assertions together and reports every failure at once instead of stopping at the first one, and assertThrows is lambda-native from the start rather than retrofitted, returning the caught exception for further inspection.

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

@Test
void demonstratesJupiterAssertionImprovements() {
    // assertAll groups multiple assertions so ALL of them run and report,
    // instead of stopping at the first failure like chained plain assertions would.
    Order order = orderService.create("cust-1", "sku-1", 2);
    assertAll("order fields",
        () -> assertEquals("cust-1", order.getCustomerId()),
        () -> assertEquals(2, order.getQuantity()),
        () -> assertNotNull(order.getCreatedAt()),
        () -> assertTrue(order.getTotal().compareTo(BigDecimal.ZERO) > 0)
    );

    // assertThrows is lambda-based and returns the exception for further assertions —
    // this exists in JUnit 4.13+ too, but Jupiter's Executable/ThrowingSupplier
    // functional interfaces make it a first-class, idiomatic part of the API.
    IllegalStateException ex = assertThrows(IllegalStateException.class,
        () -> orderService.ship(unpaidOrder));
    assertEquals("Cannot ship an unpaid order", ex.getMessage());

    // assertTimeout runs the block and fails if it exceeds the duration —
    // unlike @Test(timeout=...) in JUnit 4, it does NOT interrupt the thread,
    // so use assertTimeoutPreemptively if you need the block aborted early.
    assertTimeout(java.time.Duration.ofMillis(200), () -> pricingEngine.recalculate(order));
}

Common Pitfalls When Migrating from JUnit 4

  • Importing org.junit.Test out of muscle memory instead of org.junit.jupiter.api.Test — the two coexist on the classpath if Vintage is present, and mixing them silently produces tests that don't run where you expect.

  • Assuming @RunWith(Parameterized.class)-style parameterized tests port directly — the argument-source model is different enough that these need an actual rewrite to @ParameterizedTest, not a search-and-replace.

  • Forgetting that assertEquals's argument order and overloads differ between org.junit.Assert and Jupiter's Assertions — copy-pasting JUnit 4 assertion code can compile against the wrong class if both are on the classpath.

  • Not adding junit-vintage-engine when a module still has real JUnit 4 tests — without it, the Platform simply won't discover and run them, silently dropping coverage.

  • Chaining several plain assertions instead of using assertAll when checking multiple independent fields — without it, a failure on the first field hides whether the rest would also have failed.

Keep your JUnit 5 knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever