skip to content

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%

answer

  1. @EnabledOnOs / @DisabledOnOs
  2. @EnabledIfEnvironmentVariable(named, matches) — matches is a regex
  3. @EnabledOnJre / @EnabledForJreRange
  4. @EnabledIf("methodName") for custom predicates
  5. reported as skipped WITH a reason, not passed

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.

solid answer

~50 s

JUnit 5 ships a family of conditional annotations, all implemented as `ExecutionCondition` extensions: - `@EnabledOnOs` / `@DisabledOnOs` — operating system; - `@EnabledOnJre` / `@EnabledForJreRange` — Java version; - `@EnabledIfSystemProperty(named = ..., matches = ...)` and `@EnabledIfEnvironmentVariable(named = ..., matches = ...)` — the `matches` value is a **regex** matched against the whole value; - `@EnabledIf` / `@DisabledIf` — delegate to a named boolean method; - `@Disabled("reason")` — unconditional. For this case I put `@EnabledOnOs(OS.LINUX)` and `@EnabledIfEnvironmentVariable(named = "CI", matches = ".+")` on the test; multiple conditions are ANDed, and the first one that disables wins. They can go on the class or the method, and they compose into a custom meta-annotation such as `@LinuxCiOnly`. A test excluded this way is reported as **skipped with the reason** in the IDE, in Gradle/Surefire output and in the XML report — visible, unlike a test body that quietly returns.

code

java · 21 lines
java
class PlatformSpecificTest {

    @Test
    @EnabledOnOs(OS.LINUX)
    @EnabledIfEnvironmentVariable(named = "CI", matches = ".+")
    void usesEpoll() { }

    @Test
    @EnabledIf("dockerAvailable")
    void startsContainer() { }

    static boolean dockerAvailable() {
        return Files.exists(Path.of("/var/run/docker.sock"));
    }
}

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@EnabledOnOs(OS.LINUX)
@EnabledIfEnvironmentVariable(named = "CI", matches = ".+")
@interface LinuxCiOnly { }

go deeper

for a junior

Name the annotations and show them stacked on a method; state that the result is a skip with a reason, not a pass and not a failure.

for a middle

Add that matches is a regex over the whole value, that class-level annotations skip the whole container, and that these are all ExecutionCondition implementations under the hood.

for a senior

Discuss composing them into a project meta-annotation, keeping skip reasons diagnostic, and treating a rising skip count in CI as coverage loss.

for a principal

Set policy: which tests may be environment-gated at all, whether CI should skip or hard-fail when infrastructure is missing, and how skips are surfaced and reviewed so gating does not become permanent.

