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/@AfterEachand@BeforeAll/@AfterAllare Jupiter's renames of@Before/@Afterand@BeforeClass/@AfterClass— same per-test/per-class semantics, but@BeforeAll/@AfterAllmethods can be non-static if the test class uses@TestInstance(Lifecycle.PER_CLASS).@DisplayNamelets 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
SpringExtensionand Mockito'sMockitoExtensionon the same class was awkward or impossible under the one-runner-per-class JUnit 4 model.ParameterResolverextensions can inject values directly into test method parameters — this is how Jupiter supports things like injecting aTestInfo, 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 aStream/Collectionof 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.Testout of muscle memory instead oforg.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 betweenorg.junit.Assertand Jupiter'sAssertions— copy-pasting JUnit 4 assertion code can compile against the wrong class if both are on the classpath.Not adding
junit-vintage-enginewhen 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
assertAllwhen checking multiple independent fields — without it, a failure on the first field hides whether the rest would also have failed.