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
Tests selected by tag at each stage
⚠️ Common mistakes
@Disabledwithout 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
@TestMethodOrderto 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.@Orderis 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-Dgroupssyntax expects the tag exactly as written.
🎯 Practice task
Add tags and ordering to your existing test suite. 20–25 minutes.
- Take your
ProductServiceTestorUserApiTestand tag every test with at least one of:"smoke","regression","api". Give at least two tests two tags each. - Run
mvn test -Dgroups=smoke. Confirm only the tagged tests execute. Runmvn test -Dgroups="smoke | api". Confirm the union runs. - Create a composed annotation. Define
@SmokeTestthat bundles@Tag("smoke")and@Test. Replace@Tag("smoke") @Testusages with@SmokeTest. Run the tag filter again — confirm@SmokeTestmethods are still picked up. - 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. - Add ordering. Create a three-test class with
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)simulating a create→read→delete flow. AddSystem.out.printlnto each step. Run and confirm the order in the console. - 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@BeforeEachand 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.