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:
- 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.
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”.
- 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
War Story: The
@RunWithBottleneck 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@Ruleor 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.
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.