skip to content

You want a JUnit 5 extension that records the outcome of every executed test method — passed, failed, aborted, or skipped — into your own report. Which extension interface gives you that, and what exactly are its callbacks?

level: middleimportance: should knowfreq 38%

answer

  1. TestWatcher — four default methods
  2. testSuccessful / testFailed(cause) / testAborted(cause) / testDisabled(Optional<String> reason)
  3. exactly one fires per executed test method
  4. runs after the test and its after-each callbacks
  5. not invoked for containers / @Nested classes

basics

~10 s

Implement JUnit 5's TestWatcher extension. It has four default methods: testSuccessful(context), testFailed(context, Throwable cause), testAborted(context, Throwable cause) and testDisabled(context, Optional<String> reason). Exactly one fires per executed test method, after its after-each callbacks.

solid answer

~50 s

`TestWatcher` is the Jupiter extension for observing test *results*. All four methods are `default`, so you override only what you need: ```java void testSuccessful(ExtensionContext context); void testFailed(ExtensionContext context, Throwable cause); void testAborted(ExtensionContext context, Throwable cause); void testDisabled(ExtensionContext context, Optional<String> reason); ``` Exactly one of them is invoked per test method that the engine processes. `testDisabled` fires when an `ExecutionCondition` skipped it and the `Optional` carries the condition's reason. `testAborted` fires when the test bailed out at runtime — an unmet assumption throws `TestAbortedException`, which arrives as the `cause`. `testFailed` gets the failure throwable. The callbacks run after the test has finished, after its after-each callbacks. The `ExtensionContext` gives you the display name, unique id, test class and method, and tags, which is enough to write a row into a report. I register it at class level or higher — `@ExtendWith` on the class, a test interface, or automatic `ServiceLoader` registration.

code

java · 28 lines
java
public class OutcomeRecorder implements TestWatcher {

    private static final List<String> ROWS = Collections.synchronizedList(new ArrayList<>());

    @Override
    public void testSuccessful(ExtensionContext context) {
        record(context, "PASSED", "");
    }

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        record(context, "FAILED", cause.toString());
    }

    @Override
    public void testAborted(ExtensionContext context, Throwable cause) {
        record(context, "ABORTED", cause.toString());
    }

    @Override
    public void testDisabled(ExtensionContext context, Optional<String> reason) {
        record(context, "SKIPPED", reason.orElse("no reason given"));
    }

    private static void record(ExtensionContext context, String status, String detail) {
        ROWS.add(String.join("|", context.getUniqueId(), context.getDisplayName(), status, detail));
    }
}

go deeper

for a junior

Name the interface and the four callbacks, and say that exactly one fires per executed test method.

for a middle

Give the exact signatures, distinguish disabled (reason string, never ran) from aborted (throwable, started then bailed), and mention class-level registration.

for a senior

Cover what is not covered — containers, and tests never started because a class-level fixture failed — thread safety under parallel execution, and when to move reporting to a launcher-level listener.

for a principal

Position it in a reporting strategy: in-process watcher for per-test artefacts versus launcher listeners or build-tool report consumers for suite-wide aggregation, and who owns the resulting data.

