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?
answer
- ExecutionCondition → one method
- evaluateExecutionCondition(ExtensionContext)
- ConditionEvaluationResult.enabled/disabled(reason)
- evaluated per container and per test, short-circuits on disabled
- @Disabled/@EnabledOnOs are just built-in conditions
basics
~20 sImplement 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 sI 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@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
Know that ExecutionCondition exists, that @Disabled and @EnabledOnOs are built on it, and that a disabled test is reported as skipped with a reason.
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.
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.
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