Appearance
JUnit 5 Architecture: Platform, Jupiter and Vintage
Chapter 2 explained what a unit test is. This chapter shows what runs one: how JUnit 5 is built, and what happens between clicking Run in the IDE and your test method executing.
Knowing the architecture explains why JUnit 5 replaced JUnit 4, and makes a JUnit 4 → 5 migration straightforward.
JUnit 4: one monolithic jar
JUnit 4 is a single jar that does everything: finding tests, running them, the annotations, reporting. JUnit 4 is now in maintenance mode; new development should use JUnit 5.
JUnit 5: modules
JUnit 5 is made of three parts:
- JUnit Platform: the foundation that launches tests. Its main pieces:
junit-platform-launcher: the Launcher, the entry point every tool talks tojunit-platform-engine: theTestEngineinterface that test frameworks implementjunit-platform-console: running tests from the command line- build tool support: Maven's Surefire provider and Gradle's built-in JUnit Platform support (
useJUnitPlatform())
- JUnit Jupiter: the test engine for JUnit 5 tests: the annotations (
@Test, ...), how to find and run them. When people say "JUnit 5 tests", they mean Jupiter. - JUnit Vintage: a test engine for JUnit 3 and 4 tests, so legacy tests run on the new platform.
Other frameworks (Cucumber, for example) can plug in their own engines.
However you start the tests (Maven, Gradle, the IDE, the command line), everything goes through the Launcher.
What happens when you click "Run"
Step 1: you run a test
In IntelliJ you right-click a package, a class or one test method and choose Run.
Step 2: the IDE calls the launcher's discover
IntelliJ talks to the Launcher directly; the code doing it is IntelliJ's own (in a debugger the call stack shows its JUnit5IdeaTestRunner class). It builds a discovery request with selectors saying what to run, and calls discover:
java
// what to run: a package, a class, a method...
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(selectPackage("com.example.learningspringboot"))
.build();
Launcher launcher = LauncherFactory.create();
TestPlan testPlan = launcher.discover(request);selectPackage, selectClass and selectMethod select a package, a class or a single test.
The Launcher has two main methods:
TestPlan discover(LauncherDiscoveryRequest discoveryRequest)void execute(TestPlan testPlan, TestExecutionListener... listeners)(there is also an overload that takes the discovery request and discovers again)
Step 3: each engine discovers its own tests
The Launcher is a coordinator: it passes the discovery request to every registered test engine, and each engine finds the tests that belong to its framework.
The engines plug in through junit-platform-engine, which is an SPI (service provider interface). An API is something you call; an SPI is an interface that providers implement so the framework can call them. JUnit defines the interface; each engine implements it:
java
public interface TestEngine {
String getId();
TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId uniqueId);
void execute(ExecutionRequest request);
}An engine registers itself by listing its class in a META-INF/services/org.junit.platform.engine.TestEngine file (Jupiter's lists org.junit.jupiter.engine.JupiterTestEngine); the Launcher finds all engines on the classpath this way.
java
// the launcher, conceptually
for (TestEngine engine : testEngines) {
TestDescriptor descriptor = engine.discover(...);
root.addChild(descriptor);
}Say the package contains both JUnit 5 and JUnit 4 tests. Jupiter picks out the JUnit 5 ones, Vintage the JUnit 4 ones. Each returns a TestDescriptor: a tree of the classes and methods it owns. The Launcher combines them into one TestPlan and returns it to the IDE. With a JUnit 5 class ClassA and a JUnit 4 class ClassC in one package:
text
[engine:junit-jupiter]
ClassA
method1()
method2()
[engine:junit-vintage]
ClassC
method1
method2Step 4: the IDE calls execute
java
launcher.execute(testPlan, listeners);Step 5: each engine runs its own tests
The Launcher again coordinates: it calls execute on each engine, passing only that engine's subtree of the test plan. Each engine runs its tests and reports passes and failures; the Launcher combines the results and returns them to the IDE.
Why this design is better
- Several test frameworks side by side. JUnit 5, JUnit 4 and 3, Cucumber, ... any engine that implements the SPI.
- Gradual migration. Add Jupiter and Vintage to a JUnit 4 project: write new tests in JUnit 5 while the old JUnit 4 tests keep running through Vintage, and migrate them over time.
- Parallel execution, across engines and within an engine.
- Every tool supports every engine. Because Maven, Gradle, IDEs and the console all go through the Launcher, a new engine works with all of them without extra work.
Next: Chapter 4 starts writing tests: the dependencies a project needs, the lifecycle methods that run around each test, test instances, parallel execution, and the AAA pattern every test follows.
Check yourself
- Why is JUnit 4 called monolithic?
- What are the three parts of JUnit 5, and what does each do?
- Which component do Maven, Gradle, IntelliJ and the console all go through?
- What does a discovery request contain?
- What is the difference between an API and an SPI? Which is
TestEngine? - What happens in the discover phase? What is a
TestDescriptor, and what is aTestPlan? - In the execute phase, what does each engine receive?
- How does a JUnit 4 project move to JUnit 5 without rewriting all tests at once?
Answers
- One jar does everything: discovery, execution, annotations, reporting.
- Platform (launching tests, the engine SPI, tool support), Jupiter (the JUnit 5 engine and annotations), Vintage (an engine for JUnit 3/4 tests).
- The Launcher.
- Selectors saying what to run: packages, classes or methods.
- An API is called by clients; an SPI is implemented by providers so the framework can call them.
TestEngineis an SPI. - The Launcher asks every engine to find its own tests; each returns a
TestDescriptor(a tree of its classes and methods); the Launcher combines them into oneTestPlan. - Only its own subtree of the test plan.
- Add Jupiter and Vintage: new tests in JUnit 5, old ones keep running through Vintage, and migrate gradually.