Guide contents

Lesson 21 of 71

JUnit 5

JUnit 5 is the standard unit-testing framework for Java. This lesson covers how to write tests with its Jupiter API and how to run them from a plain main method, so every example below is a complete program with checked output. After it, a reader can write and organize the tests found in a typical production code base.

Part What it is
JUnit Platform The launcher that discovers and runs tests; build tools and IDEs call it
JUnit Jupiter The programming model: @Test, assertions, lifecycle, extensions
JUnit Vintage An engine that runs old JUnit 3 and 4 tests on the Platform
junit-jupiter-params Parameterized tests (@ParameterizedTest and argument sources)

The examples use JUnit 5.13 (Platform 1.13) from the console standalone jar.

Example 1: a first test and the launcher helper

A test class is a class with methods annotated @Test. A test method returns void, takes no parameters (unless an extension supplies them), and fails when it throws. Jupiter test classes and methods may be package-private, which is the usual style.

Build tools normally run tests. Here the public class calls the JUnit Platform launcher API directly: LauncherDiscoveryRequestBuilder selects the test classes, LauncherFactory.create() returns a Launcher, and a SummaryGeneratingListener counts the results.

Java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class Calculator {
    int add(int a, int b) { return a + b; }
}

class CalculatorTest {
    Calculator calc = new Calculator();

    @Test
    void addsTwoNumbers() {
        assertEquals(5, calc.add(2, 3));
    }

    @Test
    void wrongExpectation() {
        assertEquals(6, calc.add(2, 3), "2 + 3");
    }
}

