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()
Continue reading JUnit 6 Internals: How the Test Engine Works

Parallel Test Execution in JUnit 6: Configuration and Pitfalls

Running tests sequentially is safe but slow. A test suite that takes 10 minutes to run sequentially can often complete in 2–3 minutes with parallel execution. JUnit 6 has first-class support for parallel test execution — but it requires careful configuration to avoid flaky tests caused by shared state. This guide covers every configuration option, pitfall, and real-world pattern you need to enable parallel execution confidently.

How JUnit 6 Parallel Execution Works

JUnit 6 parallel execution is disabled by default. When enabled, JUnit can run tests at two levels:

  • Class-level parallelism — multiple test classes run concurrently in different threads
  • Method-level parallelism — multiple test methods within the same class run concurrently

These are configured independently, giving you fine-grained control over the degree of parallelism.

Step 1: Enable Parallel Execution

Create or edit src/test/resources/junit-platform.properties:

# Enable parallel execution (disabled by default)
junit.jupiter.execution.parallel.enabled=true

# Default execution mode for all tests
# 'concurrent' = run in parallel with other tests at the same level
# 'same_thread' = run in the same thread as parent (sequential)
junit.jupiter.execution.parallel.mode.default=concurrent

# Default execution mode for top-level test classes
# concurrent = classes run in parallel
# same_thread = classes run sequentially (but methods within might be concurrent)
junit.jupiter.execution.parallel.mode.classes.default=concurrent

Step 2: Configure the Thread Pool Strategy

# Strategy: 'dynamic' uses (availableProcessors * factor) threads
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1.0

# Strategy: 'fixed' uses an exact thread count
# junit.jupiter.execution.parallel.config.strategy=fixed
# junit.jupiter.execution.parallel.config.fixed.parallelism=4

# Strategy: 'custom' uses your own ParallelExecutionConfigurationStrategy
# junit.jupiter.execution.parallel.config.strategy=custom
# junit.jupiter.execution.parallel.config.custom.class=com.example.MyStrategy
Continue reading Parallel Test Execution in JUnit 6: Configuration and Pitfalls

JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step

The JUnit 6 Extensions Model is one of the most powerful features in the framework. Instead of subclassing a test runner or using test rules (the old JUnit 4 approach), JUnit 6 lets you inject behaviour at precise points in the test lifecycle through clean, composable extension interfaces. This guide walks you through every major extension point with real, complete examples you can drop into your project today.

If you want to understand the architecture behind extensions, see JUnit 6 Architecture Deep Dive first. This guide focuses on practical implementation.

What Is a JUnit 6 Extension?

An extension is a class that implements one or more of JUnit 6’s callback interfaces. JUnit calls these callbacks at specific points in the test lifecycle — before the test runs, after it runs, when parameters need to be resolved, when exceptions need to be handled, and more.

Extensions replace everything that required @RunWith, @Rule, and @ClassRule in JUnit 4, but in a cleaner, more composable way.

Registering an Extension

There are three ways to register an extension:

// 1. Declarative: @ExtendWith on the test class or method
@ExtendWith(TimingExtension.class)
class OrderServiceTest { ... }

// 2. Programmatic: @RegisterExtension on a field (allows configuration)
class OrderServiceTest {
    @RegisterExtension
    static DatabaseExtension database = new DatabaseExtension("jdbc:h2:mem:test");
}

// 3. Automatic: via ServiceLoader in META-INF/services
// File: src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
// Contents: com.example.extensions.TimingExtension

Extension 1: Lifecycle Callbacks — Test Timing Logger

import org.junit.jupiter.api.extension.*;
import java.lang.reflect.Method;

/**
 * TimingExtension: logs how long each test method takes to execute.
 * Implements BeforeTestExecutionCallback and AfterTestExecutionCallback
 * to bracket the actual test method execution.
 */
public class TimingExtension
    implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    // Key used to store start time in the extension context store
    private static final String START_TIME_KEY = "startTime";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        // Store start time in the extension's namespace store
        // The store is scoped to this extension + this test method
        getStore(context).put(START_TIME_KEY, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        // Retrieve the stored start time
        long startTime = getStore(context).remove(START_TIME_KEY, long.class);
        long durationMs = System.currentTimeMillis() - startTime;

        // Get the test method name for the log message
        String testMethodName = context.getRequiredTestMethod().getName();

        System.out.printf("[TimingExtension] %-60s %4d ms%n",
            testMethodName, durationMs);
    }

    // Helper: creates a namespace-scoped store for this extension
    private ExtensionContext.Store getStore(ExtensionContext context) {
        return context.getStore(
            ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod())
        );
    }
}

