JUnit 5: Parameterized Tests, Dynamic Tests & Lifecycle
JUnit 5 (JUnit Jupiter) is the de-facto standard test framework for Java. It introduces parameterized tests for running the same logic with multiple inputs, dynamic tests generated at runtime, a richer assertion API, and a clean lifecycle model with @BeforeEach, @BeforeAll, @Nested, and @ExtendWith.
Contents
Dependency
Spring Boot projects include JUnit 5 via spring-boot-starter-test
<!-- 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-jupiter-apijunit-jupiter-enginejunit-jupiter-paramsjunit-jupiterTest Lifecycle
JUnit 5 creates a new test instance per method by default (PER_METHOD@TestInstance(Lifecycle.PER_CLASS)@BeforeAll@AfterAll
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 |
@BeforeEach | Before every test method | instance |
@AfterEach | After every test method | instance |
@AfterAll | Once after all tests in the class | static |
@Disabled | Skips the test (with optional reason) | any |
@Timeout | Fails if test exceeds duration | any |
Assertions
JUnit 5's AssertionsassertAllassertThrowsassertTimeout
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);
}
}
Parameterized Tests
Annotate a test method with @ParameterizedTest
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);
}
}
Dynamic Tests (@TestFactory)
@TestFactoryStreamCollectionIterableDynamicTest
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: Grouping Related Tests
@Nested@BeforeEach@AfterEach@BeforeEach
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 & @ExtendWith
Extensions replace JUnit 4's @RunWith@ExtendWithMockitoExtensionSpringExtensionWireMockExtension
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());
}
}
@TempDir@ExtendWith