JUnit · lesson 5 of 7
JUnit Tags, Filtering, Ordering and Suites
Run the right JUnit 6 tests from Maven: @Tag with Surefire groups and tag expressions, method ordering, junit-platform.properties and @Suite classes.
By SkillByExample · Published
A real project has fast unit tests, slow ones, and ones that need a database. Running all of them
on every save is slow, and running the wrong subset in CI misses bugs. This lesson labels tests
with tags, picks subsets from the Maven command line, controls the order tests run in, and groups
them into suites. It uses the project and mvn alias from
Your First JUnit 6 Test with Maven:
alias mvn='docker run --rm -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble mvn'
Tagging tests
Three test classes; each test prints its name so the runs below show exactly what ran.
// File: src/test/java/PriceTest.java
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("fast")
class PriceTest {
@Test void addsTax() { System.out.println("ran PriceTest.addsTax"); }
@Test void roundsPennies() { System.out.println("ran PriceTest.roundsPennies"); }
}
// File: src/test/java/RepositoryTest.java
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("db")
class RepositoryTest {
@Test @Tag("fast") void mapsRow() { System.out.println("ran RepositoryTest.mapsRow"); }
@Test @Tag("slow") void savesOrder() { System.out.println("ran RepositoryTest.savesOrder"); }
}
// File: src/test/java/ExportTest.java
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
class ExportTest {
@Test @Tag("slow") void writesPdf() { System.out.println("ran ExportTest.writesPdf"); }
@Test void namesFile() { System.out.println("ran ExportTest.namesFile"); }
}
$ mvn -B -ntp test | grep -E "^ran|Tests run: .*Skipped: [0-9]+$" | sort
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
ran ExportTest.namesFile
ran ExportTest.writesPdf
ran PriceTest.addsTax
ran PriceTest.roundsPennies
ran RepositoryTest.mapsRow
ran RepositoryTest.savesOrder
$ mvn -B -ntp test -Dgroups=fast | grep -E "^ran|Tests run: .*Skipped: [0-9]+$" | sort
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
ran PriceTest.addsTax
ran PriceTest.roundsPennies
ran RepositoryTest.mapsRow
$ mvn -B -ntp test -DexcludedGroups=slow | grep -E "^ran|Tests run: .*Skipped: [0-9]+$" | sort
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
ran ExportTest.namesFile
ran PriceTest.addsTax
ran PriceTest.roundsPennies
ran RepositoryTest.mapsRow
$ mvn -B -ntp test -Dgroups='fast & !db' | grep -E "^ran|Tests run: .*Skipped: [0-9]+$" | sort
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
ran PriceTest.addsTax
ran PriceTest.roundsPennies
A tag on a class applies to every test in it, and a method can add more: RepositoryTest.mapsRow
carries both db and fast. Surefire hands two properties to the JUnit Platform:
-Dgroups=fastruns only tests with that tag: 3 of the 6.-DexcludedGroups=slowruns everything except that tag: 4, including the untaggedExportTest.namesFile.-Dgroups='fast & !db'is a tag expression, with&,|,!and parentheses. It droppedmapsRow, which is fast but alsodb. Quote it so the shell leaves&and!alone.
Tests not selected are not run and not counted, unlike the Skipped tests in the
conditions lesson. The sort keeps the ran lines in
a stable order for this page, because Surefire doesn’t promise an order for test classes. To make a
filter permanent, put <groups> or <excludedGroups> in Surefire’s <configuration>; the
command-line property overrides it for one run.
Ordering methods with @Order
A test class that walks through one flow, step by step.
// File: src/test/java/CheckoutFlowTest.java
import org.junit.jupiter.api.*;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CheckoutFlowTest {
@Test @Order(1) void createCart() { System.out.println("1 createCart"); }
@Test @Order(2) void addItem() { System.out.println("2 addItem"); }
@Test @Order(3) void pay() { System.out.println("3 pay"); }
@Test void receipt() { System.out.println("? receipt"); }
}
$ mvn -B -ntp test -Dtest=CheckoutFlowTest | grep -E "^[0-9?] "
1 createCart
2 addItem
3 pay
? receipt
MethodOrderer.OrderAnnotation sorts by @Order value, lowest first. receipt has no @Order,
so it gets the default value and runs after every annotated method. The other built-in orderers are
MethodName, DisplayName and Random.
Ordered tests that pass state to each other (the cart from step 1 used in step 2) are a smell: one failure cascades and no step can run alone. Ordering earns its place for slow integration flows and for readable reports, not as a way to share data.
A project-wide default in junit-platform.properties
Without an annotation, JUnit picks its default order. ExportTest from above has none:
$ mvn -B -ntp test -Dtest=ExportTest | grep "^ran"
ran ExportTest.writesPdf
ran ExportTest.namesFile
writesPdf ran before namesFile. The default isn’t alphabetical: JUnit documents it as
deterministic but intentionally non-obvious, so it is the same from run to run but not something
to rely on. A
junit-platform.properties file on the test class path sets configuration parameters for the
whole project:
# File: src/test/resources/junit-platform.properties
junit.jupiter.testmethod.order.default = org.junit.jupiter.api.MethodOrderer$MethodName
$ mvn -B -ntp test -Dtest=ExportTest | grep "^ran"
ran ExportTest.namesFile
ran ExportTest.writesPdf
Now every class without its own @TestMethodOrder runs in method-name order. The same file takes
other settings shown in later lessons, such as parallel execution and default timeouts, and
junit.jupiter.testclass.order.default orders classes the same way. A -D property with the
same key on the Maven command line overrides the file.
Suites: a named selection
A suite is a class that selects tests by class, package or tag, so a named selection can be
run the same way everywhere. Suites need the junit-platform-suite module (its version comes
from the BOM):
<!-- pom.xml: inside <dependencies> -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<scope>test</scope>
</dependency>
// File: src/test/java/FastSuite.java
import org.junit.platform.suite.api.IncludeTags;
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
@Suite
@SelectClasses({PriceTest.class, RepositoryTest.class, ExportTest.class})
@IncludeTags("fast")
class FastSuite {
}
$ mvn -B -ntp test -Dtest=FastSuite | grep -E "^ran|Tests run: .*Skipped: [0-9]+$"
ran PriceTest.addsTax
ran PriceTest.roundsPennies
ran RepositoryTest.mapsRow
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
$ mvn -B -ntp test | grep -E "Tests run:.*-- in" | sed -E 's/.*Tests run: ([0-9]+),.*-- in (.*)/\2: \1 tests/' | sort
CheckoutFlowTest: 4 tests
ExportTest: 2 tests
PriceTest: 2 tests
RepositoryTest: 2 tests
FastSuite selected three classes and kept only their fast tests, the same 3 tests that
-Dgroups=fast ran, but now the selection is code: it has a name, lives in version control and
runs the same from an IDE. Other selectors include @SelectPackages, @SelectClasspathResource
and @IncludeClassNamePatterns; @ExcludeTags is the opposite of @IncludeTags.
The plain mvn test ran four classes, 10 tests (the 6 tagged tests plus CheckoutFlowTest’s 4;
the sed boils each Surefire class line down to its name and count), and FastSuite isn’t among
them. Surefire only picks up classes named
*Test, Test*, *Tests or *TestCase by default, and FastSuite matches none of them. That’s
deliberate: if it were called FastSuiteTest, a plain run would execute those three tests twice,
once directly and once through the suite. Name suites *Suite and run them with -Dtest.
Practice
- Run only the database tests that are not slow, with one tag expression.
Solution
$ mvn -B -ntp test -Dgroups='db & !slow' | grep -E "^ran|Tests run: .*Skipped: [0-9]+$"
ran RepositoryTest.mapsRow
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Only RepositoryTest.mapsRow has db (from its class) without slow.
- Write a
SlowSuitethat runs everyslowtest from the three tagged classes.
Solution
// File: src/test/java/SlowSuite.java
import org.junit.platform.suite.api.IncludeTags;
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
@Suite
@SelectClasses({PriceTest.class, RepositoryTest.class, ExportTest.class})
@IncludeTags("slow")
class SlowSuite {
}
$ mvn -B -ntp test -Dtest=SlowSuite | grep -E "^ran|Tests run: .*Skipped: [0-9]+$" | sort
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
ran ExportTest.writesPdf
ran RepositoryTest.savesOrder
- Order a class by display name instead of
@Order, so the report reads as numbered steps.
Solution
// File: src/test/java/SignupFlowTest.java
import org.junit.jupiter.api.*;
@TestMethodOrder(MethodOrderer.DisplayName.class)
class SignupFlowTest {
@Test @DisplayName("3. confirm email") void confirm() { System.out.println("3 confirm"); }
@Test @DisplayName("1. enter details") void enter() { System.out.println("1 enter"); }
@Test @DisplayName("2. accept terms") void accept() { System.out.println("2 accept"); }
}
$ mvn -B -ntp test -Dtest=SignupFlowTest | grep -E "^[0-9] "
1 enter
2 accept
3 confirm
MethodOrderer.DisplayName sorts by the display name string, so the 1., 2., 3. prefixes
decide the order, whatever the method names are.