## The built-in conditional annotations JUnit 5 (Jupiter) provides a set of annotations that decide, before a test runs, whether it should run at all. Every one of them is implemented internally as an `ExecutionCondition` extension, which is why they behave uniformly. **Unconditional:** `@Disabled` — on a class or a method, optionally with a reason string. Always supply the reason; `@Disabled("flaky, see ticket ABC-123")` is the difference between a temporary measure and permanent rot. **Operating system:** `@EnabledOnOs(OS.LINUX)`, `@DisabledOnOs({ OS.WINDOWS, OS.MAC })`. Recent versions also allow matching on architecture (`architectures = "aarch64"`). **Java runtime:** `@EnabledOnJre(JRE.JAVA_17)`, `@EnabledForJreRange(min = JRE.JAVA_17, max = JRE.JAVA_21)`, plus the disabled counterparts. **System properties and environment variables:** `@EnabledIfSystemProperty(named = "env", matches = "ci")` and `@EnabledIfEnvironmentVariable(named = "CI", matches = ".+")`. Two things trip people up here: `matches` is a **regular expression**, not an equality check, and it must match the **entire** value; and if the property or variable is *absent*, the enabled-form annotation disables the test (there is nothing to match). **Custom predicate:** `@EnabledIf("methodName")` / `@DisabledIf("methodName")` name a method returning `boolean`. The method may be in the test class (static, or instance when the lifecycle is per-class) or referenced by fully qualified name, e.g. `@EnabledIf("com.example.Conditions#dockerAvailable")`. It may take no arguments or a single `ExtensionContext`. **Native image:** `@EnabledInNativeImage` / `@DisabledInNativeImage` for GraalVM runs. ## Combining them Annotations may be placed on a test method, a test class, a `@Nested` class, or a test interface. Class-level conditions apply to everything inside; a disabled class skips all of its tests without evaluating them individually. Multiple conditional annotations on the same element are effectively **ANDed** — the engine evaluates the registered conditions in order and short-circuits on the first one that returns disabled, so the test runs only when none objects. For the question's case: ```java @EnabledOnOs(OS.LINUX) @EnabledIfEnvironmentVariable(named = "CI", matches = ".+") @Test void runsOnlyOnLinuxCi() { } ``` Because they are ordinary annotations they compose. A meta-annotation keeps intent readable and gives you one place to change the policy: ```java @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @EnabledOnOs(OS.LINUX) @EnabledIfEnvironmentVariable(named = "CI", matches = ".+") public @interface LinuxCiOnly { } ``` ## How the skip is reported The engine reports the node to the launcher as **skipped, with a reason** — for example `Disabled on operating system: Mac OS X ==> @EnabledOnOs is not satisfied`. Consequences worth stating in an interview: - It is neither a pass nor a failure. The build stays green, but the skip is counted and named. - IDEs show it with a distinct skipped icon and the reason as a tooltip; the XML report contains a `<skipped>` element. - It is therefore *visible*, unlike the common anti-pattern of starting a test with `if (!isLinux()) return;`, which reports a pass and silently erodes coverage. ## Contrast: condition-based skip vs. an in-test abort A condition is evaluated **before** the test instance is created and before any `@BeforeEach` runs, and it decides on information available at that moment (OS, env var, JRE). An abort raised from inside the test body — the assumption mechanism — happens *during* execution, after fixtures are built, and is reported as **aborted** rather than disabled. Use a conditional annotation when the environment decides; use an in-test abort when the decision depends on something you can only learn while running. ## When to reach for a custom condition instead If the predicate is genuinely custom — a reachable service, a licence file, a feature flag from configuration — write an `ExecutionCondition` and hide it behind your own annotation, rather than abusing `@EnabledIfSystemProperty` with contrived properties. But start with the built-ins: they are well tested, their reasons read well in reports, and every one of them can be turned off for a run via the `junit.jupiter.conditions.deactivate` configuration parameter, which custom conditions also inherit for free. ## Practical hygiene Skips are debt. Two habits keep them honest: always write a reason (`@Disabled` without one is unreviewable), and watch the skip count in CI — a suite whose skipped total drifts upward is losing coverage silently, and an environment-gated test that never runs anywhere is worse than a deleted one.

  • `@EnabledIfEnvironmentVariable(named = "CI", matches = "true")` — the variable is set to `TRUE_FOR_NOW` and the test does not run. Why?
    `matches` is a regular expression that must match the entire value, so `true` does not match `TRUE_FOR_NOW` (and it is case-sensitive). Use `matches = "(?i)true.*"` or, more usually, `matches = ".+"` when you only care that the variable is set at all.
  • What happens to the other tests when you put `@Disabled` on a `@Nested` class?
    The nested class is a container; disabling a container skips every test inside it, and those tests are not evaluated individually — each is reported as skipped. Tests in the enclosing class and in sibling nested classes are unaffected.

saying these in an interview costs you the question

  • Treating `matches` as an equality check rather than a full-value regular expression
  • Believing the environment-variable annotation enables the test when the variable is absent
  • Starting the test body with `if (!linux) return;` — that reports a pass and hides lost coverage
  • Saying a skipped test fails the build or is counted as a failure
  • Using `@Disabled` with no reason string

context