Appearance
JUnit 5: Test Suites in Depth | Test Suite vs Tags
A Real World Problem: Running the Right Tests at the Right Time
Imagine a fintech platform with 5,000 unit tests covering bank transfers, card payments, wallet transactions, user registration, compliance checks, and fraud detection. Every time a developer raises a pull request, running all 5,000 tests takes seven minutes on the CI server and produces a report so long that failures are hard to spot.
Engineering teams solve this in two common ways. First, they identify the tests that are absolutely critical from a business perspective — often called the P0 group — and run only those on every PR. Second, for a targeted hotfix in the bank transfer module, they run only the bank related test group, skipping everything else. Suites make both of these patterns possible by grouping test classes into named, executable units.
What Is a Test Suite?
A test suite is a collection of related test classes grouped so they can be executed as a single unit. You create as many suites as needed — one per feature, one per business domain, one per criticality tier.
Unlike tags (which are annotations scattered across many test methods), a test suite is a dedicated Java class that declares exactly which test classes or packages it covers. This makes it readable and self-documenting: looking at the BankSuite class tells you immediately which tests belong to the bank group.
How Suites Work Internally
Test suite logic is driven by the JUnit Platform Launcher. From the architecture chapter you will recall that the platform launcher is responsible for discovery and execution. Suites tap into this at two logical phases:
- Discovery — identify which test classes and methods are candidates for inclusion.
- Filtering — from the discovered set, discard any tests that do not match the specified filters.
These phases always run in order: discovery first, then filtering. Filtering is optional — a suite with only selectors but no filters runs everything it discovers.
Required Dependency
The platform launcher is already on the classpath via spring-boot-starter-test. However, suite-specific annotations (@Suite, @SelectPackages, etc.) come from a separate dependency:
xml
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<scope>test</scope>
</dependency>Without this, the platform launcher does not know how to interpret suite annotations.
Phase 1: Discovery — Finding the Tests
Discovery tells the suite where to look for test classes and methods. There are four selector annotations.
@SelectPackages — Package Level (Broadest)
java
import org.junit.platform.suite.api.SelectPackages;
import org.junit.platform.suite.api.Suite;
/**
* Discovers all test classes and methods in these packages
* and their sub-packages, recursively.
*/
@Suite
@SelectPackages({
"com.example.payment.bank",
"com.example.payment.card",
"com.example.payment.wallet"
})
public class PaymentSuite { }@SelectPackages is the broadest selector. It recursively scans sub-packages and picks up every @Test method it finds. This is convenient but carries a risk: if the suite class itself lives inside one of the scanned packages, it will discover itself during the scan, leading to duplicate test runs (discussed later).
@SelectClasses — Class Level (More Granular)
java
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
/**
* Discovers all test methods inside the listed classes specifically.
* No recursive package scan — precise and predictable.
*/
@Suite
@SelectClasses({
BankTransferTest.class,
BankRefundTest.class,
BankStatementTest.class
})
public class BankSuite { }@SelectClasses is safer than @SelectPackages for avoiding accidental inclusion of unrelated tests. You explicitly name the classes.
@SelectMethod and @SelectMethods — Method Level (Most Granular)
java
import org.junit.platform.suite.api.SelectMethod;
import org.junit.platform.suite.api.SelectMethods;
import org.junit.platform.suite.api.Suite;
@Suite
@SelectMethod(type = BankTransferTest.class, name = "testDomesticTransfer")
@SelectMethods({
@SelectMethod(type = BankTransferTest.class, name = "testInternationalTransfer"),
@SelectMethod(type = BankRefundTest.class, name = "testFullRefund")
})
public class BankCriticalMethodSuite { }Important limitation: @SelectMethod cannot discover parameterized tests. Internally, it searches for a method with the exact name that takes no parameters. A @ParameterizedTest method has a different resolved signature. For parameterized tests, use @SelectClasses or @SelectPackages.
Combining Multiple Selectors
A single suite class may declare multiple selectors. The platform launcher deduplicates test methods discovered through multiple paths, so a method found via @SelectPackages and again via @SelectClasses typically runs only once (with an important exception described later):
java
@Suite
@SelectPackages("com.example.payment.bank")
@SelectClasses({ExtraPaymentTest.class})
public class CombinedBankSuite { }Phase 2: Filtering — Refining the Discovered Set
After discovery, you can narrow the set using filter annotations. All inclusion filters run first (forming an intersection with the discovered set), then exclusion filters run (removing from what inclusion kept).
Include and Exclude Packages
java
@Suite
@SelectPackages("com.example")
@IncludePackages({"com.example.payment.bank", "com.example.payment.card"})
@ExcludePackages({"com.example.payment.bank.legacy"})
public class FilteredPaymentSuite { }@IncludePackages keeps only the listed packages from the discovery result. @ExcludePackages removes the listed packages. If both are present, inclusion runs first, then exclusion applies to the result.
Include and Exclude Tags
java
@Suite
@SelectPackages("com.example.payment")
@IncludeTags({"bank", "card"})
@ExcludeTags("slow")
public class FastPaymentSuite { }This discovers everything under com.example.payment, keeps only tests tagged bank or card, then removes any tagged slow. The result is fast bank and card tests.
Include and Exclude Class Name Patterns (Regex)
java
@Suite
@SelectPackages("com.example")
@IncludeClassNamePatterns(".*Test$")
@ExcludeClassNamePatterns(".*LegacyTest$")
public class ModernTestSuite { }These annotations use Java regular expression syntax. Common patterns:
.*Test$— class name ends with "Test".*Payment.*— class name contains "Payment"(?i).*test$— case insensitive match ending with "test"
Include and Exclude Engines
java
@Suite
@SelectPackages("com.example")
@IncludeEngines("junit-jupiter") // only JUnit 5 tests
@ExcludeEngines("junit-vintage") // no JUnit 3/4 tests
public class JUnit5OnlySuite { }The platform launcher coordinates multiple engines (Jupiter for JUnit 5, Vintage for JUnit 3 and 4). Engine filters let you restrict which engine processes the discovered tests — useful when migrating a codebase from JUnit 4 to JUnit 5 and you want suites that target only the modern tests.
Running a Suite
From IntelliJ IDEA
right click the suite class and select Run. IntelliJ treats the suite class as a runnable unit.
From Maven (CI Pipeline)
bash
# Run only the BankSuite class
mvn test -Dtest=BankSuite
# Run both BankSuite and CardSuite
mvn test -Dtest=BankSuite,CardSuiteIn a CI environment, a dedicated job per suite is common:
yaml
# CI pipeline example (pseudo-YAML)
jobs:
bank-suite:
command: mvn test -Dtest=BankSuite
card-suite:
command: mvn test -Dtest=CardSuite
p0-suite:
command: mvn test -Dtest=P0CriticalSuiteFull Production Example: P0 Critical Suite
java
import org.junit.platform.suite.api.*;
/**
* P0 suite: runs on every pull request.
* Covers business-critical paths only — fast, no slow integration tests.
*
* Discovery: all test classes in these two packages.
* Filter 1: keep only tests tagged "critical" or "smoke".
* Filter 2: remove tests tagged "slow".
*/
@Suite
@SelectPackages({
"com.example.payment.bank",
"com.example.payment.card"
})
@IncludeTags({"critical", "smoke"})
@ExcludeTags("slow")
@IncludeClassNamePatterns(".*Test$")
public class P0CriticalSuite { }java
import org.junit.platform.suite.api.*;
/**
* Bank feature suite: used for targeted hotfix validation.
* Run this when only bank module code has changed.
*/
@Suite
@SelectClasses({
BankTransferTest.class,
BankRefundTest.class,
BankSettlementTest.class,
BankStatementTest.class
})
public class BankSuite { }The Duplicate Test Trap
The most common production pitfall with suites is duplicate test execution. It occurs when a suite class is discovered as part of its own selector scan.
Scenario: Your suite class BankSuite lives in the package com.example.payment. Your selector is @SelectPackages("com.example.payment"). During discovery, JUnit finds all classes in that package — including BankSuite itself. Running BankSuite then triggers another discovery, running the tests inside again. Result: tests execute twice.
Solution: Keep all suite classes in a dedicated package that is not a sub package of any selector:
src/test/java/
com/example/payment/bank/ ← test classes here
com/example/payment/card/ ← test classes here
com/example/suites/ ← suite classes here (outside selectors)
BankSuite.java
CardSuite.java
P0CriticalSuite.javaWith this structure, @SelectPackages("com.example.payment") never scans com.example.suites, so suite classes are never discovered as part of themselves.
Test Suites vs Tags and Filtering: When to Use Which
Both mechanisms group tests. Here is a detailed comparison:
| Dimension | Tags and Filtering | Test Suites |
|---|---|---|
| Grouping mechanism | @Tag annotation on method / class | @Suite class with selectors |
| Runtime flexibility | High — expression supplied at runtime | Low — selectors and filters are hardcoded |
| Code changes needed to change groups | No | Yes (edit the suite class) |
| Risk of duplicate test runs | None | Present if suite is inside a scanned package |
| Readability | Distributed (tags on every method) | Centralised (one class per group) |
| Maintenance over time | Low | Higher — suite classes accumulate |
| Industry preference (CI) | Modern, preferred | Still widely used, especially in legacy codebases |
When to choose tags: when you need CI pipelines that switch test groups dynamically without code changes. Most modern teams building on Spring Boot or Micronaut favour this approach.
When to choose suites: when readability and explicit declaration matter, such as when onboarding new engineers or maintaining long lived regulated applications where every test group must be auditable.
Neither approach is definitively better. Many organisations use both: suites for documentation and static grouping, tags for dynamic CI filtering.
Interview Questions & Pitfalls
Q1. What is a JUnit 5 test suite and how does it differ from running all tests in a package?
A test suite is a dedicated Java class annotated with @Suite that explicitly declares which packages, classes, or methods to include, plus optional filters. Running all tests in a package by default runs everything with no control. A suite gives you precise, repeatable groupings with filtering capabilities, independent of directory structure.
Q2. What are the two logical phases of test suite execution?
Discovery and filtering. Discovery identifies all candidate test classes and methods using selectors (@SelectPackages, @SelectClasses, @SelectMethod). Filtering narrows that set using include and exclude annotations. Filtering is optional — a suite with only selectors runs everything it discovers.
Q3. Why might a test run twice when using @SelectPackages in a suite?
If the suite class itself lives inside the package specified in @SelectPackages, the scanner discovers the suite class, which triggers a second round of discovery and execution. The solution is to place all suite classes in a separate package outside the scanned hierarchy.
Q4. Can @SelectMethod discover a @ParameterizedTest method?
No. @SelectMethod resolves methods by exact name, expecting a no-argument signature. A parameterized test has a different internal signature. Use @SelectClasses or @SelectPackages to include parameterized tests in a suite.
Q5. What extra Maven dependency is needed for JUnit 5 test suite annotations?
junit-platform-suite (group org.junit.platform). The platform launcher is already present via spring-boot-starter-test, but it does not include suite annotation support without this extra dependency.
Q6. In what order do inclusion and exclusion filters run within a suite?
All inclusion filters run first, forming an intersection with the discovered set. Exclusion filters then remove matching tests from the inclusion result. This means a test must first pass all inclusion criteria before exclusion criteria are evaluated against it.
Q7. What is the main practical advantage of tags over suites for a CI pipeline?
Tags allow the grouping expression to be supplied at runtime (e.g., -Dgroups="bank & !slow") without any code change. Suites require editing a Java class and committing the change to alter which tests run. In fast-moving pipelines where different jobs target different test groups, tags are far less maintenance intensive.