// Usage
@ExtendWith(TimingExtension.class)
class PaymentServiceTest {

    @Test
    void processingPaymentCompletesSuccessfully() throws Exception {
        Thread.sleep(42); // simulates work
        assertTrue(true);
    }
}
[TimingExtension] processingPaymentCompletesSuccessfully                42 ms
Tests run: 1, Failures: 0
Continue reading JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step

Tags and Test Suites in JUnit 6: Organizing Large Test Bases

As a Java project grows, a flat test suite becomes unwieldy. You need to run a quick smoke test in 30 seconds before a commit, a full regression suite overnight, and a subset of integration tests on a staging branch. JUnit 6 Tags and Test Suites are the mechanism that makes all of this possible — without changing a single line of test logic.

This guide covers everything: the @Tag annotation, tag inheritance, filtering with Maven and Gradle, building explicit test suites with the Platform Suite Engine, and real-world tagging strategies used in production projects.

What Is a Tag in JUnit 6?

A tag is a string label attached to a test class or test method using the @Tag annotation. Tags are then used at build time to include or exclude groups of tests. They replace JUnit 4’s @Category annotation with a simpler, more flexible string-based system.

Applying @Tag to Tests

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

// @Tag on the class applies to ALL tests in the class
@Tag("unit")
@Tag("order-service")
@DisplayName("OrderService — business logic tests")
class OrderServiceTest {

    // Inherits class-level tags: "unit" and "order-service"
    @Test
    @DisplayName("Creating an order sets status to PENDING")
    void creatingOrderSetsPendingStatus() {
        OrderService service = new OrderService();
        Order order = service.createOrder("[email protected]", 49.99);
        assertEquals(OrderStatus.PENDING, order.getStatus());
    }

    // Additional method-level tag: this test has "unit", "order-service", AND "fast"
    @Test
    @Tag("fast")
    @DisplayName("Order total calculation is accurate")
    void orderTotalCalculationIsAccurate() {
        OrderService service = new OrderService();
        Order order = service.createOrder("[email protected]", 99.99);
        assertEquals(99.99, order.getTotal(), 0.001);
    }

    // Tag a slow test so it can be excluded from quick builds
    @Test
    @Tag("slow")
    @DisplayName("Processing 10000 orders completes within time limit")
    void processingLargeVolumeOfOrdersCompletesInTime() {
        // Simulate heavy processing
        long startTime = System.currentTimeMillis();
        for (int orderIndex = 0; orderIndex < 10_000; orderIndex++) {
            new OrderService().createOrder("c" + orderIndex + "@test.com", 1.0);
        }
        long duration = System.currentTimeMillis() - startTime;
        assertTrue(duration < 5000, "10,000 orders should process in under 5 seconds");
    }
}

// Integration test class — tagged separately from unit tests
@Tag("integration")
@Tag("database")
@Tag("slow")
class OrderRepositoryIT {

    @Test
    @DisplayName("Saved order can be retrieved by ID")
    void savedOrderCanBeRetrievedById() {
        // hits a real database
    }
}
Continue reading Tags and Test Suites in JUnit 6: Organizing Large Test Bases

Dynamic Tests in JUnit 6 using @TestFactory (Advanced Use Cases)

Dynamic tests in JUnit 6 take parameterized testing a step further: instead of declaring inputs at compile time with annotations, you generate entire test cases — with their own names, logic, and assertions — at runtime. This is the domain of @TestFactory. If you have ever needed to test against a live API response, a database result set, or a list of files on disk, dynamic tests are the right tool.

@TestFactory vs @ParameterizedTest vs @Test

@Test@ParameterizedTest@TestFactory
Test count known atCompile timeCompile time (annotations)Runtime
Names known atCompile timeCompile timeRuntime (generated dynamically)
Data sourceHardcodedAnnotation-declaredAny Java expression
ReturnsvoidvoidStream / Iterable / Collection of DynamicTest
Lifecycle hooksFullFullNo @BeforeEach/@AfterEach per dynamic test

Basic @TestFactory Example

import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

class CalculatorDynamicTest {

