skip to content

You want a custom live console line for every test as it finishes, and separately you want one shared Docker container started before the first test in a JVM and stopped after the last one. Which JUnit Platform listener interfaces cover each need, and how are they registered?

level: seniorimportance: should knowfreq 20%

answer

  1. TestExecutionListener = per-run events, engine-agnostic
  2. LauncherSessionListener = once per JVM session
  3. META-INF/services auto-registration
  4. Listeners observe; extensions influence
  5. Register before execute; callbacks are multi-threaded

basics

~10 s

Per-test reporting uses a TestExecutionListener, which receives testPlanExecutionStarted, executionStarted/skipped/finished and testPlanExecutionFinished. One-per-JVM setup uses a LauncherSessionListener, whose launcherSessionOpened/Closed bracket the whole session. Both are registered automatically via ServiceLoader, or explicitly on the Launcher.

solid answer

~40 s

Two different interfaces, two different scopes. **`TestExecutionListener`** (Platform level) sees the merged `TestPlan` and every execution event: `testPlanExecutionStarted(plan)`, `executionStarted(id)`, `executionSkipped(id, reason)`, `executionFinished(id, TestExecutionResult)`, `reportingEntryPublished`, `testPlanExecutionFinished(plan)`. That is where you write your own console line, timing, or report — and because it lives at the Platform, it observes Jupiter, vintage and third-party engines alike. **`LauncherSessionListener`** brackets the whole launcher session: `launcherSessionOpened(session)` fires before any discovery, `launcherSessionClosed(session)` after everything. That is the correct hook for one-per-JVM resources such as a shared container or test server, started once and closed at the end — far better than a static flag with a shutdown hook. Registration: declare the implementation as a `ServiceLoader` service in `META-INF/services/...`, so it auto-registers wherever the tests run; or call `launcher.registerTestExecutionListeners(...)` when driving the Launcher yourself.

code

java · 21 lines
java
public class ConsoleProgressListener implements TestExecutionListener {

    private final Map<String, Long> startedAt = new ConcurrentHashMap<>();

    @Override
    public void executionStarted(TestIdentifier id) {
        if (id.isTest()) startedAt.put(id.getUniqueId(), System.nanoTime());
    }

    @Override
    public void executionFinished(TestIdentifier id, TestExecutionResult result) {
        if (!id.isTest()) return;
        long ms = (System.nanoTime() - startedAt.remove(id.getUniqueId())) / 1_000_000;
        System.out.printf("%-8s %s (%d ms)%n", result.getStatus(), id.getDisplayName(), ms);
    }

    @Override
    public void executionSkipped(TestIdentifier id, String reason) {
        System.out.printf("SKIPPED  %s (%s)%n", id.getDisplayName(), reason);
    }
}

go deeper

for a junior

Know that listeners let you observe tests starting and finishing, and that you register them via a services file.

for a middle

Distinguish the two interfaces and their scopes, name the main callbacks, and know the ServiceLoader and programmatic registration paths.

for a senior

Emphasise that listeners are engine-agnostic observers, that session listeners are the right home for once-per-JVM resources, and the thread-safety/performance cautions under parallel execution.

for a principal

Position Platform listeners as the org-wide seam for reporting and shared infrastructure — one implementation covering every engine and repo — versus per-framework hooks that fragment.

