Conditional Test Execution

8 min read

Some tests only make sense in specific environments: a test that writes to a staging database should not run in the developer's local build; a test that covers Linux file-locking behaviour should not run on macOS. JUnit 5 provides two mechanisms for this — declarative condition annotations that evaluate before the test runs, and programmatic assumptions that evaluate inside the test. Knowing which to use, and the difference between a test marked "disabled" and a test that "aborts", is what this lesson covers.

Built-in condition annotations

JUnit ships a full set of @EnabledIf... / @DisabledIf... annotations. Each wraps an ExecutionCondition extension that JUnit evaluates at runtime before calling the test method:

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.condition.*;
 
// Operating system
@EnabledOnOs(OS.LINUX)
@Test void linuxFilePermissionBehaviour() { ... }
 
@DisabledOnOs({OS.WINDOWS, OS.MAC})
@Test void unixSignalHandling() { ... }
 
// Java runtime version
@EnabledOnJre(JRE.JAVA_21)
@Test void virtualThreadTest() { ... }
 
@DisabledOnJre({JRE.JAVA_8, JRE.JAVA_11})
@Test void requiresModernJre() { ... }
 
// System property
@EnabledIfSystemProperty(named = "test.env", matches = "staging")
@Test void stagingOnlyTest() { ... }
 
// Environment variable
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
@Test void ciOnlyTest() { ... }
 
@DisabledIfEnvironmentVariable(named = "SKIP_SLOW_TESTS", matches = "true")
@Test void slowRegressionTest() { ... }

A test that fails a condition is marked ABORTED (skipped) in the report — not FAILED. This matters for CI: aborted tests do not fail the build. They appear as skipped with a reason, informing the reader without blocking the pipeline.

@EnabledIf with a custom condition method

For conditions that need logic — not just an environment variable equality check — @EnabledIf references a method on the test class:

class FeatureFlagTest {
 
    @EnabledIf("isPaymentFeatureEnabled")
    @Test
    void paymentFlowTest() {
        // Only runs when the feature flag is on
    }
 
    boolean isPaymentFeatureEnabled() {
        return FeatureFlags.isEnabled("payment-v2");
    }
}

The method must return boolean, be accessible from the test class, and have no parameters. It can be static or non-static. For shared conditions used across many test classes, implement a proper ExecutionCondition extension (shown in the previous lesson) and register it with @ExtendWith — that is more maintainable than duplicating the method in every test class.

Combining multiple conditions

Multiple condition annotations on the same method are ANDed — all must be true for the test to run:

@EnabledOnOs(OS.LINUX)
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
@Test
void linuxCiTest() {
    // Runs only on Linux AND only in CI
}

Assumptions — conditions evaluated inside the test

Assumptions are the programmatic alternative. Instead of an annotation evaluated before the test, you call an assumeTrue(...) inside the test body. If the assumption fails, JUnit aborts the test — same ABORTED status, same effect on the report:

import static org.junit.jupiter.api.Assumptions.*;
 
@Test
void testPaymentGateway() {
    assumeTrue("staging".equals(System.getenv("ENVIRONMENT")),
        "Skipping payment test — not in staging environment");
 
    // Everything below only runs when ENVIRONMENT=staging
    Response response = paymentClient.charge(100.0, "tok_test");
    assertEquals(200, response.getStatusCode());
}

assumeTrue throws TestAbortedException when the condition is false. JUnit catches it and marks the test ABORTED. This is different from assertTrue — a failing assertTrue marks the test FAILED. A failing assumeTrue marks it SKIPPED.

assumingThat — partial conditions inside a test

assumingThat lets part of a test be conditional while the rest always runs:

@Test
void testProductPage() {
    Product product = productService.getById(1);
 
    // This assertion always runs — in every environment
    assertNotNull(product, "Product should exist in all environments");
 
    // This assertion only runs in staging
    assumingThat("staging".equals(System.getenv("ENVIRONMENT")), () -> {
        assertEquals("Staging Product Name", product.getName());
    });
 
    // This continues regardless of assumingThat
    assertTrue(product.getPrice() > 0);
}

assumingThat does not abort the whole test — only its lambda is skipped if the condition is false. The test continues after assumingThat and can still pass or fail based on other assertions.

@Disabled vs conditions vs assumptions — when to use each

MechanismUse when…Result if inactive
@DisabledTest is temporarily broken and should never runSKIPPED — always
Condition annotationTest should run in specific environments, declared upfrontABORTED — at runtime
assumeTrueCondition depends on runtime state, determined inside the testABORTED — at runtime
assumingThatOnly part of the test is conditionalPartial execution — rest continues

Condition evaluation flow

Comparison with TestNG

TestNG handles conditional execution with @Test(enabled = false) (equivalent to @Disabled) and throw new SkipException("...") inside the test (equivalent to assumeTrue). There is no TestNG annotation equivalent to @EnabledOnOs or @EnabledIfEnvironmentVariable — those require custom listeners or logic inside @BeforeMethod.

JUnit 5's condition annotations are declarative and self-documenting. The intent is visible at a glance on the test method, and no listener or custom code is needed for common cases like OS or JRE version checks.

⚠️ Common mistakes

  • Using assumeTrue when you mean assertTrue. assumeTrue(user != null) silently skips the test if user is null. assertNotNull(user) fails the test if user is null. The first hides a bug; the second exposes it. Use assertNotNull to check preconditions that must be true for correctness, assumeTrue for environment preconditions that may legitimately vary.
  • Stacking condition annotations expecting OR logic. @EnabledOnOs(OS.LINUX) AND @EnabledOnJre(JRE.JAVA_21) means both must be true. If you want Linux OR Java 21, you need a custom ExecutionCondition or @EnabledIf with a method that implements the OR logic.
  • Relying on system property conditions in parallel tests. @EnabledIfSystemProperty reads system properties at evaluation time. System properties are global to the JVM. If parallel tests modify system properties, the condition evaluation becomes non-deterministic. Use environment variables (read-only at runtime) instead of mutable system properties for conditions in parallel test suites.

🎯 Practice task

Build a multi-environment test suite. 20–25 minutes.

  1. Write a test class with four tests:
    • One with @EnabledOnOs(OS.MAC) (or whatever your OS is)
    • One with @DisabledOnOs for a different OS
    • One with @EnabledIfEnvironmentVariable(named = "RUN_INTEGRATION", matches = "true")
    • One with plain @Test (no condition)
  2. Run mvn test. Confirm the appropriate tests run and the conditional ones are skipped. Check the Surefire report to confirm skipped tests show as ABORTED, not FAILED.
  3. assumeTrue pattern. Write a test with assumeTrue(System.getenv("DB_URL") != null, "Skipping — no database configured"). Run without setting DB_URL. Confirm the test is ABORTED. Set DB_URL=jdbc:h2:mem:test in your shell and run again — the test executes.
  4. assumingThat. Write a test that always asserts one thing but conditionally asserts another based on the environment. Confirm the always-assertion can fail independently of the conditional one.
  5. Stretch — custom condition. Implement a BusinessHoursCondition that enables a test only if the current hour (local time) is between 9 and 17. Register it with @ExtendWith. Run at different hours (or mock LocalTime.now()) to verify the condition.

Next lesson: @Timeout and parallel execution — controlling test speed and running tests concurrently with @ResourceLock for thread safety.

// tip to track lessons you complete and pick up where you left off across devices.