    // @TestFactory returns a Stream of DynamicTest
    // JUnit 6 runs each DynamicTest as a separate test case
    @TestFactory
    Stream additionDynamicTests() {
        // Each record represents: displayName, addend1, addend2, expectedSum
        record AdditionCase(String name, int a, int b, int expected) {}

        return Stream.of(
            new AdditionCase("positive numbers: 1 + 2 = 3",    1,   2,   3),
            new AdditionCase("negative + positive: -5 + 5 = 0", -5,  5,   0),
            new AdditionCase("zeros: 0 + 0 = 0",               0,   0,   0),
            new AdditionCase("large numbers: 1000 + 2000 = 3000", 1000, 2000, 3000)
        ).map(testCase ->
            // dynamicTest(displayName, executable)
            dynamicTest(testCase.name(), () -> {
                Calculator calc = new Calculator();
                assertEquals(testCase.expected(),
                    calc.add(testCase.a(), testCase.b()),
                    testCase.name());
            })
        );
    }
}
Output:
  ✔ positive numbers: 1 + 2 = 3
  ✔ negative + positive: -5 + 5 = 0
  ✔ zeros: 0 + 0 = 0
  ✔ large numbers: 1000 + 2000 = 3000

Tests run: 4, Failures: 0
Continue reading Dynamic Tests in JUnit 6 using @TestFactory (Advanced Use Cases)

Parameterized Tests in JUnit 6: All Sources Explained with Examples

Parameterized tests are one of the most powerful features in JUnit 6. Instead of writing ten near-identical test methods with different inputs, you write one test method and supply the inputs as a data source. JUnit 6 runs the method once per input row, reports each run individually, and gives you full control over how arguments are provided and displayed. This guide covers every argument source with complete code examples and expected output.

Setup: Adding the Dependency

Parameterized test support is included in junit-jupiter (the aggregator). If you added the aggregator, nothing extra is needed. If you added only junit-jupiter-api, add the params module separately:

<!-- Already included if you use the junit-jupiter aggregator -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>6.0.0</version>
    <scope>test</scope>
</dependency>

1. @ValueSource — Single Values

The simplest source. Supplies a single argument of a primitive or String type per test invocation:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class StringValidatorTest {

    // Runs 4 times — once for each string in the array
    @ParameterizedTest(name = "''{0}'' should not be blank")
    @ValueSource(strings = {"Hello", "JUnit 6", "Java", "  a  "})
    void nonBlankStringsAreValid(String input) {
        assertFalse(input.isBlank(),
            "'" + input + "' should not be blank");
    }

    // Works with int, long, double, boolean, char, byte, short, float, Class, URI, URL
    @ParameterizedTest(name = "{0} is a positive number")
    @ValueSource(ints = {1, 10, 100, Integer.MAX_VALUE})
    void positiveIntegersAreGreaterThanZero(int number) {
        assertTrue(number > 0);
    }
}
Output:
  ✔ 'Hello' should not be blank
  ✔ 'JUnit 6' should not be blank
  ✔ 'Java' should not be blank
  ✔ '  a  ' should not be blank

2. @NullSource, @EmptySource, @NullAndEmptySource

import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.EmptySource;
import org.junit.jupiter.params.provider.NullAndEmptySource;

class EmailValidatorTest {

    // @NullSource: supplies null as the argument
    @ParameterizedTest
    @NullSource
    void nullEmailIsRejected(String email) {
        assertThrows(IllegalArgumentException.class,
            () -> emailValidator.validate(email));
    }

    // @EmptySource: supplies "" (empty string), empty list, empty array, etc.
    @ParameterizedTest
    @EmptySource
    void emptyEmailIsRejected(String email) {
        assertFalse(emailValidator.isValid(email));
    }

    // @NullAndEmptySource: combines both null AND empty in two invocations
    @ParameterizedTest(name = "blank email ''{0}'' should be invalid")
    @NullAndEmptySource
    void blankEmailIsInvalid(String email) {
        assertFalse(emailValidator.isValid(email));
    }

    // Combine @NullAndEmptySource with @ValueSource for comprehensive edge-case coverage
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {"   ", "t", "n"})
    void blankOrWhitespaceEmailIsInvalid(String email) {
        assertFalse(emailValidator.isValid(email));
    }
}
Continue reading Parameterized Tests in JUnit 6: All Sources Explained with Examples

Structuring Tests with JUnit 6 Nested Tests (Real-World Use Cases)

