skip to content

Conditions & Watchers

Extensions that decide whether a test runs or observe its outcome: ExecutionCondition and TestWatcher. Interviewers use these to test the breadth of the extension surface.

on this pageshow

questions

5

In JUnit 5, how do you make a test class or a test method skip itself automatically based on a runtime check — for example, a required database not being reachable — using the extension model rather than commenting the test out?

level: middleimportance: must knowfreq 50%

answer

  1. ExecutionCondition → one method
  2. evaluateExecutionCondition(ExtensionContext)
  3. ConditionEvaluationResult.enabled/disabled(reason)
  4. evaluated per container and per test, short-circuits on disabled
  5. @Disabled/@EnabledOnOs are just built-in conditions

basics

~20 s

Implement JUnit 5's ExecutionCondition extension. Its evaluateExecutionCondition(ExtensionContext) returns ConditionEvaluationResult.enabled(reason) or disabled(reason). JUnit calls it for every container and test it is registered on; a disabled result skips that node, reported as skipped rather than failed.

solid answer

~40 s

I write an `ExecutionCondition`. It is a JUnit 5 extension with one method, `ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context)`, and I return `ConditionEvaluationResult.enabled("db reachable")` or `ConditionEvaluationResult.disabled("db unreachable at localhost:5432")`. The reason string is what shows up as the skip reason in reports and IDEs, so I make it diagnostic. The engine evaluates registered conditions for every container (test class, `@Nested` class) and every test before it is executed, in registration order, short-circuiting on the first `disabled` result. Disabling a container skips everything inside it. In practice I pair the condition with a meta-annotation — `@ExtendWith(DatabaseCondition.class)` on a custom `@RequiresDatabase` annotation — so tests read declaratively and the condition can read attributes from the annotation via `context.getElement()`. All the built-in conditional annotations (`@Disabled`, `@EnabledOnOs`, `@EnabledIfEnvironmentVariable`) are implemented exactly this way.

code

java · 28 lines
java
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(DatabaseCondition.class)
public @interface RequiresDatabase {
    String host() default "localhost";
    int port() default 5432;
}

public class DatabaseCondition implements ExecutionCondition {

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        Optional<RequiresDatabase> annotation =
                AnnotationSupport.findAnnotation(context.getElement(), RequiresDatabase.class);
        if (annotation.isEmpty()) {
            return ConditionEvaluationResult.enabled("@RequiresDatabase not present");
        }
        RequiresDatabase cfg = annotation.get();
        try (Socket socket = new Socket()) {
            socket.connect(new InetSocketAddress(cfg.host(), cfg.port()), 250);
            return ConditionEvaluationResult.enabled("database reachable");
        }
        catch (IOException ex) {
            return ConditionEvaluationResult.disabled(
                    "database unreachable at " + cfg.host() + ":" + cfg.port());
        }
    }
}

go deeper

for a junior

Know that ExecutionCondition exists, that @Disabled and @EnabledOnOs are built on it, and that a disabled test is reported as skipped with a reason.

for a middle

Be able to write the interface from memory, return ConditionEvaluationResult.enabled/disabled, register it through a composed annotation, and explain that it is evaluated per container and per test.

for a senior

Talk about evaluation cost and caching, short-circuit ordering, making skip reasons diagnostic, and the CI trap where infrastructure-based skips hide outages behind a green build.

for a principal

Frame it as suite policy: which categories of test may self-skip at all, how skips are made visible (thresholds on skip counts in CI), and where the line sits between conditions, tagging, and separate test source sets.

