Appearance
JUnit 5 Architecture: JUnit Platform, JUnit Jupiter and JUnit Vintage
The Airport Control Tower Analogy
Imagine a busy international airport. Dozens of airlines operate there — different aircraft, different crew procedures, different check in systems. The airport itself does not care which airline is taking off; it provides the infrastructure: runways, air traffic control, gates, and ground equipment that every airline uses regardless of their own internal processes.
JUnit 5 is designed exactly like that airport. The JUnit Platform is the infrastructure layer — it does not care whether your tests are written in JUnit 5 style, JUnit 4 style, or some other framework entirely. The JUnit Jupiter and JUnit Vintage modules are like the airlines — they each have their own internal rules, annotations, and behavior, and they plug into the platform through a well defined interface.
Understanding this architecture is not optional theory. If your company asks you to migrate from JUnit 4 to JUnit 5, or if you encounter a build that runs tests from multiple frameworks, this knowledge is what allows you to reason about what is happening and how to fix it.
JUnit 4: The Monolithic Era
JUnit 4 shipped as a single, monolithic JAR. That one artifact was responsible for everything:
- Discovering test classes and methods
- Executing tests
- Handling all annotations (
@Test,@Before,@After, etc.) - Generating reports
This worked, but it created a tight coupling between the framework and the tools. Maven, Gradle, Eclipse, IntelliJ — each IDE and build tool had to understand JUnit 4 internals directly. Extending the framework was difficult. Adding a new testing framework alongside JUnit 4 was nearly impossible without rewriting tooling.
JUnit 4 is now in maintenance mode. It receives bug fixes but no new features. All new development should use JUnit 5.
JUnit 5: A Modular Architecture
JUnit 5 is not a single JAR. It is a family of three top level modules, each with a clear and distinct responsibility.
JUnit 5
├── JUnit Platform (infrastructure / coordinator)
│ ├── Platform Launcher
│ ├── Platform Engine (SPI)
│ ├── Surefire Provider
│ ├── Gradle Provider
│ ├── Gradle Plugin
│ └── Console Launcher
│
├── JUnit Jupiter (JUnit 5 test engine)
│ ├── Jupiter API (annotations, assertions, extensions)
│ └── Jupiter Engine (implements TestEngine SPI)
│
└── JUnit Vintage (JUnit 3 and JUnit 4 test engine)
└── Vintage Engine (implements TestEngine SPI)Each module has a single responsibility. The platform knows nothing about annotations; Jupiter knows nothing about Maven or Gradle; Vintage knows nothing about Jupiter.
The Three Modules in Detail
JUnit Platform
The platform is the foundation on which test engines run. It provides:
Platform Launcher — the central coordinator. Every test run passes through the launcher, no matter how it is triggered. The launcher has exactly two methods:
discover(LauncherDiscoveryRequest)— find all tests matching the requestexecute(TestPlan)— run the discovered tests
Platform Engine (SPI) — a Service Provider Interface that any test engine must implement. It declares two methods:
discover(EngineDiscoveryRequest, UniqueId)— find tests this engine can handleexecute(ExecutionRequest)— run those tests and report results
Surefire Provider / Gradle Provider / Gradle Plugin — bridge modules so Maven and Gradle build tools can communicate with the Platform Launcher.
Console Launcher — allows running tests from the command line without any build tool.
JUnit Jupiter
When developers say "JUnit 5," they usually mean Jupiter. Jupiter is:
- The new annotation model:
@Test,@BeforeEach,@AfterAll,@ParameterizedTest,@RepeatedTest, etc. - The new extension model:
@ExtendWith,ParameterResolver,TestInstanceFactory, etc. - The new assertion API:
assertAll,assertThrows,assertTimeout, etc. - The Jupiter Engine, which implements the
TestEngineSPI so the platform can discover and run Jupiter tests.
JUnit Vintage
Vintage is a compatibility bridge. It implements the TestEngine SPI to discover and execute JUnit 3 and JUnit 4 test cases. This allows teams to adopt JUnit 5 incrementally: new tests are written in Jupiter style while existing JUnit 4 tests continue to run unchanged through the Vintage engine.
The Complete Flow: Running a Test in IntelliJ
Let us trace exactly what happens when you right click a test package in IntelliJ and select "Run Tests."
Step 1: IntelliJ Invokes the Launcher's discover Method
IntelliJ contains code in its JUnit 5 plugin (class JUnit5IdeaTestRunner in the com.intellij.junit5 package) that creates a LauncherDiscoveryRequest and calls the Platform Launcher's discover method.
The discovery request contains selectors — instructions that tell the launcher what scope to search:
java
// What IntelliJ does internally (simplified):
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(
// Discovered from the right-click context:
selectPackage("com.example.myapp"), // whole package
// OR:
selectClass(UserServiceTest.class), // single class
// OR:
selectMethod(UserServiceTest.class, "findByEmail_shouldReturnUser") // one method
)
.build();
Launcher launcher = LauncherFactory.create();
TestPlan testPlan = launcher.discover(request);Step 2: The Launcher Delegates to All Registered Test Engines
The Platform Launcher acts as a coordinator. It iterates over every registered TestEngine implementation and calls discover() on each, passing the same request:
java
// Pseudocode of what the Launcher does internally:
for (TestEngine engine : registeredEngines) {
TestDescriptor descriptor = engine.discover(discoveryRequest, UniqueId.root(...));
// collect descriptors
}The registered engines in a typical Spring Boot project are JUnit Jupiter (for JUnit 5 tests) and optionally JUnit Vintage (for JUnit 4 tests).
Step 3: Each Engine Discovers Its Own Tests
Each engine looks through the requested package (or class or method) and claims the tests that belong to it.
Jupiter Engine finds classes with @Test, @ParameterizedTest, @RepeatedTest, etc. It builds a TestDescriptor tree:
JupiterEngineDescriptor
└── ClassTestDescriptor: UserServiceTest
├── TestMethodDescriptor: findByEmail_shouldReturnUser
└── TestMethodDescriptor: findByEmail_shouldReturnEmpty_whenNotFoundVintage Engine finds classes that extend junit.framework.TestCase (JUnit 3) or use @org.junit.Test (JUnit 4) and builds its own TestDescriptor tree.
Each TestDescriptor is framework specific internal knowledge — Jupiter uses it to know which annotations to process, Vintage uses it to know which JUnit 4 runner to invoke.
Step 4: The Launcher Creates a TestPlan
The launcher collects all TestDescriptor trees from all engines and merges them into a single TestPlan:
TestPlan
├── JupiterEngineDescriptor
│ └── ClassTestDescriptor: UserServiceTest
│ ├── TestMethodDescriptor: findByEmail_shouldReturnUser
│ └── TestMethodDescriptor: findByEmail_shouldReturnEmpty_whenNotFound
│
└── VintageEngineDescriptor
└── ClassTestDescriptor: LegacyCalculatorTest (JUnit 4)
└── TestMethodDescriptor: testMultiplyThe launcher returns this TestPlan to IntelliJ. IntelliJ can now display the test tree in the IDE panel before any tests have run.
Step 5: IntelliJ Calls the Launcher's execute Method
java
// IntelliJ calls:
launcher.execute(testPlan, listeners...);Step 6: The Launcher Delegates Execution to Each Engine
The launcher iterates the TestPlan, and for each engine's sub-tree, calls engine.execute() with only that engine's TestDescriptor:
java
// Pseudocode:
for (TestEngine engine : registeredEngines) {
TestDescriptor engineDescriptor = testPlan.getSubtree(engine);
engine.execute(new ExecutionRequest(engineDescriptor, engineExecutionListener));
}Jupiter Engine runs its tests using its knowledge of JUnit 5 annotations. Vintage Engine runs its JUnit 4 tests using the JUnit 4 runner internally.
They run independently. Jupiter tests and Vintage tests can even run in parallel.
Step 7: Results Flow Back to IntelliJ
Each engine fires events (testStarted, testSucceeded, testFailed, testSkipped) on the EngineExecutionListener. The launcher aggregates these events from all engines into one combined report and streams them back to IntelliJ, which renders the red/green test results in real time.
API vs SPI — A Critical Distinction
The TestEngine interface is an SPI (Service Provider Interface), not an API.
| Term | Who provides the interface | Who implements it |
|---|---|---|
| API | You (the framework) | The client (the caller) |
| SPI | You (the framework) | The provider (the plugin) |
An API says: "Here is an endpoint. Call it and pass this request to get this response."
An SPI says: "Here is an interface. If you want to integrate with us, implement it."
TestEngine is an SPI:
java
// JUnit Platform defines this SPI:
public interface TestEngine {
String getId();
TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId uniqueId);
void execute(ExecutionRequest request);
}JUnit Jupiter and JUnit Vintage both implement this interface. Cucumber, another test framework, also implements it. Any framework that wants its tests to run inside the JUnit Platform ecosystem only needs to provide a TestEngine implementation and register it via Java's ServiceLoader mechanism.
This is why JUnit 5's architecture is so powerful: it is open to extension without modification.
Key Advantages of the JUnit 5 Architecture
1. Multiple Test Frameworks in One Build
Because each framework just needs to implement TestEngine, a single Maven build can simultaneously run JUnit 5 tests (via Jupiter), JUnit 4 tests (via Vintage), Cucumber scenarios, and any other framework that provides a TestEngine.
2. Incremental Migration from JUnit 4 to JUnit 5
xml
<!-- pom.xml: add both engines -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<scope>test</scope>
</dependency>With both engines on the classpath:
- New tests are written using JUnit 5 annotations (
@Testfromorg.junit.jupiter.api) - Existing JUnit 4 tests continue to compile and run unchanged
- You migrate class by class, sprint by sprint, until Vintage can be removed
3. Parallel Test Execution Across Engines
Because each engine runs independently, tests in Jupiter and tests in Vintage can execute concurrently. Within Jupiter, you can also configure fine grained parallelism per method and per class.
4. Tool Agnostic by Design
The platform abstracts away the tool concern. Maven, Gradle, IntelliJ, Eclipse, and the command line all talk to the same Launcher interface. A new test framework added to the platform automatically works with all these tools without any framework specific plugin.
Maven Surefire and Gradle Integration
When you run mvn test, the flow is:
Maven → Surefire Plugin → Surefire Provider → Platform Launcher → [Jupiter Engine, Vintage Engine]When you run ./gradlew test:
Gradle → Gradle Provider → Platform Launcher → [Jupiter Engine, Vintage Engine]The spring-boot-starter-test dependency transitively includes the Surefire provider and the Platform Launcher, so most Spring Boot projects work with no additional configuration.
Interview Questions & Pitfalls
Q1: What are the three top level modules of JUnit 5 and what does each do?
JUnit Platform is the infrastructure layer: it hosts test engines, provides the Launcher, and integrates with build tools and IDEs. JUnit Jupiter is the JUnit 5 test engine: it provides the @Test, @BeforeEach, @ParameterizedTest annotations and implements TestEngine for the platform. JUnit Vintage is the backward compatibility engine: it allows JUnit 3 and JUnit 4 tests to run on the JUnit 5 platform without modification.
Q2: What is the difference between an API and an SPI? Which one is TestEngine?
An API is a contract defined by a framework for clients to call. An SPI is a contract defined by a framework for providers to implement. TestEngine is an SPI — it is an interface defined by the JUnit Platform that any test framework (Jupiter, Vintage, Cucumber, etc.) must implement in order to integrate with the platform.
Q3: How does the JUnit Platform Launcher discover tests?
The launcher receives a LauncherDiscoveryRequest containing selectors (package, class, or method). It iterates over all registered TestEngine implementations and calls discover() on each. Each engine returns a TestDescriptor tree containing only the tests it recognizes. The launcher merges all descriptors into a single TestPlan and returns it.
Q4: Why is JUnit 4 considered monolithic and JUnit 5 modular?
JUnit 4 shipped as one JAR that performed all tasks: discovery, execution, annotation processing, and reporting. Any tool wanting to run JUnit 4 tests had to understand its internals. JUnit 5 splits responsibilities: the Platform handles discovery and execution orchestration, the engines handle framework specific logic, and SPI allows any framework to integrate. Each module can evolve independently.
Q5: How does JUnit 5 allow migrating from JUnit 4 without breaking existing tests?
By adding both the junit-jupiter and junit-vintage-engine dependencies. Jupiter discovers and runs JUnit 5 tests; Vintage discovers and runs existing JUnit 4 tests. Both engines run through the same Platform Launcher, so existing tests continue to compile and run while new tests are written in JUnit 5 style. Migration can proceed gradually without a big bang rewrite.
Q6: Who is actually responsible for running tests in parallel?
The Platform Launcher is the coordinator that decides how to invoke each engine's execute() method. It controls parallelism at the engine level and can invoke multiple engines concurrently. Within the Jupiter engine, the parallelism strategy is further configured through junit-platform.properties, where you control per-method and per-class concurrency separately.
Q7: What happens if a test class contains both JUnit 4 and JUnit 5 annotations?
The discovery phase will likely see it through both engines. Jupiter will claim methods annotated with org.junit.jupiter.api.@Test; Vintage will claim methods annotated with org.junit.@Test. This leads to confusing behavior. The recommended practice is to never mix annotations from both frameworks in the same class.