## What TestWatcher is for Most Jupiter extension callbacks fire *around* a test: before it, after it, when a parameter needs resolving. None of those tell you cleanly how the test turned out. `TestWatcher` is the interface whose entire job is to receive the **result** of a test method after the engine has decided it — for custom reports, flaky-test tracking, screenshots on failure, publishing metrics, or annotating an external system. ## The four callbacks ```java public interface TestWatcher extends Extension { default void testDisabled(ExtensionContext context, Optional<String> reason) {} default void testSuccessful(ExtensionContext context) {} default void testAborted(ExtensionContext context, Throwable cause) {} default void testFailed(ExtensionContext context, Throwable cause) {} } ``` All four are `default` no-ops, so an implementation overrides only the ones it cares about. **`testDisabled(context, Optional<String> reason)`** — the test was *not executed* because an `ExecutionCondition` returned a disabled result (that includes `@Disabled`, `@EnabledOnOs` and friends, and your own conditions). The `Optional` holds the reason string the condition supplied, if any. Note the shape: a skip is not a failure and carries no throwable. **`testSuccessful(context)`** — the test method completed without a failure being recorded. **`testAborted(context, Throwable cause)`** — the test *started* and then bailed out at runtime rather than failing. The canonical cause is `org.opentest4j.TestAbortedException`, thrown when a runtime precondition inside the body was not met. Reporting-wise, aborted sits between skipped and failed: work was done, the verdict is inconclusive. **`testFailed(context, Throwable cause)`** — the test failed. The `cause` is the throwable that caused it, typically an `AssertionFailedError` from an assertion or any exception escaping the test. Exactly one of the four is invoked for each test method the engine processes, so a watcher can safely increment counters without double counting. ## What you get from the ExtensionContext The `ExtensionContext` passed in is the context of the test that just finished. Useful accessors: `getDisplayName()`, `getUniqueId()`, `getTestClass()` and `getTestMethod()` (both `Optional`), `getTags()`, `getRequiredTestClass()` / `getRequiredTestMethod()` for the non-optional forms, and `getExecutionException()` (which mirrors the `cause`). That is enough to key a report row, group by tag, or find the source method with reflection. ## Which nodes trigger it `TestWatcher` observes **test methods** — `@Test` methods and each invocation of a `@TestTemplate`, so a `@RepeatedTest` with five repetitions produces five callbacks, and a parameterized test produces one per argument set. It is *not* invoked for containers: test classes and `@Nested` classes do not produce watcher callbacks, and neither does a test that never executes because its enclosing container failed — for example when a class-level `@BeforeAll` throws, its tests are never started, so no per-test callback arrives. If you need a genuinely complete picture of the run, including container failures, that belongs at the launcher level with a `TestExecutionListener` rather than in an extension. ## Registration and timing Register a watcher the usual ways — `@ExtendWith(MyWatcher.class)` on the test class, a static `@RegisterExtension` field, a common test interface, or automatic registration via the `ServiceLoader` mechanism so it applies to the whole suite. Class level or higher is the sensible placement: a watcher that only exists for one method rarely earns its keep, and registration high in the hierarchy is what makes suite-wide reporting work. The callbacks run **after** the test has finished and after its after-each lifecycle callbacks, which matters if your watcher wants to capture state produced during teardown — by then teardown has already run. ## A worked shape ```java public class ResultRecorder implements TestWatcher { private static final List<String> ROWS = Collections.synchronizedList(new ArrayList<>()); @Override public void testSuccessful(ExtensionContext c) { row(c, "PASSED", null); } @Override public void testFailed(ExtensionContext c, Throwable t) { row(c, "FAILED", t.toString()); } @Override public void testAborted(ExtensionContext c, Throwable t) { row(c, "ABORTED", t.toString()); } @Override public void testDisabled(ExtensionContext c, Optional<String> reason) { row(c, "SKIPPED", reason.orElse("no reason given")); } private static void row(ExtensionContext c, String status, String detail) { ROWS.add(c.getUniqueId() + "|" + status + "|" + Objects.toString(detail, "")); } } ``` Note the synchronised collection: with parallel execution enabled, watcher callbacks arrive from multiple threads. ## Choosing between TestWatcher and the alternatives - Need the **outcome** of individual test methods, in-process, with access to the extension context (stores, tags, the test instance's class)? `TestWatcher`. - Need something to run **after** every test regardless of outcome, and possibly to fail the build? An after-each callback — a watcher cannot influence results. - Need the whole run, including containers, plans and the summary, or need it to work across engines? A launcher-level `TestExecutionListener`. - Need to *react to* a thrown exception and possibly swallow or rethrow it? A test-execution exception handler, which sits in the failure path itself rather than observing it afterwards.

  • Which callback fires when a test bails out because a runtime precondition inside its body was not met, and what is the cause argument?
    `testAborted(context, cause)` fires, and the cause is an `org.opentest4j.TestAbortedException`. Aborted is a distinct verdict from both failed and disabled: the test actually started and then stopped without a conclusive result, so reports should not count it as a pass or a failure.
  • A repeated test runs five repetitions. How many TestWatcher callbacks do you get?
    Five — one per invocation, because each repetition is a separate test-template invocation and therefore a separate test node. The same applies to a parameterized test, which produces one callback per argument set. The template method itself is a container and produces no watcher callback.

saying these in an interview costs you the question

  • Expecting a `TestWatcher` callback for a test class or `@Nested` container
  • Thinking `testDisabled` receives the exception that caused the skip — it receives an `Optional<String>` reason and no throwable
  • Confusing aborted with failed, and counting aborted tests as failures in a report
  • Assuming callbacks are single-threaded when parallel execution is enabled
  • Believing more than one of the four can fire for the same test node

context