skip to content

Tags, Conditions & Parallelism

Selecting which tests run — by tag or by environment — and running them concurrently. Interviewers ask because CI pipelines live and die on these mechanics.

on this pageshow

explore

questions

19

JUnit 5 ships annotations that switch a test off depending on the environment it runs in — for example @EnabledOnOs and @DisabledOnJre. Name the main members of that family, describe how they can be applied, and explain what shows up in the report for a test they switch off.

level: juniorimportance: must knowfreq 48%

answer

  1. package org.junit.jupiter.api.condition
  2. OS / JRE / SystemProperty / EnvironmentVariable / EnabledIf
  3. class-level disables the whole container
  4. result = skipped + disabledReason
  5. backed by ExecutionCondition extensions

basics

~20 s

JUnit 5 has @EnabledOnOs/@DisabledOnOs, @EnabledOnJre/@DisabledOnJre and @EnabledForJreRange/@DisabledForJreRange, plus @EnabledIfSystemProperty/@EnabledIfEnvironmentVariable and method-based @EnabledIf/@DisabledIf. They go on a test method or a whole class, and a switched-off test is reported as skipped with a reason — it never runs.

solid answer

~40 s

The family lives in `org.junit.jupiter.api.condition`: - **OS/architecture:** `@EnabledOnOs(OS.LINUX)`, `@DisabledOnOs(OS.WINDOWS)`, with an `architectures` attribute for CPU architecture. - **Java version:** `@EnabledOnJre(JRE.JAVA_17)`, `@DisabledOnJre`, and range forms `@EnabledForJreRange(min = JRE.JAVA_17, max = JRE.JAVA_21)` / `@DisabledForJreRange`. - **Ambient config:** `@EnabledIfSystemProperty(named=..., matches=...)`, `@EnabledIfEnvironmentVariable(...)` and their `Disabled` twins — `matches` is a regex. - **Custom logic:** `@EnabledIf("methodName")` / `@DisabledIf(...)` naming a boolean method. All of them can be placed on a test method or on the class (where a disabled class skips the whole container, including `@BeforeAll`). They are implemented as `ExecutionCondition` extensions, evaluated before the test instance is created. A disabled test is reported as **skipped** with a reason — default text like "Disabled on OS: ...", or the `disabledReason` you supply — so it stays visible in the report instead of silently disappearing.

code

java · 22 lines
java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;

class FileSystemTest {

    @Test
    @EnabledOnOs(value = OS.LINUX, disabledReason = "uses inotify")
    void watchesDirectory() { }

    @Test
    @DisabledOnJre(JRE.JAVA_17)
    void usesNewerApi() { }

    @Test
    @EnabledForJreRange(min = JRE.JAVA_17, max = JRE.JAVA_21)
    void supportedRangeOnly() { }
}

@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
class NightlyIntegrationTest {
    @Test void slowEndToEndFlow() { }   // whole class skipped locally
}

go deeper

for a junior

Name the annotations, show one on a method, and say the test is reported as skipped rather than passing.

for a middle

Add placement semantics (class-level skips the container including @BeforeAll), meta-annotation reuse, ANDed multiple conditions, and disabledReason.

for a senior

Discuss when environment-gating is the right call at all versus making the test environment-independent, and the reporting discipline of skipped-with-reason so gated tests do not rot unnoticed.

for a principal

Treat conditionally-disabled tests as a coverage risk to be tracked — a test permanently skipped in every environment is dead code that still looks like coverage; policy should require a named reason and a place where skips are reviewed.

