JUnit 5: The Modern Testing Platform
The transition from JUnit 4 to JUnit 5 wasn’t just an update; it was a complete architectural rewrite. JUnit 5 separates the API (writing tests) from the Engine (running tests), allowing different testing frameworks (Spock, Cucumber, jqwik) to run on the same platform.
1. The Genesis: Why JUnit 5?
JUnit 4 was a single monolithic jar. It forced IDEs (IntelliJ, Eclipse) and Build Tools (Maven, Gradle) to tightly couple with its internal implementation details.
JUnit 5 decoupled this by splitting into three sub-projects:
- JUnit Platform: The foundation. It defines the
TestEngineAPI for discovering and executing tests. - JUnit Jupiter: The new programming model (API) for writing tests and extensions.
- JUnit Vintage: A
TestEnginefor running legacy JUnit 3 and 4 tests on the new platform.
This architecture means tools only need to support the Platform, and any testing framework (even non-Java ones) can plug in.
2. Core Features & Lifecycle
The lifecycle annotations have been renamed to be more descriptive:
| Feature | JUnit 4 | JUnit 5 |
|---|---|---|
| Run Once Before All | @BeforeClass |
@BeforeAll |
| Run Before Each | @Before |
@BeforeEach |
| Run After Each | @After |
@AfterEach |
| Run Once After All | @AfterClass |
@AfterAll |
| Disable Test | @Ignore |
@Disabled |
| Tagging | @Category |
@Tag |
Parameterized Tests
Instead of writing 10 separate tests for 10 inputs, use Parameterized Test.
3. Hardware Reality: Parallel Execution
By default, JUnit runs tests sequentially in a single thread. However, modern CPUs have many cores. JUnit 5 supports parallel execution using the underlying ForkJoinPool.
Enabling Parallelism
Create a junit-platform.properties file:
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
The Cost of Context Switching
Running tests in parallel isn’t “free”.
- Memory Overhead: Each thread needs its own stack (typically 1MB).
- Context Switching: If you have 1000 threads on an 8-core machine, the OS spends significant time switching between them.
- Cache Contention: Threads on different cores fighting for L3 cache lines can cause false sharing.
[!WARNING] Shared State is the Enemy. If your tests modify static variables or shared resources (like a Singleton database connection), running them in parallel will cause race conditions and flaky tests.
4. The Extension Model
JUnit 4 used Runner and Rule, which were often mutually exclusive (you couldn’t easily use MockitoRunner AND SpringRunner together).
JUnit 5 introduces a unified Extension Model based on registering callbacks.
// Extensions can be composed!
@ExtendWith(MockitoExtension.class)
@ExtendWith(SpringExtension.class)
class ComplexIntegrationTest {
// ...
}
5. Interactive: Test Lifecycle Visualizer
Click “Run Test” to see the execution order of lifecycle methods.
6. Dynamic Tests
Sometimes the structure of your tests needs to be determined at runtime (e.g., reading test cases from a JSON file).
@TestFactory
Stream<DynamicTest> dynamicTestsFromStream() {
return Stream.of("racecar", "radar", "level")
.map(text -> DynamicTest.dynamicTest("Test: " + text,
() -> assertTrue(isPalindrome(text))));
}
This creates a Dynamic Test graph that IDEs can render just like static tests.