Skip to content

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:

  1. Tag must not be null or empty. @Tag("") and @Tag(null) are invalid.

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

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

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

OperatorMeaningExample
&AND — both tags must be presentcard & visa
|OR — at least one tag must be presentcard | bank
!NOT — tag must not be present!visa
( )Grouping for precedence(card | bank) & !slow

Examples:

  • card & visa — select tests that have both the card and visa tags
  • card | bank — select tests that have either card or bank (or both)
  • !visa — select tests that do not have the visa tag
  • (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 test

Hardcoding 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 → only processBankTransfer() runs (fast bank tests)
  • cardprocessVisaPayment() and processMasterCardPayment() run
  • payment → 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:

FeatureTagging and FilteringTest Suites
Grouping mechanismAnnotation-based (@Tag)Java class-based (@Suite)
Runtime flexibilityDynamic (expression at runtime)Static (hardcoded in suite class)
Duplicate test riskNonePossible
ReadabilityScattered across test classesCentralised in suite class
Maintenance overheadLowHigher (suite classes accumulate)
Industry trendModern, preferred for CIStill 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.