Disabling, Ordering, and Tagging Tests

8 min read

Three scenarios come up on every real project: a test is temporarily broken and you need to disable it without deleting it, you need to run only the smoke tests in CI, and occasionally you need a specific execution order for stateful tests. JUnit 5 covers all three with dedicated annotations. This lesson also shows how to build composite annotations so you don't repeat @Tag("smoke") @Test on every method.

Disabling tests with @Disabled

@Disabled marks a test or an entire class as skipped. The test appears in the report as "Skipped" with the reason string — which is important for anyone reading the report to understand why the test is not running:

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
 
@Disabled("Waiting for JIRA-456 — payment gateway API is down")
class PaymentGatewayTest {
    // All tests in this class are skipped
}
 
class CheckoutTest {
 
    @Test
    @Disabled("Flaky on CI — timer precision issue, tracked in JIRA-789")
    void shouldTimeOutAfterThirtySeconds() {
        // Skipped until the flakiness is resolved
    }
 
    @Test
    void shouldCalculateTotal() {
        // This one still runs
    }
}

Always include a reason string. @Disabled without a message is a mystery to anyone reading the report six months later. The format "Reason — JIRA-XXXX" links the disable to a trackable work item.

Conditional execution — skipping based on environment

For tests that should only run in specific environments, JUnit 5 provides built-in condition annotations that are more expressive than @Disabled:

import org.junit.jupiter.api.condition.*;
 
// Run only on Linux
@EnabledOnOs(OS.LINUX)
@Test void linuxSpecificBehaviour() { ... }
 
// Skip on Windows
@DisabledOnOs(OS.WINDOWS)
@Test void notOnWindows() { ... }
 
// Run only when JAVA_VERSION environment variable equals "17"
@EnabledIfEnvironmentVariable(named = "JAVA_VERSION", matches = "17")
@Test void java17Feature() { ... }
 
// Run only when a system property is set
@EnabledIfSystemProperty(named = "test.env", matches = "staging")
@Test void stagingOnly() { ... }
 
// Run only on JRE 17 or later
@EnabledOnJre({ JRE.JAVA_17, JRE.JAVA_21 })
@Test void modernJreOnly() { ... }

The difference from @Disabled: these evaluate a condition at runtime. A test that is disabled by @EnabledOnOs(OS.LINUX) on a Windows machine shows as "Skipped" in the report — which is informative. @Disabled is unconditional; @EnabledOnOs is conditional.

Tagging tests — the @Tag annotation

Tags are how JUnit 5 replicates TestNG's groups. You annotate tests with one or more tags and run subsets by tag expression:

import org.junit.jupiter.api.Tag;
 
@Tag("smoke")
@Test void loginHappyPath() { ... }
 
@Tag("regression")
@Tag("slow")
@Test void fullCheckoutFlow() { ... }
 
@Tag("api")
@Tag("smoke")
@Test void healthCheckEndpoint() { ... }

Running by tag from Maven:

# Run only smoke tests
mvn test -Dgroups=smoke
 
# Run smoke OR api tests
mvn test -Dgroups="smoke | api"
 
# Run regression tests that are NOT slow
mvn test -Dgroups="regression & !slow"

Or configure in pom.xml for a CI profile:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
    <configuration>
        <groups>smoke</groups>
        <excludedGroups>slow</excludedGroups>
    </configuration>
</plugin>

This mirrors TestNG's <groups><run><include name="smoke"/></run></groups> in testng.xml — the difference is that JUnit's tag filtering uses a boolean expression syntax, which is more flexible.

Controlling test order with @TestMethodOrder

By default, JUnit 5 does not guarantee execution order. If your tests are truly independent (they should be), this is fine. For tests that exercise a stateful workflow — for example, a Selenium test that creates an account, then logs in, then places an order — you may want a specific order:

import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.TestMethodOrder;
 
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AccountWorkflowTest {
 
    @Test @Order(1)
    @DisplayName("Create account")
    void createAccount() { ... }
 
    @Test @Order(2)
    @DisplayName("Log in with new credentials")
    void login() { ... }
 
    @Test @Order(3)
    @DisplayName("Place an order")
    void placeOrder() { ... }
}

Other orderers:

// Alphabetical by method name — deterministic but not human-controlled
@TestMethodOrder(MethodOrderer.MethodName.class)
 
// Random — intentionally non-deterministic; reveals hidden ordering dependencies
@TestMethodOrder(MethodOrderer.Random.class)
 
// Alphabetical by @DisplayName value
@TestMethodOrder(MethodOrderer.DisplayName.class)

MethodOrderer.Random.class is a useful diagnostic tool: if your tests only pass when run in a specific order, randomising reveals the dependency so you can fix it.

Composite annotations — the clean solution for repeated tags

Writing @Tag("smoke") @Test on every smoke test method is repetitive and error-prone. JUnit 5 lets you create custom composed annotations that bundle multiple annotations together:

import java.lang.annotation.*;
import org.junit.jupiter.api.*;
 
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tag("smoke")
@Test
public @interface SmokeTest { }
 
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tag("regression")
@Tag("slow")
@Test
public @interface SlowRegressionTest { }

Now your tests read cleanly:

@SmokeTest
void loginHappyPath() { ... }
 
@SlowRegressionTest
void fullCheckoutFlow() { ... }

The @Tag and @Test are composed inside the annotation — JUnit reads through the meta-annotations and applies them. Running mvn test -Dgroups=smoke still picks up @SmokeTest methods.

Test filtering flow

⚠️ Common mistakes

  • @Disabled without a reason string. Six months later nobody knows why the test is disabled, whether the issue is fixed, or whether the test can be deleted. Always write a reason — ideally with a ticket reference.
  • Using @TestMethodOrder to paper over test isolation problems. If a test only passes when run after a specific other test, the tests are not isolated — they share state. @Order is the right tool for deliberate stateful workflows (like a checkout flow), not a band-aid for tests that should be independent.
  • Tag names with spaces or special characters. Tags like "smoke test" or "api-regression" may not work correctly in tag expression filters. Use simple alphanumeric tags with no spaces: "smoke", "api", "regression", "slow". The -Dgroups syntax expects the tag exactly as written.

🎯 Practice task

Add tags and ordering to your existing test suite. 20–25 minutes.

  1. Take your ProductServiceTest or UserApiTest and tag every test with at least one of: "smoke", "regression", "api". Give at least two tests two tags each.
  2. Run mvn test -Dgroups=smoke. Confirm only the tagged tests execute. Run mvn test -Dgroups="smoke | api". Confirm the union runs.
  3. Create a composed annotation. Define @SmokeTest that bundles @Tag("smoke") and @Test. Replace @Tag("smoke") @Test usages with @SmokeTest. Run the tag filter again — confirm @SmokeTest methods are still picked up.
  4. Disable one test. Pick a passing test and add @Disabled("Temporarily disabled — JIRA-999"). Run the suite. Confirm the test appears as "Skipped" with your reason.
  5. Add ordering. Create a three-test class with @TestMethodOrder(MethodOrderer.OrderAnnotation.class) simulating a create→read→delete flow. Add System.out.println to each step. Run and confirm the order in the console.
  6. Stretch — reveal a hidden dependency. Add @TestMethodOrder(MethodOrderer.Random.class) to the ordering class. Run it five times. If the results are inconsistent, your tests share state. Fix the issue by resetting state in @BeforeEach and confirm they pass in any order.

You now have the full Chapter 2 toolkit. Next chapter: parameterised tests — running the same test logic with dozens of input combinations using @ParameterizedTest, @ValueSource, @CsvSource, and @MethodSource.

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