The JUnit 5 @Tag annotation puts a label on a test class or a test method, and a build tool or an IDE runs only the tests whose labels match a filter. A tag is a plain string such as fast, slow or integration, and one test can carry several of them.
We use tags when one test run is not right for every situation. For example, a developer wants the fast unit tests on every save, while the CI server also runs the slow integration tests before a merge.
The following example tags a test class with fast and recipes, and runs only the fast tests with Maven.
@Tag("fast")
@Tag("recipes")
class RecipeScalerTest {
private final RecipeScaler scaler = new RecipeScaler();
@Test
void doublesServings() { // tags: fast, recipes
assertEquals(400.0, scaler.scale(200, 2, 4));
}
}
mvn test -Dgroups=fast # runs 3 of the 7 tests in the project
mvn test -DexcludedGroups=slow # runs 5 of the 7 tests
[INFO] Running com.howtodoinjava.tags.ShoppingListTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.546 s -- in com.howtodoinjava.tags.ShoppingListTest
[INFO] Running com.howtodoinjava.tags.RecipeScalerTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.307 s -- in com.howtodoinjava.tags.RecipeScalerTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Notice that the class-level tags apply to every test method in the class, and that Maven does not report the filtered-out tests as skipped. They are not part of the test run at all.
Next, we see how class tags, method tags and custom annotations combine, and which tag names JUnit rejects. After that, we filter with tag expressions in Maven, Gradle, a test suite and IntelliJ IDEA. The examples use Java 25, JUnit 6.1.3, Maven Surefire 3.6.0 and Gradle 9.8.0. The @Tag annotation and the filter syntax are the same in JUnit 5 and JUnit 6.
1. How the @Tag Annotation Works
The annotation org.junit.jupiter.api.Tag is allowed on test classes and test methods, and JUnit reads it during test discovery (the step where JUnit finds the tests before running them). Tags do nothing on their own. When we run mvn test without a filter, every test runs, tagged or not, and only a tag filter decides which tests are left out.
A recipe app gives a typical picture. RecipeScalerTest checks plain arithmetic in a few milliseconds, RecipeSearchTest rebuilds a search index, and ShoppingListTest exports files. We tag the tests by speed (fast, slow), by type (integration) and by feature (recipes, shopping), so each team member can pick the subset they need.
1.1. Class Tags and Method Tags
A tag on the class applies to all test methods of the class, including the methods of @Nested classes and subclasses, because @Tag is an @Inherited annotation. A tag on a method adds to the class tags and never replaces them. So the method rebuildsSearchIndex() gets the two class tags plus its own slow tag.
@Tag("integration")
@Tag("recipes")
class RecipeSearchTest {
private final List<String> recipes = List.of("Pancakes", "Tomato soup", "Tomato pasta");
@Test
void findsByIngredient() { // tags: integration, recipes
long found = recipes.stream().filter(r -> r.contains("Tomato")).count();
assertEquals(2, found);
}
@Test
@Tag("slow")
void rebuildsSearchIndex() { // tags: integration, recipes, slow
List<String> index = recipes.stream().map(String::toLowerCase).sorted().toList();
assertEquals("pancakes", index.getFirst());
}
}
The @Tag annotation is repeatable, so we write it once per tag. The compiler wraps repeated annotations in the container annotation @Tags, and the form @Tags({@Tag(“integration”), @Tag(“recipes”)}) means the same.