## The problem they solve Some tests are genuinely environment-bound: a file-locking test that only makes sense on Windows, a test using an API added in a newer Java release, an integration test that needs credentials present only in CI. JUnit 5 lets you express that declaratively rather than with an `if` and a `return` that silently reports a green test that never ran. ## The catalogue All of these are in `org.junit.jupiter.api.condition`, and every one has an `Enabled...` and a `Disabled...` form: | Concern | Annotations | |---|---| | Operating system / CPU | `@EnabledOnOs`, `@DisabledOnOs` — take `OS` enum values (`LINUX`, `MAC`, `WINDOWS`, `AIX`, `SOLARIS`, `FREEBSD`, `OTHER`) and optionally `architectures` (for example `"aarch64"`) | | Java runtime version | `@EnabledOnJre`, `@DisabledOnJre` (specific versions), `@EnabledForJreRange`, `@DisabledForJreRange` (`min`/`max`) | | System property | `@EnabledIfSystemProperty`, `@DisabledIfSystemProperty` (`named` + `matches` regex) | | Environment variable | `@EnabledIfEnvironmentVariable`, `@DisabledIfEnvironmentVariable` (`named` + `matches` regex) | | Arbitrary logic | `@EnabledIf`, `@DisabledIf` — name a boolean method | There is also `@Disabled`, the unconditional one, which is not part of this family conceptually but shares the reporting behaviour. ## How they are applied - **On a method:** only that test is affected. - **On a class:** the entire container is skipped. Nothing inside runs — not the test methods, not `@BeforeAll`, not `@Nested` children. This matters: people sometimes expect class-level setup to still execute. - **On a custom annotation:** these are meta-annotatable, so you can define `@IntegrationTest` as `@Test @EnabledIfEnvironmentVariable(named = "CI", matches = "true")` and use one annotation everywhere. - **Combined:** several conditions on the same element are all evaluated, and **any** condition that says "disabled" wins — they are ANDed for enablement. The OS/JRE/property/env annotations are repeatable, so you can list several system-property requirements on one element. ## The `JRE` enum and ranges `@EnabledOnJre(JRE.JAVA_21)` matches only that feature release. `@EnabledForJreRange(min = JRE.JAVA_17, max = JRE.JAVA_21)` matches the inclusive range; omitting `min` means "from the lowest supported", omitting `max` means "up to the highest known". The catch is `JRE.OTHER`: when the tests run on a Java release newer than the enum constants known to your JUnit version, the detected JRE is `OTHER` and it falls outside every named range — so `@EnabledForJreRange(min = JRE.JAVA_17)` can unexpectedly *disable* a test on a brand-new JDK. Recent JUnit versions add integer `minVersion`/`maxVersion` attributes precisely to avoid depending on the enum being up to date. ## Reporting: skipped, with a reason When a condition disables an element, Jupiter reports it as **skipped** and attaches a reason. The defaults are descriptive ("Disabled on JRE version: 25", "@EnabledIfEnvironmentVariable(\"CI\") does not match ..."). Every annotation in the family also has a `disabledReason` attribute for a human explanation, which is what a reviewer reads six months later: ```java @DisabledOnOs(value = OS.WINDOWS, disabledReason = "POSIX file permissions unavailable") ``` This visibility is the argument for annotations over an early `return`: the test count shows the test was not executed, which an `if` hides. ## Conditions vs assumptions Both avoid running code in the wrong environment, but they differ in *when* and *what they report*. A condition is evaluated **before** the test instance is created and yields a **skipped** result; an assumption (`Assumptions.assumeTrue(...)`) runs **inside** the test after setup and yields an **aborted** result, which is why it is the right tool when the decision depends on state only available mid-test. Declarative environment facts — OS, Java version, presence of an env var — belong in conditions. ## Under the hood Each annotation is backed by an `ExecutionCondition` extension registered on the annotation itself. That is why the whole family behaves consistently, why custom conditions written as `ExecutionCondition` implementations plug in the same way, and why the platform offers a switch to deactivate conditions wholesale when you need to force a skipped test to run.

  • If you put @DisabledOnOs(OS.WINDOWS) on the test class, does @BeforeAll still run on Windows?
    No. A class-level condition disables the container itself, so Jupiter skips the whole class: no test instances, no @BeforeAll or @AfterAll, and no nested classes. The container is reported as skipped with the reason. If you need class setup to run regardless, the condition belongs on the individual methods instead.
  • When would you use Assumptions.assumeTrue instead of one of these annotations?
    When the decision depends on state that only exists once the test is running — a service responded, a fixture returned no data, a feature flag fetched at runtime. Conditions are evaluated before the test instance is created, so they can only see ambient facts such as the OS, Java version, system properties and environment variables. Assumptions also report the test as aborted rather than skipped.
  • How do you avoid repeating the same condition on twenty test classes?
    Meta-annotate: define your own annotation, for example @IntegrationTest, and put @Test plus the condition annotations on it. Jupiter resolves conditions through meta-annotations, so the single custom annotation carries the behaviour and gives the rule a name that reads well in the code.

saying these in an interview costs you the question

  • Thinking a disabled test is reported as passing, or disappears from the report entirely
  • Expecting @BeforeAll to still run when the condition is on the class
  • Believing these annotations only work on methods
  • Confusing conditions (evaluated before instance creation, reported skipped) with assumptions (evaluated inside the test, reported aborted)
  • Assuming two conditions on one element are ORed so any single satisfied condition enables the test

context

open as a page

In JUnit 5, what do you have to configure to make tests actually run in parallel, and where do those settings live?

