Contents
- Dependency
- Test Lifecycle
- Assertions
- Parameterized Tests
- Dynamic Tests (@TestFactory)
- @Nested — Grouping Related Tests
- Extensions & @ExtendWith
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:
| Annotation | Runs | Method must be |
| @BeforeAll | Once before any test in the class | static (or PER_CLASS) |
| @BeforeEach | Before every test method | instance |
| @AfterEach | After every test method | instance |
| @AfterAll | Once after all tests in the class | static (or PER_CLASS) |
| @Disabled | Skips the test (with optional reason) | any |
| @Timeout | Fails if test exceeds duration | any |
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.