The example project has 7 tests. The effective tags of each test decide which filters select it, and every count in the next sections comes from this table.
| Test method | Class tags | Method tags | Effective tags |
|---|---|---|---|
| AppStartupTest.createsScaler() | none | none | none |
| RecipeScalerTest.doublesServings() | fast, recipes | none | fast, recipes |
| RecipeScalerTest.rejectsZeroServings() | fast, recipes | none | fast, recipes |
| RecipeSearchTest.findsByIngredient() | integration, recipes | none | integration, recipes |
| RecipeSearchTest.rebuildsSearchIndex() | integration, recipes | slow | integration, recipes, slow |
| ShoppingListTest.mergesSameItem() | shopping | fast (from @FastTest) | shopping, fast |
| ShoppingListTest.exportsAsText() | shopping | slow, integration | shopping, slow, integration |
1.2. A Custom Annotation for a Tag
When we write @Test and @Tag(“fast”) on hundreds of methods, sooner or later someone types @Tag(“fsat”), and the misspelled tag drops the test from the fast run without any warning. JUnit supports composed annotations, that is, our own annotation that carries @Test and @Tag as meta-annotations. The compiler checks the annotation name, so a typo fails the build.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
@Test
public @interface FastTest {
}
The method mergesSameItem() uses @FastTest in place of @Test. JUnit runs it as a test and gives it the fast tag, so it shows up in the fast run next to the two RecipeScalerTest methods.
@Tag("shopping")
class ShoppingListTest {
@FastTest
void mergesSameItem() { // tags: shopping, fast
ShoppingList list = new ShoppingList();
list.add("eggs", 6);
list.add("eggs", 4);
assertEquals(10, list.quantityOf("eggs"));
}
}
1.3. Tag Naming Rules
JUnit strips a tag of leading and trailing whitespace and accepts it only when the rest follows these rules.
- The tag is not null or blank.
- The tag has no whitespace inside, so “smoke test” is invalid.
- The tag has no ISO control characters.
- The tag has no comma, no parentheses and none of the characters &, | and !, because tag expressions use them as operators.
An invalid tag does not break the build. JUnit logs a warning during discovery and ignores the tag, so the test runs without it. For example, a test with only @Tag(“smoke test”) has no tags at all, so -Dgroups=”any()” does not select it.
WARNING: TestEngine with ID 'junit-jupiter' encountered a non-critical issue during test discovery:
(1) [WARNING] Invalid tag syntax in @Tag("smoke test") declaration on method 'void com.howtodoinjava.tags.BadTagTest.badTag()'. Tag will be ignored.
So we use lowercase names with hyphens, such as smoke-test or end-to-end, and keep a short list of agreed tags in the project README.
2. JUnit 5 Tag Expressions
A tag expression is a boolean expression over tag names that selects tests. The same expression syntax works in Maven, Gradle, suites, IntelliJ IDEA and the console launcher, because the JUnit Platform evaluates it, not the build tool.
The operators are ! (not), & (and) and | (or), in this order of precedence, and parentheses change the order. Two special expressions are also allowed. The expression any() selects every test that has at least one tag, and none() selects every test without tags.
Every count in the table follows from the effective tags of the 7 tests in section 1.1, and mvn test -Dgroups=… with the same expression runs the same tests.
| Expression | Selected tests | Count |
|---|---|---|
| fast | both RecipeScalerTest tests, mergesSameItem() | 3 |
| fast | shopping | the 3 fast tests, exportsAsText() | 4 |
| recipes & !slow | both RecipeScalerTest tests, findsByIngredient() | 3 |
| (fast | integration) & !slow | the 3 fast tests, findsByIngredient() | 4 |
| any() | every test except createsScaler() | 6 |
| none() | createsScaler() | 1 |
A comma in Maven’s groups list means “or”, so -Dgroups=fast,shopping also runs 4 tests. We prefer the | form, because it reads the same everywhere.
A malformed expression stops the test run before any test starts. For example, -Dgroups=”fast & | slow” fails with a PreconditionViolationException that names the position of the problem.
[ERROR] org.junit.platform.commons.PreconditionViolationException: Unable to parse tag expression "fast & | slow": missing rhs operand for '&' at index <5>
[INFO] BUILD FAILURE
3. Running Tagged Tests With Maven
Maven Surefire passes two parameters to the JUnit Platform. The parameter groups lists the tags or the tag expression to include, and excludedGroups lists the tags to exclude. Surefire runs on the JUnit Platform when the project has the junit-jupiter dependency, so the JUnit 5 Maven dependencies are all we need.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
3.1. From the Command Line
On the command line, we set the two parameters as system properties. Quotes are needed when the expression has spaces, !, & or |, because the shell treats those characters as its own operators.
mvn test -Dgroups=fast # 3 tests
mvn test -DexcludedGroups=slow # 5 tests
mvn test -Dgroups="recipes & !slow" # 3 tests
mvn test -Dgroups="fast | integration" -DexcludedGroups=slow # 4 tests
mvn test -Dgroups="none()" # 1 test
The property name is excludedGroups, with a “d”. Surefire ignores an unknown property such as -DexcludeGroups=slow, so all 7 tests run, including the slow ones, and nothing in the output points to the typo.
3.2. In pom.xml With Profiles
When a filter is the default for the project, we can put it in the plugin configuration, e.g. an excludedGroups element with the value slow. But a value in the plugin configuration wins over the command line. With that configuration, mvn test -DexcludedGroups=integration still excludes slow and runs 5 tests, not 4.
Maven profiles avoid that problem. A profile sets the groups or excludedGroups property, and the command line can still override it. For example, mvn test -Pquick runs the 3 fast tests, and mvn test -Pci runs everything except the slow tests.
<profiles>
<profile>
<id>quick</id>
<properties>
<groups>fast</groups>
</properties>
</profile>
<profile>
<id>ci</id>
<properties>
<excludedGroups>slow</excludedGroups>
</properties>
</profile>
</profiles>
For integration tests that run in the integration-test phase, the Maven Failsafe plugin accepts the same groups and excludedGroups parameters.
4. Running Tagged Tests With Gradle
Gradle sets tag filters inside useJUnitPlatform(). The methods includeTags and excludeTags accept tag names and tag expressions. Gradle has no command-line option for tags, so we read a project property when we want to pass the filter at run time.
dependencies {
testImplementation platform('org.junit:junit-bom:6.1.3')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test', Test) {
useJUnitPlatform {
if (project.hasProperty('tags')) {
includeTags project.property('tags')
} else {
excludeTags 'slow'
}
}
}
tasks.register('fastTest', Test) {
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'fast'
}
}
The test task skips the slow tests by default, and the extra fastTest task runs only the fast tests. With -Ptags, we pass any expression.
gradle test # 5 tests, slow ones excluded
gradle fastTest # 3 tests
gradle test -Ptags='recipes & !slow' # 3 tests
RecipeScalerTest > rejectsZeroServings() PASSED
RecipeScalerTest > doublesServings() PASSED
RecipeSearchTest > findsByIngredient() PASSED
BUILD SUCCESSFUL in 14s
For the full Gradle setup, see JUnit 5 with Gradle.
5. Tag Filters in a Test Suite
A test suite is a class annotated with @Suite that selects tests by package or class and runs them as one group. The annotations @IncludeTags and @ExcludeTags add a tag filter to the suite, so the filter is part of the code and needs no build configuration. Suites need the junit-platform-suite dependency.
The suite FastSuite selects every test in the package com.howtodoinjava.tags that has the tag fast.
@Suite
@SelectPackages("com.howtodoinjava.tags")
@IncludeTags("fast")
class FastSuite {
}
A suite can use @IncludeTags and @ExcludeTags together. NightlySuite runs the integration tests but leaves out the slow ones, so it selects only findsByIngredient(). The test exportsAsText() also has the integration tag, but its slow tag excludes it.
@Suite
@SelectPackages("com.howtodoinjava.tags")
@IncludeTags("integration")
@ExcludeTags("slow")
class NightlySuite {
}
[INFO] Running com.howtodoinjava.tags.suites.NightlySuite
[INFO] Running com.howtodoinjava.tags.RecipeSearchTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.602 s -- in com.howtodoinjava.tags.RecipeSearchTest
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.000 s -- in com.howtodoinjava.tags.suites.NightlySuite
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
The suite class names do not end with Test, so Surefire’s default includes skip them in a plain mvn test, and the tests do not run twice. Both annotations also accept tag expressions and arrays, e.g. @IncludeTags({“fast”, “integration”}).
6. Running Tagged Tests in IntelliJ IDEA
IntelliJ IDEA runs tagged tests through a JUnit run configuration. In Run > Edit Configurations, we add a JUnit configuration, choose Tags in the test kind list, and type a tag or a tag expression such as production or recipes & !slow. We save one configuration per environment, e.g. “Tags (local)” and “Tags (production)”, and pick it from the run menu.

