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:

  1. JUnit Platform: The foundation. It defines the TestEngine API for discovering and executing tests.
  2. JUnit Jupiter: The new programming model (API) for writing tests and extensions.
  3. JUnit Vintage: A TestEngine for 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.

```java import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.assertTrue; class PalindromeTest { @ParameterizedTest @ValueSource(strings = {"racecar", "radar", "level"}) void shouldIdentifyPalindromes(String candidate) { assertTrue(isPalindrome(candidate)); } } ```
```go package palindrome import "testing" func TestIsPalindrome(t *testing.T) { // Go uses "Table Driven Tests" pattern tests := []struct { input string want bool }{ {"racecar", true}, {"radar", true}, {"level", true}, {"hello", false}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { if got := IsPalindrome(tt.input); got != tt.want { t.Errorf("IsPalindrome(%q) = %v, want %v", tt.input, got, tt.want) } }) } } ```

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”.

  1. Memory Overhead: Each thread needs its own stack (typically 1MB).
  2. Context Switching: If you have 1000 threads on an 8-core machine, the OS spends significant time switching between them.
  3. 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.

@BeforeAll (static)
@BeforeEach
@Test
@AfterEach
@AfterAll (static)

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.