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?
answer
- TestWatcher — four default methods
- testSuccessful / testFailed(cause) / testAborted(cause) / testDisabled(Optional<String> reason)
- exactly one fires per executed test method
- runs after the test and its after-each callbacks
- not invoked for containers / @Nested classes
basics
~10 sImplement 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 linespublic 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
Name the interface and the four callbacks, and say that exactly one fires per executed test method.
Give the exact signatures, distinguish disabled (reason string, never ran) from aborted (throwable, started then bailed), and mention class-level registration.
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.
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