JUnit 5: The Modern Testing Platform

Imagine running a large commercial kitchen where only one head chef (the Test Runner) is allowed to coordinate everything. If you wanted the chef to also handle dietary restrictions (like Mockito) and manage inventory (like Spring Boot), you couldn’t do both easily—the chef was a bottleneck.

This was the fundamental flaw in JUnit 4: it was a monolithic framework that forced tools (IDEs, build tools) and extensions to tightly couple to its internals. You could only have one @RunWith per class, making it notoriously difficult to combine MockitoJUnitRunner with SpringRunner.

The transition to JUnit 5 wasn’t just an update; it was a complete architectural rewrite designed to solve this exact problem. JUnit 5 separates the API (how you write tests) from the Engine (how tests are executed), allowing different testing frameworks (Spock, Cucumber, jqwik) to run on the exact 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

War Story: The @RunWith Bottleneck In JUnit 4, extending test behavior was done via Runners (e.g., @RunWith(SpringRunner.class)). Because Java doesn’t support multiple inheritance, a test class could only have one Runner. If you needed to load a Spring Context AND initialize Mockito mocks, you had to use cumbersome workarounds like @Rule or manual initialization (MockitoAnnotations.initMocks()).

JUnit 5 abandons Runners entirely for a unified, composable Extension Model based on registering lifecycle callbacks. You can now compose as many extensions as you want.

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.