A JUnit 5 test method carries @EnabledIfSystemProperty(named = "env", matches = "ci"). Under exactly which values of that system property does the test run, what happens when the property is not set at all, and how does @EnabledIfEnvironmentVariable differ in practice?
answer
- matches = regex, full match, case-sensitive
- absent key: Enabled -> disabled, Disabled -> enabled
- matches = ".*" means "is set"
- env var = process, cannot be set inside the JVM
- repeatable, multiple conditions ANDed
basics
~20 smatches is a regular expression that must match the property's ENTIRE value, so only the exact value "ci" enables it — "ci-eu" does not. If the property is absent the test is disabled, because a missing value cannot match. @EnabledIfEnvironmentVariable is identical but reads the process environment, which the JVM cannot set at runtime.
solid answer
~60 s`matches` is a **regex evaluated as a full match** against the property's value, not a substring or literal comparison. So `matches = "ci"` enables the test only when `env` is exactly `ci`; `ci-eu` does not match. To accept a family of values you write the regex: `matches = "ci.*"` or `matches = "ci|staging"`. If the property is **not set**, there is no value to match, so the `Enabled...` form disables the test (reason: the property does not exist). The mirror image matters: `@DisabledIfSystemProperty` with an absent property leaves the test **enabled** — absence never disables. That asymmetry is the usual source of "why did this test run in production CI?". `@EnabledIfEnvironmentVariable` behaves identically but reads `System.getenv`. The practical differences: environment variables are inherited from the process, cannot be set from within the JVM, are case-sensitive on Unix, and are typically injected by CI; system properties come from `-D` flags or the build's JVM args and can be set programmatically before conditions are evaluated. Both annotations are repeatable, and multiple occurrences are ANDed.
code
java · 22 linesimport org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;
class GatedTests {
@Test
@EnabledIfSystemProperty(named = "env", matches = "ci")
void onlyWhenEnvIsExactlyCi() { } // "ci-eu" does NOT enable this
@Test
@EnabledIfSystemProperty(named = "env", matches = "ci.*")
void anyCiFlavour() { }
@Test
@EnabledIfEnvironmentVariable(named = "DB_URL", matches = ".*",
disabledReason = "no database configured")
void needsDatabase() { } // runs when DB_URL is set to anything
@Test
@DisabledIfSystemProperty(named = "skip.slow", matches = "true")
void runsUnlessExplicitlySkipped() { } // absent property -> still runs
}go deeper
Say that matches is a regex against the whole value and that a missing property means the enabled-if test is skipped.
Lay out the four-cell truth table for present/absent versus enabled/disabled forms, and explain where properties and environment variables actually come from.
Choose the annotation form by failure mode (fail-closed for destructive tests), keep the gate in one meta-annotation, and note that mutating properties at runtime is unsafe under parallel execution.
Standardise the gating vocabulary across the suite — one custom annotation per environment class, one place where the regex lives — so that changing a pipeline variable cannot silently disable a slice of coverage.
## The two annotations ```java @EnabledIfSystemProperty(named = "env", matches = "ci") @EnabledIfEnvironmentVariable(named = "CI", matches = "true") ``` Both take a `named` key and a `matches` pattern, both have `Disabled...` twins, both accept `disabledReason`, and both are `@Repeatable`. ## `matches` is a full-match regex This is the single most-tested detail. Jupiter compares the value with `Pattern.matches`-style semantics: the **entire** value must match the pattern. Consequences: - `matches = "ci"` → enabled only for the exact value `ci`. Not `CI` (case-sensitive), not `ci-eu`, not ` ci` with a stray space. - `matches = "ci.*"` → `ci`, `ci-eu`, `ci-nightly`. - `matches = "ci|staging"` → either exact word. - `matches = ".*"` → any value, i.e. "the key is set to something" — the idiomatic presence check. - Regex metacharacters are live: `matches = "1.2"` also matches `1x2`. Escape them (`"1\\.2"`). A very common bug is treating `matches` as a literal, producing a test that quietly never runs because the real value has a suffix. ## Absent keys and the enabled/disabled asymmetry | Annotation | key absent | key present, value matches | key present, value does not match | |---|---|---|---| | `@EnabledIfSystemProperty` | **disabled** | enabled | disabled | | `@DisabledIfSystemProperty` | **enabled** | disabled | enabled | Same table for the environment-variable pair. The rule to remember: *a missing key can never satisfy a match, and matching is what drives the annotation's own verb.* So "enabled if" fails closed, "disabled if" fails open. Choosing between them is therefore a safety decision: if a test is dangerous to run outside a specific environment (it writes to a shared system), gate it with the `Enabled...` form so a misconfigured environment skips rather than runs. ## Where the values come from **System properties** are JVM-level: set by `-Denv=ci` in the test JVM's arguments, by the build's test-task configuration, or programmatically via `System.setProperty` — which works only if it happens before the condition is evaluated, i.e. in a `@BeforeAll` of a *different* class or a launcher-level hook, not in the test's own setup (conditions run before the instance exists). Relying on programmatic mutation is fragile, especially with parallel execution, where another thread's property change can flip a condition mid-run. **Environment variables** are inherited from the process that started the JVM. Java cannot change its own environment, so they are set by CI configuration, a shell, or the build tool's process-environment settings. They are case-sensitive on Unix-like systems and conventionally upper-case. This makes them the natural fit for "am I in CI?" and for secrets injected by the pipeline, while system properties fit "which profile did this run choose?". ## Repeating and combining Both annotations are repeatable: ```java @EnabledIfEnvironmentVariable(named = "CI", matches = "true") @EnabledIfEnvironmentVariable(named = "REGION", matches = "eu-.*") @Test void runsOnlyInEuCi() { } ``` All of them must be satisfied — conditions are ANDed for enablement. There is no built-in OR across separate annotations; express alternatives inside one regex, or write a method-based `@EnabledIf` when the logic is genuinely compound. ## Reporting and debugging A non-match produces a skipped result whose default reason names the key and the pattern, which is usually enough to diagnose. Add `disabledReason` to explain *why* the gate exists. When a test is unexpectedly skipped, the checklist is: is the key spelled and cased correctly; is the value exactly what you think (log it); is `matches` a full-match regex that covers the real value; and is the key visible to the *test JVM* rather than only to the build's own process. ## Meta-annotation reuse Because conditions are resolved through meta-annotations, teams normally wrap these once: ```java @Target(METHOD) @Retention(RUNTIME) @Test @EnabledIfEnvironmentVariable(named = "CI", matches = "true", disabledReason = "needs pipeline credentials") public @interface CiOnlyTest { } ``` That keeps the regex and the reason in one place instead of copy-pasted across a suite, where a later change to the CI value would otherwise silently disable dozens of tests.
- A test gated with @EnabledIfSystemProperty(named = "env", matches = "ci") never runs in a pipeline where the property is set to "ci-eu". Why, and what is the minimal fix?matches is a regex evaluated against the whole value, so "ci" does not match "ci-eu" — the test is skipped with a reason naming the pattern. The minimal fix is to widen the pattern, for example matches = "ci.*" or matches = "ci(-.*)?". Do not switch to a substring assumption; there is no substring mode.
- Can you set the system property a condition reads from inside @BeforeEach or the test's own setup?No. Conditions are evaluated before the test instance is created and before lifecycle callbacks for that test run, so the property must exist earlier — from the JVM's -D arguments, the build's test configuration, or a launcher-level hook. Mutating global properties mid-run is also unsafe under parallel execution, since another thread's condition may read the changed value.
- You must skip a destructive test unless the pipeline explicitly opts in. Which annotation form do you choose and why?The @EnabledIf... form, so that a missing or misspelled key results in the test being skipped rather than run. The Disabled... form fails open: if the key is absent, the test executes. When the failure mode of running is worse than the failure mode of skipping, choose the annotation whose default on absence is 'do not run'.
saying these in an interview costs you the question
- Treating matches as a literal string comparison or a substring check
- Believing an unset property makes @EnabledIfSystemProperty enable the test
- Assuming @DisabledIfSystemProperty disables the test when the property is missing
- Thinking you can set the required environment variable from inside the JVM at runtime
- Expecting two conditions on one element to be ORed so satisfying either one enables the test