Most developers treat JUnit 6 as a black box: annotate a method with @Test, run the build, see green or red. But understanding how the JUnit 6 test engine actually works — the internal phases, the test descriptor tree, the execution listener chain, the extension invocation order — makes you dramatically better at debugging mysterious failures, building custom tooling, and reasoning about framework behaviour.
This post walks through the complete internal lifecycle of a test run, from Launcher creation to final result reporting, with diagrams and code you can run yourself.
The Two Phases: Discovery and Execution
Every JUnit 6 test run has exactly two phases:
- Discovery Phase — The engine scans the classpath, resolves selectors and filters, and builds a TestDescriptor tree representing all tests that could run.
- Execution Phase — The engine walks the descriptor tree, fires lifecycle callbacks and extension hooks, executes test methods, and reports results through
EngineExecutionListener.
These phases are intentionally separate. You can discover tests without executing them — this is how IDEs show the test tree before you click Run.
The TestDescriptor Tree
During discovery, JUnit builds a tree of TestDescriptor nodes. Each node represents a test container (engine, class, nested class) or a test (individual method). Here is what the tree looks like for a simple test class:
TestDescriptor Tree
└─ EngineDescriptor [junit-jupiter]
└─ ClassTestDescriptor [CalculatorTest]
├─ TestMethodDescriptor [additionTest()]
├─ TestMethodDescriptor [subtractionTest()]
└─ NestedClassTestDescriptor [WhenDividingByZero]
└─ TestMethodDescriptor [throwsArithmeticException()]
Inspecting the Test Plan Programmatically
import org.junit.platform.launcher.*;
import org.junit.platform.launcher.core.*;
import org.junit.platform.engine.discovery.DiscoverySelectors;
public class TestPlanInspector {
public static void main(String[] args) {
// Build a discovery request for a specific package
LauncherDiscoveryRequest discoveryRequest =
LauncherDiscoveryRequestBuilder.request()
.selectors(DiscoverySelectors.selectPackage("com.example"))
.build();
Launcher launcher = LauncherFactory.create();
// DISCOVER: build the TestDescriptor tree without executing tests
TestPlan testPlan = launcher.discover(discoveryRequest);
// Walk the tree and print every node
testPlan.getRoots().forEach(engine -> {
System.out.println("Engine: " + engine.getDisplayName());
printDescendants(testPlan, engine, 1);
});
}
private static void printDescendants(TestPlan plan,
TestIdentifier node,
int depth) {
String indent = " ".repeat(depth);
plan.getChildren(node).forEach(child -> {
// isTest() = leaf node (a @Test method)
// isContainer() = intermediate node (class, nested class, engine)
String type = child.isTest() ? "[TEST] " : "[CONTAINER]";
System.out.printf("%s%s %s%n", indent, type, child.getDisplayName());
printDescendants(plan, child, depth + 1);
});
}
}
Sample Output
Engine: JUnit Jupiter
[CONTAINER] CalculatorTest
[TEST] additionTest()
[TEST] subtractionTest()
[CONTAINER] WhenDividingByZero
[TEST] throwsArithmeticException()
Continue reading JUnit 6 Internals: How the Test Engine Works