Contents

Spring Boot projects include JUnit 5 via spring-boot-starter-test. For standalone projects add the JUnit BOM explicitly.

<!-- Spring Boot — already includes JUnit 5 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- Standalone Maven project --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.11.0</version> <scope>test</scope> </dependency> JUnit 5 is split into three modules: junit-jupiter-api (write tests), junit-jupiter-engine (run tests), and junit-jupiter-params (parameterized tests). The junit-jupiter aggregate pulls all three in one declaration.

JUnit 5 creates a new test instance per method by default (PER_METHOD). Use @TestInstance(Lifecycle.PER_CLASS) to share state across methods and allow @BeforeAll/@AfterAll on non-static methods.

import org.junit.jupiter.api.*; class OrderServiceTest { private OrderService service; @BeforeAll static void initAll() { System.out.println("Once before all tests in this class"); } @BeforeEach void init() { service = new OrderService(); // fresh instance per test } @Test void createsOrder() { Order order = service.create("item-1", 3); Assertions.assertNotNull(order.id()); } @Test @DisplayName("Rejects zero quantity") void rejectsZeroQty() { Assertions.assertThrows(IllegalArgumentException.class, () -> service.create("item-1", 0)); } @AfterEach void tearDown() { service = null; } @AfterAll static void cleanAll() { System.out.println("Once after all tests"); } }

Key lifecycle annotations:

AnnotationRunsMethod must be
@BeforeAllOnce before any test in the classstatic (or PER_CLASS)
@BeforeEachBefore every test methodinstance
@AfterEachAfter every test methodinstance
@AfterAllOnce after all tests in the classstatic (or PER_CLASS)
@DisabledSkips the test (with optional reason)any
@TimeoutFails if test exceeds durationany

JUnit 5's Assertions class provides assertAll for grouped checks, assertThrows for exception testing, and assertTimeout for performance assertions — all with optional failure messages.

import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; class AssertionExamplesTest { @Test void groupedAssertions() { var user = new User("Alice", "alice@example.com", 30); // All assertions are evaluated even if one fails Assertions.assertAll("user", () -> Assertions.assertEquals("Alice", user.name()), () -> Assertions.assertEquals("alice@example.com", user.email()), () -> Assertions.assertTrue(user.age() >= 18, "Must be adult") ); } @Test void exceptionAssertion() { var service = new PaymentService(); // Verify the right exception type is thrown IllegalArgumentException ex = Assertions.assertThrows( IllegalArgumentException.class, () -> service.charge(-10) ); Assertions.assertTrue(ex.getMessage().contains("negative")); } @Test void timeoutAssertion() { // Fails if the lambda takes more than 500 ms Assertions.assertTimeout(java.time.Duration.ofMillis(500), () -> { Thread.sleep(100); }); } @Test void nullAndInstanceChecks() { Object obj = "hello"; Assertions.assertNotNull(obj); Assertions.assertInstanceOf(String.class, obj); Assertions.assertNull(null); } }

Annotate a test method with @ParameterizedTest and choose a source annotation. JUnit runs the method once per set of arguments, reporting each invocation separately in the test report.

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.*; import org.junit.jupiter.api.Assertions; class ParameterizedExamplesTest { // Single value source — strings @ParameterizedTest @ValueSource(strings = { "racecar", "level", "deified" }) void isPalindrome(String word) { Assertions.assertTrue(StringUtils.isPalindrome(word)); } // Multiple columns — CSV inline @ParameterizedTest @CsvSource({ "Alice, 25, true", "Bob, 17, false", "Charlie, 0, false" }) void isEligible(String name, int age, boolean expected) { Assertions.assertEquals(expected, EligibilityChecker.check(age)); } // From a static factory method @ParameterizedTest @MethodSource("orderProvider") void processesOrder(String product, int qty, double expectedTotal) { double total = new PricingService().calculate(product, qty); Assertions.assertEquals(expectedTotal, total, 0.001); } static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> orderProvider() { return java.util.stream.Stream.of( Arguments.of("Widget", 1, 9.99), Arguments.of("Widget", 5, 49.95), Arguments.of("Gadget", 2, 29.98) ); } // Enum source @ParameterizedTest @EnumSource(value = OrderStatus.class, names = { "PENDING", "PROCESSING" }) void isActiveStatus(OrderStatus status) { Assertions.assertTrue(status.isActive()); } // Load from CSV file on the classpath @ParameterizedTest @CsvFileSource(resources = "/test-data/prices.csv", numLinesToSkip = 1) void pricesFromFile(String sku, double price) { Assertions.assertTrue(price > 0); } }

@TestFactory generates test cases at runtime by returning a Stream, Collection, or Iterable of DynamicTest objects. Use it when the number of tests or their names can't be determined at compile time.

import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; import java.util.stream.Stream; import static org.junit.jupiter.api.DynamicTest.dynamicTest; import org.junit.jupiter.api.Assertions; class DynamicTestExamplesTest { @TestFactory Stream<DynamicTest> roundTripTests() { // Generate a test for each supported currency return Stream.of("USD", "EUR", "GBP", "JPY") .map(currency -> dynamicTest( "Round-trip for " + currency, () -> { String encoded = CurrencyEncoder.encode(100, currency); Assertions.assertEquals(currency, CurrencyEncoder.decode(encoded).currency()); } )); } @TestFactory Stream<DynamicTest> fromDatabase() { // Tests driven by a list from a data source return TestDataRepository.loadCases().stream() .map(tc -> dynamicTest(tc.name(), () -> { var result = new Calculator().evaluate(tc.expression()); Assertions.assertEquals(tc.expected(), result); })); } }

@Nested inner classes let you organise tests into groups with their own @BeforeEach/@AfterEach setup. The outer class's @BeforeEach still runs first, giving a layered setup hierarchy.

import org.junit.jupiter.api.*; @DisplayName("ShoppingCart") class ShoppingCartTest { ShoppingCart cart; @BeforeEach void init() { cart = new ShoppingCart(); } @Nested @DisplayName("when empty") class WhenEmpty { @Test void hasZeroItems() { Assertions.assertEquals(0, cart.size()); } @Test void totalIsZero() { Assertions.assertEquals(0.0, cart.total()); } @Test void isEmptyReturnsTrue() { Assertions.assertTrue(cart.isEmpty()); } } @Nested @DisplayName("after adding one item") class AfterAddingItem { @BeforeEach void addItem() { cart.add(new Item("Widget", 9.99)); } @Test void hasOneItem() { Assertions.assertEquals(1, cart.size()); } @Test void totalIsPrice() { Assertions.assertEquals(9.99, cart.total()); } @Test void isNotEmpty() { Assertions.assertFalse(cart.isEmpty()); } @Nested @DisplayName("after removing the item") class AfterRemoving { @BeforeEach void remove() { cart.remove("Widget"); } @Test void isEmpty() { Assertions.assertTrue(cart.isEmpty()); } } } }

Extensions replace JUnit 4's @RunWith. Compose multiple extensions via repeated @ExtendWith. Commonly used extensions: MockitoExtension, SpringExtension, WireMockExtension.

import org.junit.jupiter.api.extension.*; // Custom extension that injects a temp directory public class TempDirExtension implements BeforeEachCallback, AfterEachCallback { @Override public void beforeEach(ExtensionContext ctx) throws Exception { // Inject via store java.nio.file.Path tmp = java.nio.file.Files.createTempDirectory("junit5-"); ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("tmpDir", tmp); } @Override public void afterEach(ExtensionContext ctx) throws Exception { java.nio.file.Path tmp = ctx.getStore(ExtensionContext.Namespace.GLOBAL) .get("tmpDir", java.nio.file.Path.class); deleteRecursively(tmp); } private void deleteRecursively(java.nio.file.Path dir) throws Exception { java.nio.file.Files.walk(dir) .sorted(java.util.Comparator.reverseOrder()) .forEach(p -> p.toFile().delete()); } } // Using multiple extensions together @ExtendWith({ MockitoExtension.class, TempDirExtension.class }) class FileProcessorTest { @Mock OrderRepository repo; // injected by MockitoExtension @TempDir java.nio.file.Path tmpDir; // JUnit 5 built-in @TempDir @Test void processesFile() { // tmpDir is a fresh empty directory for this test Assertions.assertTrue(tmpDir.toFile().exists()); } } JUnit 5 ships a built-in @TempDir annotation that injects a temporary directory automatically — no custom extension needed for that specific case. Use @ExtendWith for cross-cutting concerns like mock injection, timing, or test data setup.