Open a JUnit report with two hundred tests in it and you will see things like
shouldReturn401WhenTokenExpired() sitting next to
test3() sitting next to a nested class called Inner2Test. Nobody wrote it
that way on purpose — it accretes, one quick test at a time, until the report is a worse
explanation of the system than the code itself.
JUnit gives you three annotations that fix this without touching how the tests run:
@DisplayName to say what a test actually checks, @Nested to group tests
the way the feature itself is structured, and @Tag to run a slice of the suite instead
of all of it. This post covers all three — including the part most guides skip,
generating display names instead of writing them by hand — and every claim
here was compiled, run, and captured from a real Maven module, not paraphrased from the user
guide.
It also catches the suite up on what actually changed between JUnit 5 and JUnit 6 for exactly these three annotations — no more and no less, since the full migration already has two posts on this site covering it end to end.
Versions used in this post. JUnit Jupiter / Platform 6.1.3, the current GA release — verified againstmaven-metadata.xmlon Maven Central (lastUpdated 20260807), not against a blog post or an LLM’s memory. JUnit 6.0.0 itself reached GA on 2025-09-30; JUnit 5.14.4 is the final release of the 5.x line. Built and run with JDK 25 (Temurin, the current LTS); the module’s ownmaven.compiler.releaseis 17, JUnit 6’s actual floor. Full migration story: JUnit 6 Deep Dive and 15 Common Problems During the JUnit 5 to JUnit 6 Upgrade.
Naming a test for the person reading the report, not the compiler
By default a test’s name in any report — your IDE, Surefire’s console output, a CI
dashboard — is just its method name. @DisplayName replaces that with a literal
string you write, and it is the smallest change in this whole post: one annotation, one argument,
no behaviour change to the test itself.
With no @DisplayName and no generator, the report shows the raw method name,
camelCase and all — the top row above. Add @DisplayName("...") to one method and
only that method’s report line changes; nothing else about how or when the test runs is affected.
The third row, a generator applied to the whole class, is where this post is actually going, and
it is covered next.
Two short code examples from this module’s OrderPricingNestedTest class show the
pattern — a class-level name and a method-level name, both literal strings:
@DisplayName("Order pricing")
class OrderPricingNestedTest {
@Test
@DisplayName("an order with no items has a zero total")
void emptyOrderHasZeroTotal() {
assertEquals(0, new Order().getTotal());
}
// ...
}
Source: OrderPricingNestedTest.java. Run through the console-standalone launcher with --details=tree, that class’s real report looks like this — the labels are the @DisplayName strings, not the method names:
│ └─ Order pricing ✔
│ ├─ an order with no items has a zero total ✔
│ └─ when a discount code is applied ✔
│ ├─ the total reflects the discount ✔
Output: 03-nested-lifecycle-tree.txt (full tree, six tests across three nesting levels — the nesting itself is the next section’s subject).
If you’re writing @ParameterizedTest methods, @DisplayName also accepts
placeholders — {0}, {index}, named placeholders from
@CsvSource headers — so each invocation gets its own report line instead of
method[1], method[2]. That is a parameterized-tests feature in its own
right; Parameterized Tests in JUnit 6
covers every source type and the full placeholder syntax.
- Going deeper: the exact character handling for
@DisplayNamestrings (leading/trailing whitespace, blank names) is in the JUnit 6 user guide’s display-names section (official docs, not executed here). - If every method in a class needs a name and hand-writing them is the bottleneck, skip to the next section — that is what generators are for.
Naming fifty tests at once: DisplayNameGenerator
Writing @DisplayName on every method works for a handful of tests. It stops working
somewhere past the twentieth — not because the annotation is wrong, but because nobody keeps
fifty hand-written strings in sync with the methods they describe. DisplayNameGenerator
is the other half of the feature: instead of a string, you give JUnit a strategy for
computing the name from the method itself, applied once at the class level with
@DisplayNameGeneration.
public interface DisplayNameGenerator {
String generateDisplayNameForClass(Class<?> testClass);
String generateDisplayNameForNestedClass(List<Class<?>> enclosingInstanceTypes, Class<?> nestedClass);
String generateDisplayNameForMethod(List<Class<?>> enclosingInstanceTypes, Class<?> testClass, Method testMethod);
}
That is the interface’s real shape in 6.1.3, read with javap directly off the
junit-jupiter-api-6.1.3.jar rather than copied from prose — captured in
10-displaynamegenerator-javap.txt — which matters here, because the interface quietly changed underneath a lot of existing blog posts and even some AI-generated code:
The overloads you’ll find in older examples are deprecated, not removed.javap -vonDisplayNameGenerator.classshowsgenerateDisplayNameForNestedClass(Class<?>)andgenerateDisplayNameForMethod(Class<?>, Method)carrying@Deprecated(since = "5.12"). They still compile and the engine still calls them — but only as a fallback. A generator that overrides only the old single-Classsignatures silently never runs for@Nestedclasses or for methods with enclosing context, because the engine resolves the new three-argument overloads first and the old ones are now the ones nobody implements by accident. Override the current signatures shown above, not the ones Stack Overflow answers from a few years ago use.
Here is a generator that turns camelCaseMethodNames into sentences, implementing the
current overloads:
public class SentenceCaseDisplayNameGenerator implements DisplayNameGenerator {
private static final Pattern CAMEL_BOUNDARY = Pattern.compile("(?<=[a-z0-9])(?=[A-Z])");
@Override
public String generateDisplayNameForClass(Class<?> testClass) {
return toSentence(testClass.getSimpleName());
}
@Override
public String generateDisplayNameForNestedClass(List<Class<?>> enclosingInstanceTypes, Class<?> nestedClass) {
String indent = " ".repeat(enclosingInstanceTypes.size());
return indent + toSentence(nestedClass.getSimpleName());
}
@Override
public String generateDisplayNameForMethod(List<Class<?>> enclosingInstanceTypes, Class<?> testClass, Method testMethod) {
String indent = " ".repeat(enclosingInstanceTypes.size() + 1);
return indent + "→ " + toSentence(testMethod.getName());
}
// toSentence(): splits on the regex above, lowercases, capitalises the first word
}
Source: SentenceCaseDisplayNameGenerator.java. Applied to a class with @DisplayNameGeneration(SentenceCaseDisplayNameGenerator.class) and zero manual @DisplayName annotations — except one, deliberately — the real console-standalone output is:
│ ├─ an explicit @DisplayName always wins over the generator ✔
│ ├─ → Withdrawing more than the balance throws insufficient funds exception ✔
│ ├─ → Depositing apositive amount increases the balance ✔
│ └─ When the account is overdrawn ✔
│ ├─ → Further withdrawals are rejected ✔
│ └─ → A deposit that covers the overdraft clears it ✔
Source: CustomDisplayNameGeneratorTest.java; output: 01-custom-display-name-generator.txt.
That diagram is not illustrative — it is read directly off the real output above. The one
method carrying @DisplayName short-circuits at box 1 and renders exactly as written.
The other four have no annotation, so JUnit calls SentenceCaseDisplayNameGenerator,
and box 2’s output is genuinely what it produced — including the rough edge.
“Depositing apositive amount” is not a typo in this post. It is the real output ofdepositingAPositiveAmountIncreasesTheBalance()run through the generator above. The regex only splits at a lowercase-then-uppercase boundary, so the lone capitalAin...AmountAPositiveAmount...fuses onto the next word instead of standing alone as “a”. A hand-written example would never show this; a real run does. It is the kind of edge case that is obvious the moment you see real output and easy to miss if you never generate one.
Three built-in generators ship with JUnit and cover most cases without a custom class at all:
Standard (the default, unmodified names), ReplaceUnderscores (turns
snake_case_methods into spaced words — useful if your team already writes test
names that way), and IndicativeSentences, which concatenates the class’s and each
nested class’s @DisplayName into one running sentence via
@IndicativeSentencesGeneration(separator = " -> "). Run side by side against the same
Deque:
│ ├─ A stack ✔
│ │ └─ that already has one element ✔
│ │ └─ grows by one after a second push ✔
│ ├─ replace underscores generator ✔
│ │ ├─ a stack with one element reports isEmpty false ✔
│ │ └─ an empty stack reports isEmpty true ✔
│ └─ Standard (default) generator - unmodified method names ✔
│ └─ pushThenPopReturnsTheSameElement() ✔
Source: BuiltInGeneratorsComparisonTest.java; output: 02-builtin-generators-comparison.txt. Notice the IndicativeSentences row (“A stack” → “that already has one element” → “grows by one after a second push”) reads as one sentence when you follow the tree top to bottom — that is the generator’s entire point, and it only pays off once you have @Nested classes to assemble a sentence out of, which is the next section.
Reference: the full DisplayNameGenerator contract, current vs. deprecated (click to expand)
| Method (current, 5.12+ / 6.x) | Deprecated equivalent (pre-5.12) | Default static field |
|---|---|---|
generateDisplayNameForClass(Class<?>) | unchanged — always took just the class | — |
generateDisplayNameForNestedClass(List<Class<?>>, Class<?>) | generateDisplayNameForNestedClass(Class<?>) — @Deprecated(since="5.12") | — |
generateDisplayNameForMethod(List<Class<?>>, Class<?>, Method) | generateDisplayNameForMethod(Class<?>, Method) — @Deprecated(since="5.12") | — |
DisplayNameGenerator.Standard | — | the JVM-wide default |
DisplayNameGenerator.Simple | — | Standard, minus trailing () on no-arg methods |
DisplayNameGenerator.ReplaceUnderscores | — | extends Simple, swaps _ for a space |
DisplayNameGenerator.IndicativeSentences | — | reads @IndicativeSentencesGeneration |
The List<Class<?>> enclosingInstanceTypes parameter is what the deprecated overloads could not express: it is the chain of @Nested classes a method or nested class sits inside, outermost first. A generator can use its size to indent, number, or otherwise reflect depth — SentenceCaseDisplayNameGenerator above uses it for exactly that.
You can also set a generator JVM-wide via the junit.jupiter.displayname.generator.default configuration parameter instead of annotating every class, which is worth doing once a team agrees on a convention. The official DisplayNameGenerator Javadoc has the full configuration-key reference.
- Going deeper: setting a generator for an entire build via
junit-platform.propertiesrather than per class — see the reference panel above and the user guide’s generator configuration section. - The
IndicativeSentencesgenerator is the one place in this section where@Nestedand naming genuinely interact — worth revisiting once you’ve read the next section. - JUnit 6 Tutorial: Complete Series Index links the full annotation reference if you want every attribute, not just the three this post covers.
Grouping tests the way the feature is actually structured: @Nested
A flat test class forces every scenario into one @BeforeEach, or into method names
like pricing_whenDiscountApplied_andFreeShipping_total(). @Nested lets you
write that structure as actual nested classes instead — each one a non-static inner class with
its own @BeforeEach, which runs after the outer class’s. JUnit discovers and
runs every nested class as part of the outer class’s test plan automatically.
@DisplayName("Order pricing")
class OrderPricingNestedTest {
@BeforeEach
void outerSetUp() { /* always runs first */ }
@Nested
@DisplayName("when a discount code is applied")
class WhenADiscountCodeIsApplied {
@BeforeEach
void applyDiscount() { /* runs after outerSetUp() */ }
@Nested
@DisplayName("and the order also qualifies for free shipping")
class AndTheOrderAlsoQualifiesForFreeShipping {
@BeforeEach
void applyFreeShipping() { /* runs after applyDiscount() */ }
@Test
@DisplayName("lifecycle methods ran outer, then discount, then shipping, in that order")
void lifecycleRanOuterToInner() { /* ... */ }
}
}
}
Source: OrderPricingNestedTest.java. Three levels deep, and the deepest test’s own body is the proof the comments above are telling the truth — it asserts against a shared list that each @BeforeEach appends to:
assertEquals(
List.of("outer.beforeEach", "discount.beforeEach", "shipping.beforeEach"),
callOrder);
That assertion passing is the real evidence for the cascade, not a comment claiming it. The console tree for the whole class:
│ └─ Order pricing ✔
│ ├─ an order with no items has a zero total ✔
│ └─ when a discount code is applied ✔
│ ├─ the total reflects the discount ✔
│ └─ and the order also qualifies for free shipping ✔
│ ├─ shipping cost does not change the total ✔
│ └─ lifecycle methods ran outer, then discount, then shipping, in that order ✔
Output: 03-nested-lifecycle-tree.txt.
The three numbers on the diagram are the real callOrder values the assertion above
checks, in the order they run. This is the entire mechanism: nesting adds layers of setup and
teardown around the innermost test, outer always wrapping inner, never the reverse.
A@Nestedclass must be a non-static inner class. Mark itstaticand JUnit stops treating it as a nested test class at all — it becomes a plain nested type with no access to the outer instance, and its@Testmethods simply never run. There is no error; the tests just vanish from the report, which is a confusing first encounter if you don’t already know to look for a straystatic.
One practical constraint worth knowing before you reach for three levels of nesting: a
@Nested class’s own @BeforeAll/@AfterAll must be
static under the default per-method test lifecycle — to use a non-static,
instance-level @BeforeAll inside a @Nested class (useful for scenario-scoped
expensive setup), that nested class needs
@TestInstance(TestInstance.Lifecycle.PER_CLASS) on it. Structuring Tests with JUnit 6 Nested Tests covers this and every other nesting rule — depth limits, Spring context sharing across nested classes, tag inheritance — in full; this section only goes as deep as the lifecycle cascade itself.
Reference: every @Nested constraint in one place (click to expand)
- Nested classes must be non-static inner classes — see the callout above.
- They may declare their own
@BeforeEach/@AfterEach, which run after the outer class’s@BeforeEachand before its@AfterEach, cascading through every nesting level. @BeforeAll/@AfterAllin a nested class must bestaticunless that nested class carries@TestInstance(PER_CLASS).- Nesting depth is unlimited; in practice two to three levels is where most teams stop before the cascading setup becomes hard to trace by eye.
@Tagon an outer class is inherited by every test in every nested class beneath it — covered in the next section.@TestMethodOrderon an outer class is inherited by its@Nestedclasses as of JUnit 6, so you no longer repeat the annotation on every level to get a consistent ordering strategy.
- Going deeper: the full worked example with a REST controller’s nested endpoint groups is in Structuring Tests with JUnit 6 Nested Tests.
- What JUnit 6 actually changed about how nested classes are ordered — not how they cascade, which is unchanged — is next.
Why the nested-class order used to be a source of flaky-looking reports
Here is the change in this group of annotations that actually breaks something, rather than just
adding a capability: before JUnit 6, the order JUnit visited @Nested classes in was not
guaranteed. It came from whatever order the JVM’s reflection APIs happened to return declared
classes in, which could differ between JDK versions and even between runs on the same machine. A
report that listed scenarios in one order on your laptop and a different order in CI was not a bug
in your code — it was JUnit 5 behaving exactly as documented, just not as anyone expected.
We ran this module’s three-level OrderPricingNestedTest through the console launcher
three separate times and diffed the trees, ignoring only the elapsed-time line:
$ for i in 1 2 3; do java -jar junit-platform-console-standalone-6.1.3.jar execute \
--details=tree --select-class ...OrderPricingNestedTest > run_$i.txt; done
$ diff <(grep -v "Test run finished" run_1.txt) <(grep -v "Test run finished" run_3.txt)
(no output from diff = the tree, including order, is byte-identical across all 3 runs)
Only the elapsed-time line differs between runs:
run 1: Test run finished after 120 ms
run 2: Test run finished after 171 ms
run 3: Test run finished after 101 ms
Output: 04-nested-ordering-determinism.txt. An empty diff is the proof: the tree — order included — was byte-for-byte identical three times in a row, and the only line that changed anywhere was the timer.
This is a real behaviour change, not just a new feature. JUnit 6 introducesClassOrderer.Defaultand makes it the default orderer for@Nestedclasses, so the order above is now guaranteed rather than incidental. If you were relying on the old non-determinism to catch order-dependent test bugs (tests that only failed when they happened to run in a particular sequence), that safety net is quieter now — the order is fixed, so an order-dependent bug reproduces the same way every time instead of occasionally.@TestMethodOrderon the outer class is also now inherited by nested classes automatically, so one annotation covers the whole hierarchy.
- Going deeper: this is item 7 in JUnit 6 Deep Dive‘s change table, and item 10 in 15 Common Problems During the JUnit 5 to JUnit 6 Upgrade — both are worth reading if this is the only change in the whole migration that affects you.
MethodOrderer.Alphanumericwas removed outright in JUnit 6 (not deprecated-then-removed, just gone) — if your build still references it, that is a compile error, not a runtime surprise, andMethodOrderer.OrderAnnotationorMethodOrderer.MethodNameare the replacements.
Running a slice of the suite: @Tag
The third annotation does not touch the report at all — it touches which tests run.
@Tag("name") on a class or method labels it; -Dgroups and
-DexcludedGroups (Maven) or includeTags/excludeTags (Gradle)
then select a subset at build time. Tags stack: a class-level tag applies to every method in it,
and a method can add more of its own.
@Tag("fast")
@Tag("unit")
class ValidationChecksTest {
@Test
void emailWithoutAtIsRejected() { /* ... */ } // tagged fast, unit
@Test
@Tag("slow") // ADDS to the class-level tags
void validatesLargeVolumeCorrectly() { /* ... */ } // tagged fast, unit, slow
@FastUnitCheck // composed annotation, see below
void blankEmailIsRejected() { /* ... */ } // tagged fast, unit
}
Source: ValidationChecksTest.java. A second class in the same module carries @Tag("slow") and @Tag("integration") at the class level, including on a @Nested class inside it that inherits both tags without repeating them — OrderRepositoryIntegrationTest.java. Three real Maven runs against the same module, filtered three different ways:
$ mvn test -Dgroups="fast"
...
[INFO] Running com.ankurm.tutorials.junit.displaynestedtag.ValidationChecksTest
[WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.102 s -- in com.ankurm.tutorials.junit.displaynestedtag.ValidationChecksTest
...
[WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 1
Output: 05-tag-filter-fast.txt. Out of six test classes in the module, exactly one — the one carrying @Tag("fast") — ran. Excluding by tag instead of including gives the opposite shape:
$ mvn test -DexcludedGroups="slow"
...
[WARNING] Tests run: 17, Failures: 0, Errors: 0, Skipped: 1
Output: 06-tag-filter-exclude-slow.txt (full log: every class that ran, and OrderRepositoryIntegrationTest conspicuously absent). 17 instead of 20 — OrderRepositoryIntegrationTest drops out entirely (class-level slow), and ValidationChecksTest drops from 5 tests to 4 (only its one method-level slow test is excluded, the rest of the class still runs). Tag expressions combine both directions in one filter:
$ mvn test -Dgroups="unit & !slow"
...
[WARNING] Tests run: 4, Failures: 0, Errors: 0, Skipped: 1
Output: 07-tag-filter-expression.txt — “unit AND NOT slow” drops the one slow-tagged method that “fast” alone (05, above) still included alongside it, landing on 4 instead of 5.
The “20 tests total” baseline above is not asserted — it is the unfiltered mvn test run of the whole module, captured once before any -Dgroups or -DexcludedGroups flag is applied: 08-full-suite.txt, 20 run and 1 skipped, which is the number every filtered run above is a subset of.
Composed annotations from the old shape of this post still work exactly the same way in JUnit 6
— a meta-annotation bundling @Test with one or more @Tags into a
single name:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag("fast")
@Tag("unit")
public @interface FastUnitCheck {
}
Source: FastUnitCheck.java. A method annotated @FastUnitCheck alone is discovered as a test and carries both tags — the 05 output above includes it in the “fast” run, proving the composed route produces identical tagging to writing @Tag("fast") @Tag("unit") directly.
@Disabled is still here too, and it interacts with tag filtering in a way worth
seeing directly: a disabled test is skipped regardless of which tag filter you run. It is
not part of the tag system at all — it is a separate, always-on exclusion. Every run above
shows “Skipped: 1” no matter which tag expression was used, because the one
@Disabled method in ValidationChecksTest never runs under any of them.
The console-standalone tree view shows the reason text inline, which Surefire’s own summary line
does not:
│ ├─ an internationalised domain name is accepted ↷ Waiting on RFC 6531 (internationalised addresses) support - tracked as TUT-142
Output: 09-tags-and-disabled-reason.txt. Always give @Disabled a reason string — it is the only place that explanation survives once the test stops being the first thing anyone looks at.
A composed annotation’s tags are fixed at compile time.@FastUnitCheckalways means@Tag("fast") @Tag("unit")— there is no way to parameterise the tag value per call site the way you could with a plain@Tag("..."). That is a feature for a team-wide convention (everyone’s fast unit tests get exactly the same two tags, no typos, no drift) and a limitation the moment you need a one-off third tag, at which point you add@Tag("something-else")alongside@FastUnitCheckon that one method rather than creating a new composed annotation for a single use.
Reference: tag names, tag expressions, and when to reach for @Suite instead (click to expand)
- Tag names may not contain whitespace or ISO control characters; letters, digits,
-and_are always safe. JUnit validates this at runtime and throws if a tag is malformed. - Tag expressions support
&(AND),|(OR), and!(NOT), with parentheses for grouping —(unit | integration) & !databaseis valid. @Tagon an outer class is inherited by every@Nestedclass beneath it (a JUnit-specific collection rule, not the plain Java@Inheritedmeta-annotation, which only applies to subclassing — never to member classes).@Suite(fromjunit-platform-suite) builds a fixed, named collection of classes or tags for when you need a reproducible subset that won’t silently grow as new matching tests are added — tag filters, by contrast, pick up every future test with that tag automatically.
Full depth, including the Platform Suite Engine and a production tagging-strategy table, is in Tags and Test Suites in JUnit 6.
- Going deeper: the complete tag-expression grammar and the
@Suiteannotations (@SelectPackages,@IncludeTags,@SelectClasses) are in Tags and Test Suites in JUnit 6. - The Gradle translation of every Maven command above —
includeTags,excludeTags, and a dedicatedfastTesttask — is in build.gradle.kts (translated, not independently run in this module — the captured output above is all Maven).
Should you reach for all three of these?
@DisplayName is close to free — add it where a method name genuinely doesn’t
explain itself, and stop there for a small suite. @Nested earns its keep once a class
has two or more distinct scenarios that each need their own setup; below that, it is ceremony
around a handful of tests that would read fine as a flat class. @Tag and
DisplayNameGenerator are the two that need a suite big enough to justify them —
tags matter once a build takes long enough that nobody wants to run all of it before every commit,
and a generator matters once hand-maintaining @DisplayName strings across dozens of
methods is itself the chore you’re trying to eliminate.
None of this fixes a slow or flaky test suite. Tags let you avoid running the slow tests every time; they do nothing to make the slow tests faster, and a tag expression that quietly excludes a flaky integration test from every CI run is a way to stop noticing a real problem, not a way to fix one. Reach for @Tag to organise a suite you trust, not to hide one you don’t.
Further reading
- Companion repository for this post: DisplayNestedTag module on asmhatre/JUnit_Tutorials.
- JUnit 6 Deep Dive: What’s New, What Changed, and How to Migrate — the full picture this post only covers three annotations’ worth of.
- 15 Common Problems During JUnit 5 to JUnit 6 Upgrade — problem 10 is the nested-ordering change from this post, in migration-checklist form.
- Structuring Tests with JUnit 6 Nested Tests — the complete nesting reference, including Spring context sharing.
- Tags and Test Suites in JUnit 6 — the complete tagging reference, including
@Suite. - JUnit 6 Parameterized Tests: All Sources Explained — for the
@DisplayNameplaceholder syntax this post only mentioned in passing. - JUnit 6 Tutorial: Complete Series Index — every annotation and pattern this site covers, one page.
- Official
DisplayNameGeneratorJavadoc, JUnit 6.1.3. - JUnit 6 User Guide.
- junit-jupiter
maven-metadata.xmlon Maven Central — the primary source for the version numbers in this post.
No Comments yet!