As your test suite grows, flat test classes become hard to navigate. JUnit 6 Nested Tests — using the @Nested annotation — let you organise related tests into inner class groups that produce a hierarchical, readable test plan. This guide covers every aspect of nested tests with real-world examples, output, and patterns used in production codebases.

What Are Nested Tests?

A @Nested test class is a non-static inner class inside a JUnit 6 test class. JUnit treats it as a group of tests that share a common context. The result is a tree-shaped test report that reads like a specification:

ShoppingCartTest
  ├─ WhenCartIsEmpty
  │   ├─ ✔ checkout throws EmptyCartException
  │   └─ ✔ total is zero
  ├─ WhenCartHasOneItem
  │   ├─ ✔ total equals item price
  │   └─ ✔ removing the item empties the cart
  └─ WhenDiscountIsApplied
      ├─ ✔ valid coupon reduces total by coupon percentage
      └─ ✔ expired coupon throws CouponExpiredException

Basic @Nested Example

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("ShoppingCart — item management and checkout")
class ShoppingCartTest {

    // Shared object under test — each test in any nested class gets a fresh instance
    private ShoppingCart cart;

    @BeforeEach
    void createEmptyCart() {
        cart = new ShoppingCart();
    }

    // ----------------------------------------------------------------
    // Nested class: groups tests for the "empty cart" scenario
    // ----------------------------------------------------------------
    @Nested
    @DisplayName("When the cart is empty")
    class WhenCartIsEmpty {

        // Note: outer @BeforeEach runs first, creating an empty cart
        // No additional setup needed here — the cart is already empty

        @Test
        @DisplayName("total is zero")
        void totalIsZero() {
            assertEquals(0.0, cart.getTotal(), 0.001);
        }

        @Test
        @DisplayName("checkout throws EmptyCartException")
        void checkoutThrowsEmptyCartException() {
            assertThrows(EmptyCartException.class, () -> cart.checkout(),
                "Checking out an empty cart must throw EmptyCartException");
        }

        @Test
        @DisplayName("item count is zero")
        void itemCountIsZero() {
            assertEquals(0, cart.getItemCount());
        }
    }

    // ----------------------------------------------------------------
    // Nested class: groups tests for the "cart has items" scenario
    // ----------------------------------------------------------------
    @Nested
    @DisplayName("When the cart has items")
    class WhenCartHasItems {

        @BeforeEach
        void addItemsToCart() {
            // Outer @BeforeEach already ran (cart is empty)
            // This inner @BeforeEach adds items for this scenario
            cart.addItem(new CartItem("Java Book",   49.99));
            cart.addItem(new CartItem("JUnit Guide", 29.99));
        }

        @Test
        @DisplayName("total equals sum of item prices")
        void totalEqualsSumOfItemPrices() {
            assertEquals(79.98, cart.getTotal(), 0.001);
        }

        @Test
        @DisplayName("item count reflects number of added items")
        void itemCountReflectsAddedItems() {
            assertEquals(2, cart.getItemCount());
        }

        @Test
        @DisplayName("removing an item decreases total correctly")
        void removingItemDecreasesTotal() {
            cart.removeItem("Java Book");
            assertEquals(29.99, cart.getTotal(), 0.001);
        }

        // ----------------------------------------------------------------
        // Deeply nested class: sub-scenario within "cart has items"
        // ----------------------------------------------------------------
        @Nested
        @DisplayName("And a valid discount coupon is applied")
        class AndValidDiscountCouponApplied {

            @BeforeEach
            void applyValidCoupon() {
                // Outer @BeforeEach (empty cart) ran first
                // Inner @BeforeEach (add items) ran second
                // This @BeforeEach applies a coupon on top
                cart.applyCoupon(new Coupon("SAVE10", 10)); // 10% off
            }

            @Test
            @DisplayName("total is reduced by the coupon percentage")
            void totalIsReducedByCouponPercentage() {
                // 79.98 - 10% = 71.982
                assertEquals(71.98, cart.getTotal(), 0.01);
            }

            @Test
            @DisplayName("coupon is marked as used after checkout")
            void couponIsMarkedAsUsedAfterCheckout() {
                cart.checkout();
                assertTrue(cart.getAppliedCoupon().isUsed());
            }
        }
    }
}
Continue reading Structuring Tests with JUnit 6 Nested Tests (Real-World Use Cases)