JUnit 5 in Java
Introduction
Testing is not a luxury; it is a fundamental requirement of professional software engineering. Without a robust test suite, refactoring is dangerous, and deploying to production is a gamble.
JUnit 5 is the current state-of-the-art testing framework for Java. Unlike its predecessor (JUnit 4), which was a single monolithic library, JUnit 5 is a modular, extensible, and modern framework designed from the ground up to leverage Java 8+ features like lambdas and the module system.
This guide will break down the architecture of JUnit 5, explore fundamental and advanced annotations, and demonstrate how to write clean, maintainable unit tests.
The JUnit 5 Architecture
JUnit 5 is composed of three distinct sub-projects:
- JUnit Platform: The foundation. It provides an API for tools (like IntelliJ, Eclipse, or Maven/Gradle) to discover and run tests.
- JUnit Jupiter: The modern API for writing tests. It contains all the new annotations and extensions introduced in JUnit 5.
- JUnit Vintage: A compatibility layer that allows you to run older JUnit 3 and JUnit 4 tests on the new JUnit 5 platform, easing the migration process.
Core Annotations and Setup
To use JUnit 5, you write methods annotated with @Test inside a test class. Notice that test classes and methods no longer need to be public in JUnit 5; package-private is perfectly acceptable.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeAll
static void initAll() {
System.out.println("Runs once before all tests in this class (must be static).");
}
@BeforeEach
void setUp() {
// Runs before every single test method. Ideal for resetting state.
calculator = new Calculator();
}
@Test
@DisplayName("Adds two positive numbers correctly")
void testAddition() {
int result = calculator.add(2, 3);
assertEquals(5, result, "2 + 3 should equal 5");
}
@Test
@Disabled("Feature not implemented yet")
void testDivision() {
// This test is skipped
}
@AfterEach
void tearDown() {
// Clean up resources if necessary
}
}
Important Annotations:
@Test: Marks a method as a test case.@DisplayName: Provides a human-readable name for the test in reports.@BeforeEach/@AfterEach: Setup and teardown for each test method.@BeforeAll/@AfterAll: Setup and teardown for the entire class.@Disabled: Skips the test execution.
Assertions: Validating Behavior
JUnit Jupiter provides a rich set of assertions located in org.junit.jupiter.api.Assertions. Because it supports Java 8 lambdas, you can use lazily evaluated error messages and grouped assertions.
1. Basic Assertions
assertEquals(expected, actual);
assertTrue(condition);
assertNull(object);
2. Exception Testing
In JUnit 4, testing exceptions was awkward. JUnit 5 makes it elegant by returning the thrown exception so you can inspect it.
@Test
void testDivideByZeroThrowsException() {
ArithmeticException exception = assertThrows(ArithmeticException.class, () -> {
calculator.divide(10, 0);
});
assertEquals("/ by zero", exception.getMessage());
}
3. Grouped Assertions
If multiple assertions are related, assertAll executes all of them, even if the first one fails. This prevents the "fix one, run, fix next" loop.
@Test
void testUserProperties() {
User user = new User("Alice", "alice@example.com");
assertAll("User Data",
() -> assertEquals("Alice", user.getName()),
() -> assertTrue(user.getEmail().contains("@"))
);
}
Advanced Usage: Parameterized Tests
Often, you need to test the exact same logic against a variety of different inputs. Instead of writing 10 identical tests, use @ParameterizedTest.
You need to add the junit-jupiter-params dependency to use this feature.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
class ParameterizedExampleTest {
@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 8, 10})
void testIsEven(int number) {
assertTrue(number % 2 == 0);
}
@ParameterizedTest(name = "{0} + {1} should equal {2}")
@CsvSource({
"1, 1, 2",
"5, 3, 8",
"-2, 2, 0"
})
void testMultipleValues(int a, int b, int expectedResult) {
Calculator calc = new Calculator();
assertEquals(expectedResult, calc.add(a, b));
}
}
Best Practices and Common Pitfalls
1. Test Isolation
The Mistake: Having tests that depend on the execution order or shared state of other tests.
The Fix: A unit test should be entirely self-contained. Use @BeforeEach to instantiate fresh objects. Tests should pass whether run individually, in parallel, or in any random order.
2. Overusing Mocks
The Mistake: Mocking everything (e.g., with Mockito) to the point where the test only verifies the mock framework, not your business logic. The Fix: Only mock architectural boundaries (databases, HTTP clients, heavy file IO). Use real objects for domain logic, DTOs, and internal services whenever possible.
3. Unclear Test Names
The Mistake: Naming tests test1(), testAdd(), or checkMethod().
The Fix: Use descriptive names or @DisplayName. A good test name describes the scenario and the expected outcome (e.g., whenAccountIsEmpty_thenWithdrawalThrowsException).
Summary
- JUnit 5 provides a modular, lambda-friendly framework for writing reliable tests in Java.
- Use
@BeforeEachand@AfterEachto manage state effectively. - Leverage
assertThrowsfor clean exception testing andassertAllfor grouping related checks. - Utilize
@ParameterizedTestto run extensive data combinations through your algorithms with minimal code duplication.