level: juniorimportance: must knowfreq 45%

basics

~10 s

Parallel execution is off by default. Set junit.jupiter.execution.parallel.enabled=true, then set the default execution modes: junit.jupiter.execution.parallel.mode.default and junit.jupiter.execution.parallel.mode.classes.default to concurrent. Put them in junit-platform.properties on the test classpath, or pass them as JVM system properties.

open as a page

In JUnit 5, how do you label a subset of tests with the @Tag annotation so that a build can run only that subset, and what does @Tag actually do at discovery time?

level: juniorimportance: must knowfreq 50%

basics

~20 s

@Tag("slow") labels a test class or method; it is metadata and changes nothing by itself. The runner passes a tag expression (Gradle includeTags/excludeTags, Maven groups/excludedGroups) and the JUnit Platform filters non-matching tests out of the run.

open as a page

In JUnit 5 parallel execution, what is the difference between the configuration parameters junit.jupiter.execution.parallel.mode.default and junit.jupiter.execution.parallel.mode.classes.default, and how does the @Execution annotation interact with them?

level: middleimportance: must knowfreq 38%

basics

~20 s

mode.classes.default decides whether top-level test classes may run concurrently with each other; mode.default is the default mode for the rest of the tree — methods and nested classes. Both take same_thread or concurrent. @Execution(CONCURRENT|SAME_THREAD) on a class or method overrides the configured default for that node and everything nested inside it.

open as a page

In JUnit 5, what does the @ResourceLock annotation do, and when would you reach for it?

level: middleimportance: must knowfreq 34%

basics

~20 s

@ResourceLock declares that a test class or method needs a named shared resource. When tests run in parallel, JUnit will not let two tests hold conflicting locks on the same key at once, so tests that touch the same shared state are serialised while everything else keeps running concurrently.

open as a page

Explain the tag-expression syntax the JUnit Platform uses to select tests: what do the operators !, & and | mean, what do the selectors any() and none() match, what is the precedence, and which characters are therefore illegal inside a @Tag value?

level: middleimportance: must knowfreq 42%

basics

~20 s

Tag expressions are boolean: ! (not), & (and), | (or), with that precedence, and parentheses to group — e.g. (micro | contract) & !flaky. any() matches any test with at least one tag, none() matches untagged tests. Because those symbols are grammar, a tag value cannot contain , ( ) & | ! or whitespace, and must not be blank.

open as a page

JUnit 5's @EnabledIf and @DisabledIf take the name of a condition method rather than a value. What are the rules for that method — its signature, visibility, where it may live, and when it is invoked — and when would you reach for it instead of the OS/JRE/property annotations?

level: middleimportance: should knowfreq 30%

basics

~20 s

The named method must return boolean, take no arguments, and not be private. It must be static unless the class uses @TestInstance(PER_CLASS), because conditions are evaluated before the test instance exists. It can live in the test class or be referenced by fully qualified name like com.acme.Conditions#onCi, and it is evaluated per annotated element.

open as a page

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?

level: middleimportance: should knowfreq 36%

basics

~20 s

matches 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.

open as a page

JUnit 5 lets you choose a parallel execution strategy of dynamic, fixed, or custom. What does each one mean, and how do you control the resulting degree of parallelism?

level: middleimportance: should knowfreq 32%

basics

~10 s

junit.jupiter.execution.parallel.config.strategy picks how the thread pool is sized. dynamic (the default) uses available processors multiplied by ...config.dynamic.factor (default 1.0). fixed uses the exact number in ...config.fixed.parallelism. custom names your own ParallelExecutionConfigurationStrategy class in ...config.custom.class.

open as a page

What does JUnit 5's @Isolated annotation guarantee, and how does it differ from putting @ResourceLock on the same class?

level: middleimportance: should knowfreq 25%

basics

~20 s

@Isolated marks a test class to run exclusively: while it executes, no other test in the suite runs concurrently, even tests that share no resource with it. @ResourceLock only excludes tests declaring the same key. @Isolated is effectively an exclusive lock on a global resource, so it is the blunt instrument to use when the shared state cannot be named.

open as a page

JUnit 5's @ResourceLock takes a ResourceAccessMode of READ or READ_WRITE. What is the difference in what JUnit allows to run concurrently, and how do you choose?

level: middleimportance: should knowfreq 27%

basics

~20 s

READ is a shared lock: any number of tests declaring READ on the same key may run at once. READ_WRITE is exclusive: while a test holds it, no other test with that key runs, whether READ or READ_WRITE. Use READ when the test only observes the resource, READ_WRITE when it mutates it. READ_WRITE is the default.

