JUnit 6 Internals: How the Test Engine Works

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:

  1. Discovery Phase — The engine scans the classpath, resolves selectors and filters, and builds a TestDescriptor tree representing all tests that could run.
  2. 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()

The Execution Listener Chain

During execution, the Launcher notifies registered TestExecutionListener implementations of each event. Build tools, IDEs, and reporters attach listeners here. You can attach your own:

import org.junit.platform.launcher.*;
import org.junit.platform.launcher.core.*;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.engine.discovery.DiscoverySelectors;

/**
 * Custom TestExecutionListener: prints a real-time summary of each test event.
 * This is exactly how Maven Surefire and IDE test panels receive test results.
 */
public class ConsoleTestListener implements TestExecutionListener {

    private int passCount = 0;
    private int failCount = 0;
    private int skipCount = 0;

    @Override
    public void executionStarted(TestIdentifier testIdentifier) {
        if (testIdentifier.isTest()) {
            System.out.print("  Running: " + testIdentifier.getDisplayName() + " ... ");
        }
    }

    @Override
    public void executionFinished(TestIdentifier testIdentifier,
                                   TestExecutionResult result) {
        if (!testIdentifier.isTest()) return;

        switch (result.getStatus()) {
            case SUCCESSFUL -> { System.out.println("✔ PASSED");  passCount++; }
            case FAILED     -> {
                System.out.println("❌ FAILED");
                result.getThrowable().ifPresent(t ->
                    System.out.println("    Reason: " + t.getMessage()));
                failCount++;
            }
            case ABORTED    -> { System.out.println("⚠ SKIPPED"); skipCount++; }
        }
    }

    @Override
    public void testPlanExecutionFinished(TestPlan testPlan) {
        System.out.println("n--- Test Summary ---");
        System.out.println("Passed:  " + passCount);
        System.out.println("Failed:  " + failCount);
        System.out.println("Skipped: " + skipCount);
    }

    public static void main(String[] args) {
        LauncherDiscoveryRequest request =
            LauncherDiscoveryRequestBuilder.request()
                .selectors(DiscoverySelectors.selectPackage("com.example"))
                .build();

        Launcher launcher = LauncherFactory.create();
        ConsoleTestListener listener = new ConsoleTestListener();

        // Execute and receive events via the listener
        launcher.execute(request, listener);
    }
}

Console Output

  Running: additionTest() ... ✔ PASSED
  Running: subtractionTest() ... ✔ PASSED
  Running: throwsArithmeticException() ... ✔ PASSED

--- Test Summary ---
Passed:  3
Failed:  0
Skipped: 0

Internal Execution Order: Full Sequence Diagram

LauncherFactory.create()
  └─ ServiceLoader discovers TestEngine implementations on classpath
      (JupiterTestEngine, VintageTestEngine, any custom engines)

launcher.discover(request)
  └─ For each TestEngine:
      engine.discover(discoveryRequest, uniqueId)
        └─ JupiterTestEngine scans classpath roots
        └─ Finds @Test, @ParameterizedTest, @TestFactory methods
        └─ Applies class/method/tag filters from request
        └─ Builds TestDescriptor tree
      returns TestDescriptor root
  └─ Launcher merges all engine roots into TestPlan

launcher.execute(request, listeners)
  └─ Notify listeners: testPlanExecutionStarted
  └─ For each engine root:
      Notify: executionStarted(engine)
      For each test class:
          Notify: executionStarted(class)
          Invoke @BeforeAll (+ BeforeAllCallback extensions)
          For each test method:
              Create new test instance (or reuse if PER_CLASS)
              Notify: executionStarted(method)
              Invoke @BeforeEach (+ BeforeEachCallback)
              Invoke BeforeTestExecutionCallback
              Invoke @Test method
              Invoke AfterTestExecutionCallback
              Invoke @AfterEach (+ AfterEachCallback)
              Notify: executionFinished(method, result)
          Invoke @AfterAll (+ AfterAllCallback)
          Notify: executionFinished(class, result)
      Notify: executionFinished(engine)
  └─ Notify listeners: testPlanExecutionFinished

UniqueId: How JUnit Identifies Tests

Every node in the test tree has a UniqueId — a structured string that unambiguously identifies it across the entire test plan:

// Example UniqueId for a nested test method
// Format: [engine:engineId]/[class:fqcn]/[nested-class:simpleName]/[method:methodName(params)]

String uniqueId = "[engine:junit-jupiter]"
    + "/[class:com.example.ShoppingCartTest]"
    + "/[nested-class:WhenCartHasItems]"
    + "/[method:totalEqualsSumOfItemPrices()]";

// Run exactly this test via LauncherDiscoveryRequest
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
    .selectors(DiscoverySelectors.selectUniqueId(uniqueId))
    .build();

Frequently Asked Questions (FAQs)

Q1: Why does JUnit sometimes report “Test not found” for a method that clearly exists?

This usually means the discovery phase did not include that method in its descriptor tree. Common causes: the test class is not on the test classpath (wrong source root), the class has a non-default constructor without a corresponding TestInstanceFactory, a filter (tag, class name pattern) excludes it, or the test engine is not on the classpath. Enable debug logging with -Djunit.platform.output.capture.stdout=true to see discovery events.

Q2: How does JUnit decide what order to execute tests in?

By default, JUnit 6 uses a deterministic but non-obvious ordering based on method name hashes. This avoids false ordering dependencies while still being reproducible across runs. To control ordering explicitly, use @TestMethodOrder(MethodOrderer.OrderAnnotation.class) with @Order(n) on methods, or use MethodOrderer.Alphanumeric for alphabetical ordering.

Q3: What is the difference between TestPlan and TestDescriptor?

TestDescriptor is the internal engine-side representation of a test node — it includes engine-specific details and is mutable. TestPlan is the launcher-side, read-only view of the discovered test tree expressed as TestIdentifier objects. External tooling (IDEs, reporters, listeners) uses TestPlan/TestIdentifier; test engines use TestDescriptor internally.

Q4: Can I add or remove tests from the plan between discovery and execution?

Not directly through the public Launcher API — the test plan is immutable once returned by discover(). To filter tests dynamically, apply PostDiscoveryFilter implementations during the discovery request build, or use ExecutionCondition extensions inside the engine to conditionally skip tests at execution time.

Q5: How can I capture test output (System.out) per test?

Enable output capture in junit-platform.properties:

# Capture stdout/stderr per test and show on failure
junit.platform.output.capture.stdout=true
junit.platform.output.capture.stderr=true

Captured output appears in the test report only when the test fails, keeping passing test output silent. This is the recommended approach over adding System.out.println to tests.

See Also

Conclusion

The JUnit 6 test engine is a well-designed, two-phase system: discovery builds the tree, execution walks it. Understanding the TestDescriptor hierarchy, the EngineExecutionListener event chain, and UniqueId addressing transforms you from a consumer of the framework into someone who can extend, debug, and build on top of it with confidence.

Next: Build Your Own JUnit 6 Test Engine — put this internal knowledge to work by implementing a complete custom TestEngine from scratch.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.