public class FirstTest {
    public static void main(String[] args) {
        run(CalculatorTest.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=2 succeeded=1 failed=1 aborted=0 skipped=0
FAILED wrongExpectation(): 2 + 3 ==> expected: <6> but was: <5>

The helper prints the counts and one line per failure, sorted by name, so the output does not depend on execution order. assertEquals(expected, actual, message) puts the expected value first; the optional last argument is prefixed to the failure message. Every later example in this lesson and in the next two lessons repeats the same run method unchanged at the end of the file.

Example 2: assertions

Assertions has static methods for each kind of check. Three of them need explanation:

  • assertThrows(type, executable) passes when the code throws that type (or a subtype) and returns the exception, so the test can check its message.
  • assertAll(executables...) runs every check and reports all failures together, instead of stopping at the first one.
  • assertTimeout(duration, executable) runs the code in the test thread, waits for it to finish, and fails if it took longer than the duration. assertTimeoutPreemptively runs the code in another thread and abandons it when time runs out.
Java
import static org.junit.jupiter.api.Assertions.*;

import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

record Money(long cents, String currency) {
    Money {
        if (cents < 0) throw new IllegalArgumentException("negative amount: " + cents);
    }
}

class AssertionTests {
    @Test
    void throwsOnNegative() {
        var e = assertThrows(IllegalArgumentException.class, () -> new Money(-1, "USD"));
        assertEquals("negative amount: -1", e.getMessage());
    }

    @Test
    void groupedChecksAllRun() {
        Money m = new Money(500, "EUR");
        assertAll("money",
            () -> assertEquals(500, m.cents()),
            () -> assertEquals("USD", m.currency()),
            () -> assertTrue(m.cents() > 1000, "large"));
    }

    @Test
    void finishesInTime() {
        List<Integer> sorted = assertTimeout(Duration.ofSeconds(5),
            () -> List.of(3, 1, 2).stream().sorted().toList());
        assertIterableEquals(List.of(1, 2, 3), sorted);
    }

    @Test
    void sameInstance() {
        String a = "id";
        assertNotSame(a, new String(a));
        assertNull(null);
    }
}

public class AssertionsDemo {
    public static void main(String[] args) {
        run(AssertionTests.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=4 succeeded=3 failed=1 aborted=0 skipped=0
FAILED groupedChecksAllRun(): money (2 failures)

The groupedChecksAllRun failure is a MultipleFailuresError. Its first message line names the heading and the number of failures; the full message lists both failed checks (USD and large), which a plain sequence of assertEquals calls would not show, since the first failure ends the method.

Example 3: lifecycle methods

Jupiter creates a new instance of the test class for every test method by default (Lifecycle.PER_METHOD). That keeps tests independent: a field changed by one test is not seen by the next. Lifecycle methods run around the tests:

Annotation Runs Must be static by default
@BeforeAll once, before all tests in the class yes
@BeforeEach before every test no
@AfterEach after every test, even a failed one no
@AfterAll once, after all tests yes
Java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.*;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

@TestMethodOrder(MethodOrderer.MethodName.class)
class LifecycleTest {
    static final List<String> log = new ArrayList<>();
    List<String> items = new ArrayList<>();

    @BeforeAll static void openPool() { log.add("beforeAll"); }
    @BeforeEach void setUp() { items.add("seed"); log.add("beforeEach"); }
    @AfterEach void tearDown() { log.add("afterEach"); }
    @AfterAll static void closePool() { log.add("afterAll"); }

    @Test void a_addsOne() {
        items.add("x");
        assertEquals(2, items.size());
        log.add("test a");
    }

    @Test void b_seesFreshList() {
        assertEquals(1, items.size());   // a's "x" is not here: new instance
        log.add("test b");
    }
}

public class LifecycleDemo {
    public static void main(String[] args) {
        run(LifecycleTest.class);
        LifecycleTest.log.forEach(System.out::println);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=2 succeeded=2 failed=0 aborted=0 skipped=0
beforeAll
beforeEach
test a
afterEach
beforeEach
test b
afterEach
afterAll

@TestMethodOrder(MethodOrderer.MethodName.class) fixes the order for this demonstration. Without it, the order is deterministic but intentionally not obvious, and tests must not depend on it. Annotating the class with @TestInstance(TestInstance.Lifecycle.PER_CLASS) makes Jupiter use one instance for all tests; then @BeforeAll and @AfterAll may be instance methods, and state can leak between tests.

Example 4: @Nested and @DisplayName

@Nested marks a non-static inner class as a group of tests. Each nested test runs the outer @BeforeEach methods first, then the nested ones, so a nested class describes one state of the object under test. @DisplayName replaces the method or class name in reports.

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

import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Deque;
import java.util.NoSuchElementException;
import org.junit.jupiter.api.*;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

@DisplayName("A stack")
class StackTest {
    Deque<String> stack;

    @BeforeEach void create() { stack = new ArrayDeque<>(); }

    @Test @DisplayName("is empty when created")
    void emptyOnCreate() { assertTrue(stack.isEmpty()); }

    @Nested @DisplayName("after one push")
    class AfterPush {
        @BeforeEach void push() { stack.push("a"); }

        @Test @DisplayName("pop returns the element")
        void pop() { assertEquals("a", stack.pop()); }

        @Test @DisplayName("is still empty (wrong on purpose)")
        void wrong() { assertTrue(stack.isEmpty(), "expected empty"); }

        @Nested @DisplayName("and one pop")
        class AndPop {
            @BeforeEach void pop() { stack.pop(); }

            @Test @DisplayName("pop throws")
            void popThrows() { assertThrows(NoSuchElementException.class, stack::pop); }
        }
    }
}

public class NestedDemo {
    public static void main(String[] args) {
        run(StackTest.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=4 succeeded=3 failed=1 aborted=0 skipped=0
FAILED is still empty (wrong on purpose): expected empty ==> expected: <true> but was: <false>

Selecting the outer class also runs its nested classes, which is why four tests are found. The failure line shows the display name, not the method name.

Example 5: parameterized tests

@ParameterizedTest runs one method once per set of arguments. Each run is a separate test in the counts. The argument source decides where the values come from:

Source Supplies
@ValueSource one literal per run (ints, strings, and other simple types)
@CsvSource rows of comma-separated values, converted to the parameter types
@MethodSource a static factory method returning a Stream, List, or array of Arguments
Java
import static org.junit.jupiter.api.Assertions.*;

import java.util.Arrays;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class Fees {
    static long feeCents(long amountCents) {
        return amountCents < 10_000 ? 50 : amountCents / 100;   // 1%, minimum 50
    }
    static boolean isValidIsin(String s) { return s != null && s.matches("[A-Z]{2}[A-Z0-9]{9}[0-9]"); }
}

class FeesTest {
    @ParameterizedTest
    @ValueSource(strings = {"US0378331005", "GB0002634946", "us0378331005"})
    void validIsins(String isin) {
        assertTrue(Fees.isValidIsin(isin));
    }

    @ParameterizedTest(name = "{0} cents -> fee {1}")
    @CsvSource({
        "500,     50",
        "10000,  100",
        "250000, 2500"
    })
    void fee(long amount, long expected) {
        assertEquals(expected, Fees.feeCents(amount));
    }

    static Stream<Arguments> boundaries() {
        return Stream.of(
            Arguments.of(9_999L, 50L),
            Arguments.of(10_001L, 100L));
    }

    @ParameterizedTest
    @MethodSource("boundaries")
    void boundary(long amount, long expected) {
        assertEquals(expected, Fees.feeCents(amount));
    }
}

public class ParamsDemo {
    public static void main(String[] args) {
        run(FeesTest.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=8 succeeded=7 failed=1 aborted=0 skipped=0
FAILED [3] us0378331005: expected: <true> but was: <false>

Eight invocations run. The lower-case ISIN fails, and the default display name [3] us0378331005 shows the invocation index and the argument, which makes the failing row easy to find. The name attribute on the CSV test sets a custom pattern where {0} and {1} are the arguments. @CsvSource trims whitespace around values, so the columns can be aligned.

Example 6: @TempDir, assumptions, and @Disabled

@TempDir on a field or parameter of type Path gives the test a fresh directory, which JUnit deletes after the test. Tests that write files then do not interfere with each other or leave files behind.

An assumption (Assumptions.assumeTrue, assumeFalse, assumingThat) stops a test without failing it when a precondition does not hold, for example a test that needs a specific operating system. A stopped test is counted as aborted. @Disabled skips a test without running it; it is counted as skipped.

Java
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class TradeFileWriter {
    static void write(Path file, List<String> trades) throws IOException {
        Files.write(file, trades);
    }
}

class FileTests {
    static Path seen;

    @Test
    void writesOneLinePerTrade(@TempDir Path dir) throws IOException {
        seen = dir;
        Path file = dir.resolve("trades.csv");
        TradeFileWriter.write(file, List.of("AAPL,100", "MSFT,50"));
        assertEquals(2, Files.readAllLines(file).size());
    }

    @Test
    void onlyOnMars() {
        assumeTrue("mars".equals(System.getProperty("os.name")), "not on Mars");
        fail("never reached");
    }

    @Test
    @Disabled("waiting for exchange sandbox")
    void againstSandbox() {
        fail("never runs");
    }
}

public class TempDirDemo {
    public static void main(String[] args) {
        run(FileTests.class);
        System.out.println("temp dir deleted: " + !Files.exists(FileTests.seen));
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=3 succeeded=1 failed=0 aborted=1 skipped=1
temp dir deleted: true

The aborted and skipped tests do not appear as failures, so a build passes. That is the intended behavior, and also the risk: a test that is always aborted on the build machine tests nothing.

Example 7: tags and filters

@Tag("name") labels a test class or method. The launcher, or a build tool configuration, includes or excludes tags with a filter. A common split is fast unit tests on every build and slow or integration tests in a separate stage. This example adds a TagFilter to the discovery request; the rest of the helper is unchanged.

Java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.Arrays;
import org.junit.jupiter.api.*;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.TagFilter;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class PricingTest {
    @Test void unitOne() { assertEquals(4, 2 + 2); }
    @Test void unitTwo() { assertEquals("ab", "a" + "b"); }
    @Test @Tag("slow") void fullDayReplay() { assertEquals(1, 1); }
    @Test @Tag("integration") void database() { assertEquals(1, 1); }
}

public class TagsDemo {
    public static void main(String[] args) {
        System.out.println("exclude slow, integration:");
        run(TagFilter.excludeTags("slow", "integration"));
        System.out.println("include integration:");
        run(TagFilter.includeTags("integration"));
    }

    static void run(org.junit.platform.launcher.PostDiscoveryFilter filter) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(DiscoverySelectors.selectClass(PricingTest.class))
            .filters(filter)
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
    }
}
Output
exclude slow, integration:
tests: found=2 succeeded=2 failed=0 aborted=0 skipped=0
include integration:
tests: found=1 succeeded=1 failed=0 aborted=0 skipped=0

A filtered-out test is not discovered at all, so it does not appear in any count. With Maven Surefire or Gradle, the same filter is set in the build file (groups/excludedGroups, or useJUnitPlatform { excludeTags(...) }).

Example 8: the extension model

An extension is a class that implements one or more callback interfaces and is registered with @ExtendWith. Jupiter calls it at defined points: BeforeAllCallback, BeforeEachCallback, AfterEachCallback, TestExecutionExceptionHandler, and ParameterResolver (which supplies test-method parameters) are common ones. @TempDir and the Mockito integration in the next lesson are built on this model.

This extension injects a fixed Clock into any test parameter of that type and records each finished test with its outcome.

Java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.*;
import java.util.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.*;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class FixedClockExtension implements ParameterResolver, AfterEachCallback {
    static final List<String> audit = Collections.synchronizedList(new ArrayList<>());

    @Override
    public boolean supportsParameter(ParameterContext p, ExtensionContext c) {
        return p.getParameter().getType() == Clock.class;
    }

    @Override
    public Object resolveParameter(ParameterContext p, ExtensionContext c) {
        return Clock.fixed(Instant.parse("2026-01-02T09:30:00Z"), ZoneOffset.UTC);
    }

    @Override
    public void afterEach(ExtensionContext c) {
        String outcome = c.getExecutionException().isPresent() ? "failed" : "passed";
        audit.add(c.getRequiredTestMethod().getName() + " " + outcome);
    }
}

@ExtendWith(FixedClockExtension.class)
class MarketHoursTest {
    static boolean isOpen(Clock clock) {
        LocalTime t = LocalTime.now(clock);
        return !t.isBefore(LocalTime.of(9, 30)) && t.isBefore(LocalTime.of(16, 0));
    }

    @Test void openAtTheBell(Clock clock) { assertEquals(true, isOpen(clock)); }
    @Test void dateIsFixed(Clock clock) { assertEquals(LocalDate.of(2026, 1, 2), LocalDate.now(clock)); }
    @Test void wrongOnPurpose(Clock clock) { assertEquals(false, isOpen(clock)); }
}

public class ExtensionDemo {
    public static void main(String[] args) {
        run(MarketHoursTest.class);
        FixedClockExtension.audit.stream().sorted().forEach(System.out::println);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=3 succeeded=2 failed=1 aborted=0 skipped=0
FAILED wrongOnPurpose(Clock): expected: <false> but was: <true>
dateIsFixed passed
openAtTheBell passed
wrongOnPurpose failed

Without the extension, Jupiter would fail each test with a ParameterResolutionException, because it has no way to create a Clock. Injecting the clock is the testing-time technique covered in Testcontainers and Testing Concurrent Code. Extensions can also be registered programmatically with @RegisterExtension on a field, which allows constructor arguments, or globally through ServiceLoader when auto-detection is enabled.

Interview points

  • JUnit 5 is three parts: Platform (launcher and engine API), Jupiter (the new programming model), and Vintage (runs JUnit 4 tests).
  • Jupiter creates a new test-class instance per test method by default; @TestInstance(PER_CLASS) shares one instance.
  • @BeforeAll/@AfterAll are static unless the lifecycle is per class.
  • assertAll reports every failed check; plain assertions stop at the first.
  • assertTimeout runs in the test thread and waits; assertTimeoutPreemptively runs elsewhere and abandons the code, which breaks thread-bound state such as ThreadLocal transactions.
  • A failed assumption aborts a test; @Disabled skips it; neither fails the build.
  • Each parameterized invocation is a separate test with its own display name.
  • Extensions replace JUnit 4 runners and rules; a class can have many extensions but only one runner.
  • Test-method order is deterministic but unspecified; tests must not depend on it.

Summary

  • @Test methods are void, may be package-private, and fail by throwing.
  • The launcher API (LauncherDiscoveryRequestBuilder, LauncherFactory, SummaryGeneratingListener) runs tests without a build tool.
  • Lifecycle: @BeforeAll, @BeforeEach, test, @AfterEach, @AfterAll.
  • @Nested inner classes group tests by state and inherit outer setup.
  • @ValueSource, @CsvSource, and @MethodSource feed @ParameterizedTest.
  • @TempDir gives a fresh directory that is deleted after the test.
  • @Tag plus a tag filter selects groups of tests.
  • ParameterResolver and callback interfaces are the extension points.