open as a page

Instead of repeating the JUnit 5 annotation @Tag("slow") as a string literal across hundreds of tests, how would you define a custom @Slow annotation that carries the tag, and what does that buy you?

level: middleimportance: should knowfreq 35%

basics

~20 s

Declare your own annotation with RUNTIME retention, targets TYPE and METHOD, and put @Tag("slow") on it. JUnit searches meta-annotations, so anything annotated @Slow is tagged slow. You get compile-checked usage, one place to rename, and can bundle extra annotations.

open as a page

You switched a large JUnit 5 suite to concurrent execution and a handful of tests started failing intermittently. How do you diagnose the failures and roll the change out safely?

level: seniorimportance: should knowfreq 34%

basics

~20 s

First prove concurrency is the cause: rerun with the parallel switch off. Then narrow it — set the default execution mode back to same_thread and opt classes in with @Execution(CONCURRENT), or keep concurrent globally and mark suspects @Execution(SAME_THREAD). Fix the real cause: static fields, system properties, fixed ports, shared files or database rows.

open as a page

One JUnit 5 test calls System.setProperty, another reads that property, and a third changes the JVM default time zone with TimeZone.setDefault. Under parallel execution, how do you stop them corrupting each other, and what built-in keys does JUnit provide for cases like these?

level: seniorimportance: should knowfreq 24%

basics

~20 s

Declare the shared JVM state with @ResourceLock using the constants in org.junit.jupiter.api.parallel.Resources — SYSTEM_PROPERTIES for the property tests, TIME_ZONE for the time-zone test — with READ_WRITE on the mutators and READ on the observer. Because these are canonical keys, tests written elsewhere coordinate with yours.

open as a page

One CI job excludes JUnit 5 tests tagged "slow" and another includes only tests tagged "fast". A newly written test carrying no tag at all runs in the first job but not the second — explain why, and describe what happens when both an include and an exclude filter are configured at once.

level: seniorimportance: should knowfreq 33%

basics

~20 s

An include filter keeps only tests whose tags satisfy it, and an untagged test satisfies no tag name, so it is dropped. An exclude filter drops only tests that match, so an untagged test survives. With both, exclusion wins: a test tagged fast and slow is excluded.

open as a page

Explain the difference between JUnit 5's @EnabledOnJre and @EnabledForJreRange, and describe what happens to a test annotated @EnabledForJreRange(min = JRE.JAVA_17) when it runs on a Java release newer than any constant your JUnit version knows about.

level: middleimportance: nice to knowfreq 22%

basics

~20 s

@EnabledOnJre lists exact feature releases; @EnabledForJreRange gives an inclusive min/max span. On a Java release newer than the JRE enum knows, JUnit reports JRE.OTHER, which falls outside every named range — so the test is unexpectedly skipped. Newer JUnit versions add integer minVersion/maxVersion to avoid this.

open as a page

A JUnit 5 test is being skipped in CI because of an environment-based condition annotation, and you need to force it to execute once to reproduce a failure without editing the source or the annotation. What mechanism does JUnit 5 provide for that, and what are the risks of using it?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

Set the JUnit configuration parameter junit.jupiter.conditions.deactivate to a pattern matching the ExecutionCondition classes to switch off — for example org.junit.* or *. Matching conditions stop being consulted, so gated tests run. The risk: it is coarse, and a broad pattern also deactivates @Disabled, resurrecting quarantined tests.

open as a page

How do you decide the degree of parallelism for a JUnit 5 test suite, and how do you judge whether running tests concurrently is paying off at all?

level: principalimportance: nice to knowfreq 24%

basics

~20 s

Measure, don't guess. Start with the dynamic strategy at factor 1.0, then sweep the factor or a fixed parallelism and plot wall-clock time and flake rate. CPU-bound suites plateau near the core count; I/O-bound ones keep improving until an external system saturates. Pin a fixed value on CI for reproducibility.

open as a page

A JUnit 5 suite runs concurrently, but wall-clock time barely improved: many classes declare exclusive resource locks and several are marked to run in isolation. How do you reason about the tradeoff between locking shared resources and redesigning the tests?

level: principalimportance: nice to knowfreq 18%

basics

~20 s

Measure the contention first: which keys are hot, how long each lock is held, how many classes are isolated. Then attack in order — remove the sharing where a test can own its state, narrow keys and lock scope where it cannot, and keep whole-suite isolation only for genuinely unnameable interference like timing measurements.

open as a page