## The two scopes The JUnit Platform exposes observation points at two levels, and picking the wrong one is the usual mistake. ### TestExecutionListener — per-run, per-node events Registered on the `Launcher`, it receives the lifecycle of one execution: - `testPlanExecutionStarted(TestPlan plan)` — the full merged plan, before anything runs. Good place to count tests, snapshot start time, print a header. - `dynamicTestRegistered(TestIdentifier)` — nodes created during execution (dynamic tests, parameterised invocations) that did not exist at discovery. - `executionStarted(TestIdentifier)` — a container or test began. - `executionSkipped(TestIdentifier, String reason)` — never started, e.g. disabled. - `executionFinished(TestIdentifier, TestExecutionResult)` — status SUCCESSFUL, FAILED (with throwable) or ABORTED (failed assumption). - `reportingEntryPublished(TestIdentifier, ReportEntry)` — key/value data a test published. - `testPlanExecutionFinished(TestPlan)` — everything done; write summaries here. Because `TestIdentifier` exposes `isTest()`, `isContainer()`, tags, source and `getUniqueIdObject()`, a listener can build rich output: durations per test, a machine-readable stream keyed by unique id, flaky-test detection, or a custom CI annotation format. `SummaryGeneratingListener` and `LegacyXmlReportGeneratingListener` are built-in examples. Two properties matter. First, listeners are **engine-agnostic**: they observe every engine's tests, unlike a Jupiter `Extension` which sees only Jupiter tests. Second, they are **observers** — a listener cannot fail a test, change a result, or skip anything. If you need to influence behaviour, you need an extension (Jupiter) or a filter, not a listener. ### LauncherSessionListener — once per session `LauncherSessionListener` has `launcherSessionOpened(LauncherSession)` and `launcherSessionClosed(LauncherSession)`. A session is created by `LauncherFactory.openSession()` and closed when the try-with-resources block ends; build tools and IDEs open one per test JVM. So the callbacks bracket **everything**, including discovery, and fire exactly once per JVM run. This is the intended home for expensive shared infrastructure: start a database container, an embedded broker, or a test HTTP server on open; stop it on close. The alternative people reach for — a static field plus `Runtime.addShutdownHook` — has no defined ordering with test execution and leaks when the JVM is reused. The session listener is deterministic. A caveat: with a forked-per-something execution model, "once per session" means once per JVM, so the resource starts once in each fork. That is a property of the execution setup, not of the listener. ## Registration **Automatic (`ServiceLoader`)** — the usual choice, because it works no matter who launches the tests: ``` META-INF/services/org.junit.platform.launcher.TestExecutionListener META-INF/services/org.junit.platform.launcher.LauncherSessionListener ``` Each file lists the fully qualified implementation class. Auto-registration of execution listeners can be disabled via the configuration parameter `junit.platform.execution.listeners.deactivate` with a class-name pattern, which is how you silence a noisy third-party listener without removing its jar. **Programmatic** — when your own code drives the Launcher: ```java launcher.registerTestExecutionListeners(new MyListener()); // or pass them to execute(request, listeners...) ``` Registration must happen **before** `execute`; a listener added afterwards observes nothing. Session listeners cannot be registered this way — they come from service discovery, since the session exists before you hold a launcher. ## Choosing correctly | Need | Use | |---|---| | Per-test line, timings, custom report | `TestExecutionListener` | | One resource per JVM run | `LauncherSessionListener` | | Modify/skip/fail a test, inject parameters | Jupiter `Extension` (not a listener) | | Restrict which tests run | Selectors/filters in the request | ## Practical cautions - **Keep listeners fast and non-throwing.** They run inline with execution; an exception from a listener disturbs the run, and slow I/O in `executionFinished` inflates the whole suite. - **Thread-safety.** With parallel execution, callbacks arrive from multiple threads. Aggregate into concurrent structures, not a plain `HashMap`. - **Don't reconstruct hierarchy by hand.** Ask the `TestPlan` for parents and descendants using identifiers. - **Distinguish ABORTED from FAILED** in output, or assumption-skipped tests will look like failures.

  • Why not use a Jupiter `Extension` with `BeforeAllCallback` for the shared container?
    A `BeforeAllCallback` fires per test class (or per root context, with extra bookkeeping), applies only to Jupiter tests, and gives no reliable end-of-run hook. A `LauncherSessionListener` fires exactly once per JVM session, brackets discovery as well as execution, and covers every engine — so vintage or Cucumber tests can also use the container.
  • Can a TestExecutionListener make a test fail?
    No. Listeners are pure observers of the execution stream; they cannot alter results, skip nodes, or inject anything. Influencing behaviour requires a Jupiter extension, a filter in the discovery request, or engine-level configuration. A listener throwing an exception disrupts the run rather than producing a clean failure.
  • How do you silence a third-party listener that auto-registers itself and floods the output?
    Set the configuration parameter `junit.platform.execution.listeners.deactivate` to a pattern matching its class name. That deactivates auto-registered execution listeners without removing the dependency, which is useful when the listener ships inside a library you still need.

The session listener is the building's opening and closing time; the execution listener is the turnstile counting each visitor through.

saying these in an interview costs you the question

  • Using a TestExecutionListener to try to skip or fail tests
  • Using a Jupiter extension for once-per-JVM setup and expecting a reliable teardown
  • Registering listeners after calling execute and expecting events
  • Accumulating listener state in a non-thread-safe map under parallel execution
  • Treating ABORTED results as failures in custom reports

context