JUnit5 Architecture Overview
JUnit5 comprises three distinct modules that work together to provide a comprehensive testing platform:
- JUnit Platform: Serves as the foundation for launching testing frameworks on the JVM. It enables integration with various testing engines and provides a standardized API for test discovery and execution.
- JUnit Jupiter: Implements the new programming model and extension mechanisms introduced in JUnit5. This module contains the core annotations and assertions that developers use daily.
- JUnit Vintage: Ensures backward compatibility by providing a test engine for running JUnit3 and JUnit4 based tests within the JUnit5 infrastructure.
Spring Boot Integration
For Spring Boot applications, include the following dependency in your Maven pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Test classes require the @SpringBootTest annotation to load the complete application context. This annotation internal combines @ExtendWith(SpringExtension.class) to integrate Spring's dependency injection and lifecycle management with Jupiter's extension model.
Core Test Annotations
Lifecycle Annotations
@Test: Marks a method as a test case. Must be placed on public or package-private methods.@DisplayName: Assigns a human-readable name to test classes or methods that appears in reports.@BeforeEach: Executes before every individual test method in the current class.@AfterEach: Executes after every individual test method completes.@BeforeAll: Runs once before all test methods in the class. The annotated method must be static.@AfterAll: Runs once after all test methods finish. The annotated method must be static.@Tag: Categorizes tests for selective execution (e.g., "unit", "integration").@Disabled: Prevents a test class or method from running, useful for temporarily skipping tests.@Timeout: Fails the test if execution exceeds the specified duration. Acceptsvalueandunitparameters.@ExtendWith: Registers custom extensions to enhance test functionality.@ParameterizedTest: Indicates a test method that runs multiple times with different parameters.@RepeatedTest: Executes a test method repeatedly a specified number of times.
Annotation Behavior Details
Lifecycle Scope Differences: Methods annotated with @BeforeEach and @AfterEach run around every test invocation, while @BeforeAll and @AfterAll run at the class level. The latter must be static because they execute before instance creation.
Timeout Configuration: The @Timeout annotation requires duration and time unit specification. When exceeded, it throws AssertionFailedError with a message like "test timed out after 500 milliseconds".
Extension Mechanism: The @ExtendWith annotation enables integration with external frameworks. Spring Boot's @SpringBootTest automatically includes @ExtendWith(SpringExtension.class), which provides context caching and dependency injection.
Annotation Conflicts: @RepeatedTest cannot coexist with @Test on the same method. The framework will throw an annotation conflict exception.
Assertion Mechanisms
Assertions validate expected outcomes against actual results. When an assertion fails, subsequent code in that test method stops executing. All assertions accept an optional final parameter for custom failure messages.
Simple Assertions
import static org.junit.jupiter.api.Assertions;
public class AccountValidationTests {
@Test
void verifyAccountProperties() {
Account account = new Account("user123", "active");
Assertions.assertEquals("user123", account.getUsername(), "Username mismatch");
Assertions.assertNotEquals("blocked", account.getStatus());
Assertions.assertTrue(account.isActive());
Assertions.assertFalse(account.isExpired());
Assertions.assertNotNull(account.getCreatedDate());
}
}
assertEquals(): Verifies object or primitive equalityassertNotEquals(): Confirms inequalityassertSame(): Checks reference identityassertNotSame(): Validates different object referencesassertTrue(): Confirms boolean condition is trueassertFalse(): Confirms boolean condition is falseassertNull(): Verifies null referenceassertNotNull(): Confirms non-null reference
Array Assertions
@Test
void validatePermissionArray() {
String[] expected = {"read", "write", "execute"};
String[] actual = permissionService.getDefaultPermissions();
Assertions.assertArrayEquals(expected, actual, "Permission arrays should match");
}
Array assertions perform element-by-element comparison. Arrays like [1,2] and [2,1] are considered unequal.
Composite Assertions
@Test
void comprehensiveValidation() {
Order order = orderProcessor.createOrder(items);
Assertions.assertAll("Order Validation",
() -> Assertions.assertEquals(3, order.getItemCount(), "Item count incorrect"),
() -> Assertions.assertTrue(order.getTotal() > 0, "Total amount invalid"),
() -> Assertions.assertNotNull(order.getTrackingNumber(), "Tracking number missing")
);
}
Composite assertions execute all contained checks and report all failures together using lambda expressions. No custom message parameter exists for the entire block, but individual assertions can include messages.
Exception Assertions
@Test
void verifyExceptionHandling() {
InventoryService service = new InventoryService();
Assertions.assertThrows(
InsufficientStockException.class,
() -> service.reserveItem("ITEM-001", 1000),
"Should throw exception for excessive quantity"
);
}
assertThrows() expects the specified exception type to be thrown. If no exception occurs, it fails with: "Expected InsufficientStockException to be thrown, but nothing was thrown."
Timeout Assertions
@Test
void verifyProcessingSpeed() {
Assertions.assertTimeout(
Duration.ofSeconds(2),
() -> dataProcessor.analyzeLargeDataset(),
"Processing took longer than expected"
);
}
Timeout assertions fail when execution exceeds the specified Duration.
Fail Fast Mechanism
@Test
void incompleteFeature() {
// TODO: Implement payment gateway integration
Assertions.fail("Payment gateway tests not yet implemented");
}
The fail() method immediately terminates the test with an AssertionFailedError.
Assumptions
Assumptions differ from assertions by aborting tests when conditions aren't met rather than marking them as failures. Use assumptions for environment-specific tests.
@Test
void runOnlyOnProduction() {
Assumptions.assumeTrue("production".equals(System.getenv("ENV")));
// Test code only executes in production environment
auditService.verifyProductionConfiguration();
}
When an assumption fails, it throws TestAbortedException with message "Assumption failed: assumption is not true".
Nested Test Structure
Nested tests organize related test cases hierarchically using inner classes annotated with @Nested.
@DisplayName("Shopping Cart Behavior")
class ShoppingCartTest {
private ShoppingCart cart;
@Test
@DisplayName("Initial cart state")
void emptyCartCreation() {
cart = new ShoppingCart();
Assertions.assertEquals(0, cart.getProductCount());
}
@Nested
@DisplayName("With items added")
class WithItems {
@BeforeEach
void populateCart() {
cart = new ShoppingCart();
cart.addProduct(new Product("LAPTOP", 1));
cart.addProduct(new Product("MOUSE", 2));
}
@Test
@DisplayName("Contains multiple products")
void verifyContents() {
Assertions.assertAll(
() -> Assertions.assertEquals(2, cart.getProductCount()),
() -> Assertions.assertEquals(1, cart.getQuantityFor("LAPTOP")),
() -> Assertions.assertEquals(2, cart.getQuantityFor("MOUSE"))
);
}
@Nested
@DisplayName("After checkout")
class AfterCheckout {
@BeforeEach
void processOrder() {
cart.checkout();
}
@Test
@DisplayName("Cart becomes read-only")
void verifyLockedState() {
Assertions.assertTrue(cart.isLocked());
Assertions.assertThrows(
IllegalStateException.class,
() -> cart.addProduct(new Product("KEYBOARD", 1))
);
}
}
}
}
Key Principle: Outer tests cannot trigger @BeforeEach/@AfterAll methods of inner classes, but inner tests can trigger lifecycle methods from outer classes.
Parameterized Tests
Parameterized tests execute the same test logic with varying inputs, reducing code duplication.
@ParameterizedTest
@ValueSource(strings = {"admin", "user", "guest"})
@DisplayName("Role validation for: {0}")
void validateUserRoles(String role) {
User user = userService.createUserWithRole(role);
Assertions.assertNotNull(user);
Assertions.assertEquals(role, user.getAssignedRole());
}
@ParameterizedTest
@CsvSource({
"100, 50, 150",
"200, 75, 275",
"0, 0, 0"
})
void calculateTotalPrice(int base, int tax, int expected) {
int result = priceCalculator.computeTotal(base, tax);
Assertions.assertEquals(expected, result);
}
@ParameterizedTest
@MethodSource("provideSearchQueries")
void testSearchFunctionality(String query, int expectedResults) {
SearchResults results = searchEngine.execute(query);
Assertions.assertEquals(expectedResults, results.getCount());
}
static Stream<Arguments> provideSearchQueries() {
return Stream.of(
Arguments.of("java", 150),
Arguments.of("python", 200),
Arguments.of("nonexistent", 0)
);
}
Parameter Sources
@ValueSource: Provides single-parameter values for primitive types, Strings, and Classes@NullSource: Supplies a singlenullvalue@EnumSource: Uses enum constants as parameters@CsvFileSource: Reads parameters from a CSV file@MethodSource: References a method that returns a stream of arguments
Migration from JUnit4
The most noticeable change involves package structure:
- JUnit4:
org.junit.Test - JUnit5:
org.junit.jupiter.api.Test
JUnit5 annotations reside in org.junit.jupiter.api and require no runner configuration. The vintage engine automatically detects and executes legacy JUnit4 tests during migration phases.
Test Execution
Run tests through Maven:
mvn test
Or using the Maven Surefire Plugin with explicit JUnit Platform support:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<includes>
<include>**/*Tests.java</include>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
Test reports generate in target/surefire-reports/ directory, providing detailed success and failure information for continuous integration pipelines.