## The problem Some tests should not run everywhere: an integration test that needs a database, a test that only makes sense on Linux, a test gated behind a feature flag. Deleting or commenting them out loses them. JUnit 5 solves this with **conditional test execution**, and the extension point behind it is `ExecutionCondition`. ## The interface ```java public interface ExecutionCondition extends Extension { ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context); } ``` One method. It receives the `ExtensionContext` — the engine's handle on the node being considered, from which you can get the display name, the test class (`getTestClass()`), the test method (`getTestMethod()`), the annotated element (`getElement()`), tags, and configuration parameters (`getConfigurationParameter(String)`). `ConditionEvaluationResult` is a small value object with three factory methods: `enabled(String reason)`, `disabled(String reason)` and `disabled(String reason, String customReason)` (the second form lets a condition carry both a generic reason and a user-supplied message, which is how annotations with a `disabledReason` attribute work). There is no "abstain" — you must return enabled or disabled, and enabled simply means "I have no objection". ## When it is evaluated The Jupiter engine evaluates conditions **before** the node is executed, for every *container* and every *test*: the test class, each `@Nested` class, and each test method — and for a `@TestTemplate` such as `@RepeatedTest` or `@ParameterizedTest`, for each invocation as well. Conditions registered at class level therefore run once for the class and again for each method in it, so keep them cheap or cache the expensive part (for instance a single socket probe held in a static field or in the extension context store). Conditions are evaluated in registration order and the engine **short-circuits on the first `disabled` result** — remaining conditions are not consulted. Disabling a container skips every test inside it; those tests are not evaluated individually. ## Reporting semantics A disabled node is reported to the launcher as **skipped with a reason**, not failed and not passed. Gradle, Maven Surefire, IDEs and the ConsoleLauncher all surface it as a skip, and the reason string you returned is the message shown. This is the main reason to prefer a condition over silently `return`-ing from the test body: a skipped test is visible, a test that returns early looks green and lies. ## Registration A condition is an ordinary extension, so it is registered the ordinary ways: `@ExtendWith(MyCondition.class)` on a class or method, a `@RegisterExtension` field, automatic registration via the `ServiceLoader` mechanism, or — most idiomatically — through a **composed (meta) annotation**: ```java @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @ExtendWith(DatabaseCondition.class) public @interface RequiresDatabase { String url() default "jdbc:postgresql://localhost:5432/test"; } ``` Inside the condition, `AnnotationSupport.findAnnotation(context.getElement(), RequiresDatabase.class)` gives you the annotation and its attributes. `findAnnotation` walks the element, so class-level annotations are visible when evaluating a method. ## The built-ins are the same mechanism `@Disabled` is implemented by `DisabledCondition`; `@EnabledOnOs` / `@DisabledOnOs`, `@EnabledOnJre` / `@EnabledForJreRange`, `@EnabledIfSystemProperty`, `@EnabledIfEnvironmentVariable`, and `@EnabledIf` / `@DisabledIf` (which call a named boolean method) are each an `ExecutionCondition` shipped with Jupiter. Reach for those first; write your own only when the predicate is genuinely custom. Because they are all conditions, they are all subject to the `junit.jupiter.conditions.deactivate` configuration parameter, which can turn conditions off for a run. ## Design guidance - **Make the reason actionable.** `disabled("no Docker daemon on unix:///var/run/docker.sock")` beats `disabled("skipped")`; that string is the only thing a colleague sees in CI output. - **Fail loudly in CI.** A condition that skips when infrastructure is missing is convenient locally and dangerous in CI, where an infrastructure outage becomes a green build with silent skips. A common pattern is to make the condition itself conditional: skip locally, but return `enabled` (and let the test fail hard) when an environment variable such as `CI` is set. - **Keep it fast and side-effect free.** Conditions run for every node; a five-second connection timeout per test method is a real cost. - **Do not throw from a condition to skip.** An exception from `evaluateExecutionCondition` is a failure, not a skip. - **Conditions are static-ish, per-node decisions.** If the decision can only be made once the test is running with resolved fixtures, that is what assumption-style aborts are for; conditions run before the test instance even exists.

  • Your condition is registered on the test class and probes a TCP port. What is the performance consequence, and how would you avoid it?
    A class-level condition is evaluated once for the container and again for every test method in it, so a 200 ms probe on a 40-test class costs eight seconds. Cache the probe result — a lazily initialised static field, or a value stored in the root `ExtensionContext` store so it is computed once per JVM run — and have `evaluateExecutionCondition` read the cached value.
  • What is the difference in reporting between a test skipped by an ExecutionCondition and a test that just returns early from its body?
    A condition-skipped test is reported to the launcher as skipped with the reason string, so it shows up as a skip in Gradle/Surefire/IDE output and in the XML report. A test that returns early is reported as passed — the suite looks green while nothing was verified, which is how coverage silently rots.
  • Can an ExecutionCondition see the annotation attributes on the test it is evaluating?
    Yes. `ExtensionContext.getElement()` returns the annotated `AnnotatedElement` (the method or class), and `AnnotationSupport.findAnnotation(element, MyAnnotation.class)` retrieves the annotation including inherited and meta-annotated occurrences. That is how a composed annotation like `@RequiresDatabase(url = "...")` passes configuration to its condition.

saying these in an interview costs you the question

  • Saying you would throw an exception from the condition to skip the test — that is reported as a failure, not a skip
  • Claiming the condition runs once per class; it is evaluated for the container and again for every test
  • Thinking a disabled result on a class still lets individual methods run
  • Returning `null` or nothing to mean 'no opinion' — every invocation must return enabled or disabled
  • Confusing the extension-model condition with a runtime abort inside the test body; conditions are evaluated before the test instance exists

context

open as a page

A JUnit 5 test only makes sense on Linux and only when the environment variable CI is set. What does JUnit 5 give you out of the box to keep that test in the suite but not run it elsewhere, and how does the non-run show up in the results?

level: juniorimportance: should knowfreq 48%

basics

~20 s

Use JUnit 5's built-in conditional annotations: @EnabledOnOs(OS.LINUX) plus @EnabledIfEnvironmentVariable(named = "CI", matches = ".+"). Combine them on the class or method. Elsewhere the test is reported as skipped with a reason, not failed and not passed.

open as a page

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%

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.

open as a page

A colleague writes a JUnit 5 TestWatcher that re-runs failed tests and throws an exception when it cannot write its report file. What documented limits of TestWatcher make both of those a mistake, and what does JUnit do with an exception thrown from a watcher callback?

level: seniorimportance: should knowfreq 26%

basics

~20 s

A TestWatcher may not influence execution: its callbacks return void and run after the test has already finished and after its after-each callbacks, so it cannot retry or change an outcome. Exceptions thrown from a watcher callback are logged and otherwise ignored — the build stays green while data is silently lost.

open as a page

A JUnit 5 suite has many tests switched off by @Disabled and by custom conditional annotations. Once a quarter you want one run that executes all of them without touching the code. What does the junit.jupiter.conditions.deactivate configuration parameter do, how do you supply it, and what are the caveats?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

junit.jupiter.conditions.deactivate takes a comma-separated pattern matched against the fully qualified class names of ExecutionCondition implementations; matching conditions are not evaluated, so their tests run. * deactivates all, including the one behind @Disabled. Supply it as a JVM system property, in junit-platform.properties, or via launcher config parameters.

open as a page