7. JUnit 5 Tag FAQs
7.1. What Is the Difference Between @Tag and JUnit 4 @Category?
Both label tests for filtering, but @Tag takes a string, whereas JUnit 4 @Category takes marker interfaces. When JUnit 4 tests run on the JUnit Platform through the Vintage engine, the engine turns each @Category into a tag with the fully qualified interface name, so the same groups filter selects both kinds of tests. The JUnit 5 vs JUnit 4 comparison lists the other annotation changes.
7.2. Does @Tag Disable a Test?
No. A test with a tag runs in every run that has no tag filter. To switch a test off for everybody, we use @Disabled, and to skip it on some machines, we use an annotation such as @EnabledIfEnvironmentVariable from conditional test execution.
7.3. Do @Nested Classes and Subclasses Inherit Tags?
Yes. A test in a @Nested inner class has the tags of the outer class, and a subclass gets the tags of its superclass, because @Tag is annotated with @Inherited. With @Tag(“outer”) on the outer class, -Dgroups=outer also runs the tests of the nested class.
8. Conclusion
The @Tag annotation labels a test class or a test method with a string, and method tags add to the class tags. Tags never change a test on their own. A filter in Maven, Gradle, a suite or the IDE decides which tagged tests run, and a test that does not match is left out of the run instead of being reported as skipped.
Tag expressions with !, &, |, any() and none() select precise subsets of the tests. In Maven we pass them with -Dgroups and -DexcludedGroups, and we prefer profiles over a hard-coded plugin configuration so the command line can still override the filter. A composed annotation such as @FastTest keeps the tag names consistent across the codebase.
9. References
- JUnit User Guide 6.1.3: Tagging and Filtering
- JUnit User Guide 6.1.3: Tags and Tag Expressions
- Tag Javadoc (JUnit 6.1.3)
- JUnit Platform Suite Engine
- Maven Surefire: surefire:test parameters
- Gradle: Testing in Java Projects
Happy Learning !!
How to run a test on the maven?
mvn test -Dtest=testClass