Appearance
JUnit 5: Tagging and Filtering
A Real World Problem: Running Only What Matters
Consider a payment platform with thousands of unit tests spread across card payments, bank transfers, wallet transactions, and user registration. When an engineer raises a pull request that only touches the bank transfer module, running all thousands of tests adds several minutes of CI time and produces a noisy report. What you really want is: "run only the bank related tests for this PR."
Before JUnit 5 tagging, the answer was to create separate test classes or modules — an organisational burden. JUnit 5 tags solve this at the annotation level. You label each test with one or more tags, then at runtime supply a tag expression that selects exactly the tests you care about. No code changes, no restructuring, just metadata.
What Are Tags?
A tag is a string label attached to a test method or an entire test class. Tags allow you to categorise tests so that you can selectively run or skip them without touching your test code.
java
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
class PaymentTaggingTest {
@Test
@Tag("card")
void paymentThroughCard() {
// card payment test logic
}
@Test
@Tag("bank")
void paymentThroughBank() {
// bank transfer test logic
}
@Test
@Tag("card")
@Tag("visa")
void visaCardPayment() {
// visa-specific card test
}
}A single test can have multiple tags. Running with the card tag expression selects paymentThroughCard and visaCardPayment. Running with bank selects only paymentThroughBank.
Tag Rules
JUnit 5 enforces several constraints on tag names:
Tag must not be null or empty.
@Tag("")and@Tag(null)are invalid.Leading and trailing whitespace is stripped automatically.
@Tag(" card ")is treated as@Tag("card"). This is what "strip tag" means in the JUnit 5 documentation.A stripped tag must not contain internal whitespace.
@Tag("card payment")is invalid because after stripping edges, the middle space remains. Tags are meant to be single tokens.Reserved characters are forbidden inside tag names. The characters
,,(,),&,|,!are used by the tag expression language and must not appear in tag names.
class level Tags
A tag placed on the class is inherited by every test method in that class. This is convenient when an entire class belongs to one category:
java
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("slow") // applies to every method in this class
class SlowPaymentTest {
@Test
@Tag("card") // this method has: "slow" + "card"
void slowCardPayment() { }
@Test
@Tag("bank") // this method has: "slow" + "bank"
void slowBankPayment() { }
}A test method can have both class level and method level tags simultaneously. The final effective set of tags for a method is the union of both.
Tag Expressions: Boolean Filtering
Tag expressions use boolean operators to build precise filter conditions. The syntax is identical to boolean logic you already know from if conditions.
| Operator | Meaning | Example |
|---|---|---|
& | AND — both tags must be present | card & visa |
| | OR — at least one tag must be present | card | bank |
! | NOT — tag must not be present | !visa |
( ) | Grouping for precedence | (card | bank) & !slow |
Examples:
card & visa— select tests that have both thecardandvisatagscard | bank— select tests that have eithercardorbank(or both)!visa— select tests that do not have thevisatag(card | bank) & !slow— card or bank tests, but only the fast ones
Running Tagged Tests in IntelliJ IDEA
In IntelliJ IDEA, open the Run / Edit Configurations dialog. Select your project. Change the test kind to Tags and type your tag expression in the expression field. When you run this configuration, only the matching tests execute.
Running Tagged Tests with Maven Surefire Plugin
In a CI environment, Maven is the standard build tool. The Maven Surefire Plugin runs JUnit tests during the test phase. You can configure tag filtering inside pom.xml:
xml
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<!-- Run only tests tagged 'bank' or 'card' -->
<groups>bank | card</groups>
</configuration>
</plugin>
</plugins>
</build>Then run:
bash
mvn testHardcoding the expression in pom.xml is not recommended for production. The tag expression should be dynamic, not static.
Production Pattern: Maven Profiles for Dynamic Tag Filtering
The recommended production approach uses Maven profiles combined with a system property placeholder, so the tag expression is supplied at runtime without changing source code:
xml
<profiles>
<profile>
<id>tag-test</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- Placeholder filled at runtime via -Dgroups=... -->
<groups>${groups}</groups>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>Run a targeted subset via the CI command line:
bash
# Run only bank tests
mvn test -P tag-test -Dgroups="bank"
# Run card or bank tests
mvn test -P tag-test -Dgroups="card | bank"
# Run fast card tests
mvn test -P tag-test -Dgroups="card & !slow"When the profile is not activated (plain mvn test), all tests run as usual. This gives CI pipelines the flexibility to run different subsets in different jobs without touching source files.
Full Production Example
Below is a complete example showing class level tags, method level tags, and multiple tags per method:
java
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* All methods in this class automatically inherit the "payment" tag.
*/
@Tag("payment")
class PaymentServiceTest {
@Test
@Tag("card")
@Tag("visa")
void processVisaPayment() {
// Tags: payment, card, visa
PaymentResult result = paymentService.processCard("VISA", 100.0);
assertEquals(PaymentStatus.SUCCESS, result.getStatus());
}
@Test
@Tag("card")
@Tag("mastercard")
void processMasterCardPayment() {
// Tags: payment, card, mastercard
PaymentResult result = paymentService.processCard("MASTERCARD", 200.0);
assertEquals(PaymentStatus.SUCCESS, result.getStatus());
}
@Test
@Tag("bank")
void processBankTransfer() {
// Tags: payment, bank
PaymentResult result = paymentService.processBankTransfer("IFSC001", 500.0);
assertEquals(PaymentStatus.SUCCESS, result.getStatus());
}
@Test
@Tag("bank")
@Tag("slow")
void processBankTransferWithVerification() {
// Tags: payment, bank, slow — excluded from fast CI runs
PaymentResult result = paymentService.processBankTransferWithVerification("IFSC001", 500.0);
assertEquals(PaymentStatus.SUCCESS, result.getStatus());
}
}Tag expression scenarios:
bank & !slow→ onlyprocessBankTransfer()runs (fast bank tests)card→processVisaPayment()andprocessMasterCardPayment()runpayment→ all four run (entire class)visa | mastercard→ both card-specific tests run
Creating Composite Custom Tag Annotations
To avoid repeating @Tag("bank") on dozens of methods, define a meta-annotation:
java
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.lang.annotation.*;
/**
* Shorthand for @Test + @Tag("bank").
* Use @BankTest instead of writing both annotations every time.
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag("bank")
public @interface BankTest { }java
class BankPaymentTest {
@BankTest
void transferBetweenAccounts() {
// automatically tagged "bank" and treated as a @Test
}
}This pattern eliminates annotation duplication and makes tag assignments consistent across a large codebase.
Tagging vs Test Suites
The next chapter covers test suites, which are another way to group tests. Here is a quick orientation:
| Feature | Tagging and Filtering | Test Suites |
|---|---|---|
| Grouping mechanism | Annotation-based (@Tag) | Java class-based (@Suite) |
| Runtime flexibility | Dynamic (expression at runtime) | Static (hardcoded in suite class) |
| Duplicate test risk | None | Possible |
| Readability | Scattered across test classes | Centralised in suite class |
| Maintenance overhead | Low | Higher (suite classes accumulate) |
| Industry trend | Modern, preferred for CI | Still widely used in many organisations |
Interview Questions & Pitfalls
Q1. What is the purpose of @Tag in JUnit 5 and how does it help in CI pipelines?
@Tag attaches a label to a test method or class. In CI pipelines, you use the tag expression in the Maven Surefire Plugin's <groups> configuration (or via -Dgroups with a Maven profile) to run only the tests relevant to the current change. This reduces CI time, improves signal-to-noise ratio, and enables targeted test runs per PR.
Q2. What characters are forbidden in JUnit 5 tag names and why?
The characters ,, (, ), &, |, and ! are forbidden because they are operators and delimiters in the tag expression language. Allowing them inside a tag name would make the expression ambiguous to parse.
Q3. What does "strip tag" mean in the context of JUnit 5 tag validation?
JUnit 5 strips (removes) leading and trailing whitespace from a tag name before applying validation. A "strip tag" is the result after this stripping. The rule "a strip tag must not contain whitespace" means that after edges are trimmed, no internal spaces are allowed.
Q4. If a class is annotated with @Tag("slow") and a method inside it is annotated with @Tag("bank"), what is the effective tag set for that method?
The method inherits the class level tag, so its effective tag set is {"slow", "bank"}. class level tags apply to every method in the class. method level tags add to, not replace, the class level tags.
Q5. Why is hardcoding the tag expression in pom.xml discouraged for production CI?
Because it couples the CI configuration to source code. Changing which tests run requires a code change and a commit. The recommended pattern is to use a Maven profile with a ${groups} placeholder, allowing the expression to be supplied via -Dgroups=... at the command line. This keeps the CI pipeline flexible without requiring source changes.
Q6. What is the difference between tag1 & tag2 and tag1 | tag2 in a tag expression?
tag1 & tag2 (AND) selects only tests that have both tags simultaneously. tag1 | tag2 (OR) selects tests that have either tag, including tests that have both.
Q7. How do you create a reusable composed annotation that combines @Test and @Tag?
Define a custom annotation annotated with both @Test and @Tag(...), plus @Target(METHOD) and @Retention(RUNTIME). Methods annotated with your custom annotation automatically inherit both @Test behavior and the associated tag. This eliminates repetition and ensures consistency across the codebase.