Add organising module: DisplayNameGenerator/@Nested/@Tag demos on JUnit 6.1.3

Companion code for the rewritten post "Organising and Displaying JUnit Tests:
@DisplayName, @Nested, @Tag (JUnit 5 -> JUnit 6)". Same suite also runs green
on JUnit 5.14.4 via -Djunit.version. Verified, not assumed: GA dates and
versions cross-checked against Maven Central metadata and docs.junit.org
release notes; the @TestMethodOrder-inheritance-into-@Nested-classes and
MethodOrderer.Default/ClassOrderer.Default behaviour differences between
5.14.4 and 6.1.3 are demonstrated by actually running the same source under
both engines (see organising/output/12-* and 14-*), not by reading about them.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXCrLgRKFgCh9RHKW8xaqJ
This commit is contained in:
2026-10-01 04:38:28 +00:00
co-authored by Claude Sonnet 5
parent cfc6c79fc3
commit bfc67986bc
43 changed files with 1124 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target/
+59
View File
@@ -0,0 +1,59 @@
# Organising and Displaying JUnit Tests - DisplayNameGenerator, @Nested, @Tag
Runnable companion to the ankurm.com article "Organising and Displaying JUnit Tests: @DisplayName,
@Nested, @Tag (JUnit 5 to JUnit 6)". Every scenario is a small fixture driven through the real
JUnit Platform Launcher (see `RunHelper.java`); the tests assert on what actually happened and
write it to `output/`, so every transcript quoted in the article is also an assertion that fails
the build the day it stops being true.
## Versions
| Item | Version |
|------|---------|
| JUnit (default, `junit.version`) | 6.1.3 (Jupiter, Platform, Launcher - single version line since 6.0) |
| Also run green on | 5.14.4 (`mvn test -Djunit.version=5.14.4`) |
| JUnit 6.0.0 GA date | September 30, 2025 (confirmed on `docs.junit.org/current/release-notes`) |
| Java | 17 language level (`maven.compiler.release`), run on JDK 25.0.4.1 (Temurin, current LTS) |
| Maven Surefire | 3.5.4 |
Both GA numbers were cross-checked against `maven-metadata.xml` on Maven Central
(`org/junit/platform/junit-platform-commons` and `org/junit/junit-bom`), not against a blog or an
LLM's memory - see the article's versions callout.
## What each scenario demonstrates
| Class | Shows | Output |
|-------|-------|--------|
| `DisplayNameFixtures.PlainDefault` | the default `Standard` generator - raw method signature | `01-default-generator.txt` |
| `DisplayNameFixtures.Underscored` | `DisplayNameGenerator.ReplaceUnderscores` | `02-replace-underscores.txt` |
| `DisplayNameFixtures.Sentences` | `DisplayNameGenerator.IndicativeSentences` joining class + nested + method into one sentence | `03-indicative-sentences.txt` |
| `DisplayNameFixtures.CustomSentenceCase` + `SentenceCaseGenerator` | a hand-written `DisplayNameGenerator` | `04-custom-generator.txt` |
| `DisplayNameFixtures.ConfiguredByDefault` | setting the generator project-wide via `junit.jupiter.displayname.generator.default`, no annotation touched | `05-no-config-param.txt`, `06-configured-default-generator.txt` |
| `LoanDeskTest` | `@DisplayNameGeneration` + `@Nested` + `@Tag` used together, tag inheritance into nested and doubly-nested classes | real `mvn test` run, `17-mvn-cli-tag-filter.txt` |
| `TagFixtures.CatalogServiceTest` | include/exclude filtering, composed tag annotations, OR-vs-AND semantics of `TagFilter.includeTags` | `07`, `08`, `09`, `09b` |
| `TagFixtures.TrimmedTagClass` | leading/trailing whitespace in a tag is silently trimmed, not invalid | `10`, `10b` |
| `TagFixtures.InternalWhitespaceTagClass` | internal whitespace **is** invalid - logged as a non-critical discovery warning and the tag is dropped, test still runs | `10c`, `10d` |
| `TagFixtures.BlankTagClass` | an empty `@Tag("")` inside `@Tags` is dropped the same way | `11` |
| `OrderingFixtures.DefaultSiblingOrder` | sibling `@Nested` class order with no orderer declared - deterministic, but a **different** order on 5.14.4 vs 6.1.3 | `12-*-run1/2/3.txt` (stability within a version), `12-*-junit5.14.4.txt` (cross-version diff) |
| `OrderingFixtures.ClassOrderOuter` | `@TestClassOrder` reaching two levels of `@Nested` nesting - identical on both versions in this repo's testing | `13-class-order-two-levels.txt` |
| `OrderingFixtures.MethodOrderInheritanceOuter` | `@TestMethodOrder` on an outer class now cascading into an unannotated `@Nested` child - **new in JUnit 6.0**, confirmed by a different real method order on 5.14.4 | `14-method-order-inheritance*.txt` |
| `src/broken/MethodOrdererDefaultDemo.java` | `MethodOrderer.Default` / `ClassOrderer.Default` (opt a `@Nested` class back out of inherited ordering) added in 6.0 - compiles on 6.1.3, fails on 5.14.4 | `15`, `16` |
## Prerequisites
- JDK 17 or higher (built and run here on JDK 25.0.4.1)
- Maven 3.6+
## How to run
```bash
mvn test # full suite, JUnit 6.1.3
mvn test -Djunit.version=5.14.4 # same suite, JUnit 5.14.4
mvn test -Dgroups=fast # only @Tag("fast") tests
mvn test -DexcludedGroups=slow # everything except @Tag("slow")
```
`scripts/run-all.sh` regenerates every file in `output/` from a clean state in one command,
including the cross-version comparison files and the `src/broken` compiler-message transcripts.
Instance ordering in `12-nested-sibling-order-*.txt` is stable within one JUnit version but is
explicitly NOT something to hand-derive - it is only ever read from a real run.
@@ -0,0 +1,3 @@
+ DisplayNameFixtures$PlainDefault
- total_price_is_zero_when_cart_is_empty()
- addingAnItemIncreasesTheCount()
@@ -0,0 +1,3 @@
+ DisplayNameFixtures$Underscored
- total price is zero when cart is empty
- adding an item increases the count
@@ -0,0 +1,4 @@
+ A shopping cart
- has a total of zero when it is empty
+ once an item has been added
- has a total equal to that item's price
@@ -0,0 +1,3 @@
+ Custom sentence case
- Should report zero for an empty cart()
- Should reject a negative quantity(TestInfo)
+2
View File
@@ -0,0 +1,2 @@
+ DisplayNameFixtures$ConfiguredByDefault
- total_price_is_zero_when_cart_is_empty()
@@ -0,0 +1,2 @@
+ DisplayNameFixtures$ConfiguredByDefault
- total price is zero when cart is empty
@@ -0,0 +1,6 @@
catalog.priceLookupIsCaseInsensitive ran
catalog.emptyQueryReturnsNoResults ran
-- results --
SUCCESSFUL priceLookupIsCaseInsensitive()
SUCCESSFUL emptyQueryReturnsNoResults()
SUCCESSFUL TagFixtures$CatalogServiceTest
@@ -0,0 +1,6 @@
catalog.priceLookupIsCaseInsensitive ran
catalog.emptyQueryReturnsNoResults ran
-- results --
SUCCESSFUL priceLookupIsCaseInsensitive()
SUCCESSFUL emptyQueryReturnsNoResults()
SUCCESSFUL TagFixtures$CatalogServiceTest
@@ -0,0 +1,6 @@
catalog.reindexingHitsTheRealSearchCluster ran
catalog.priceLookupIsCaseInsensitive ran
-- results --
SUCCESSFUL reindexingHitsTheRealSearchCluster()
SUCCESSFUL priceLookupIsCaseInsensitive()
SUCCESSFUL TagFixtures$CatalogServiceTest
@@ -0,0 +1,6 @@
catalog.priceLookupIsCaseInsensitive ran
catalog.emptyQueryReturnsNoResults ran
-- results --
SUCCESSFUL priceLookupIsCaseInsensitive()
SUCCESSFUL emptyQueryReturnsNoResults()
SUCCESSFUL TagFixtures$CatalogServiceTest
@@ -0,0 +1,4 @@
TrimmedTagClass.stillRuns ran
-- results --
SUCCESSFUL stillRuns()
SUCCESSFUL TagFixtures$TrimmedTagClass
@@ -0,0 +1,4 @@
TrimmedTagClass.stillRuns ran
-- results --
SUCCESSFUL stillRuns()
SUCCESSFUL TagFixtures$TrimmedTagClass
@@ -0,0 +1,4 @@
InternalWhitespaceTagClass.stillRunsButTagIsDropped ran
-- results --
SUCCESSFUL stillRunsButTagIsDropped()
SUCCESSFUL TagFixtures$InternalWhitespaceTagClass
@@ -0,0 +1 @@
-- results --
@@ -0,0 +1,4 @@
BlankTagClass.neverActuallyRuns ran
-- results --
SUCCESSFUL neverActuallyRuns()
SUCCESSFUL TagFixtures$BlankTagClass
@@ -0,0 +1,9 @@
+ OrderingFixtures$DefaultSiblingOrder
+ Delta
- t()
+ Alpha
- t()
+ Mike
- t()
+ Zebra
- t()
@@ -0,0 +1,9 @@
+ OrderingFixtures$DefaultSiblingOrder
+ Alpha
- t()
+ Delta
- t()
+ Zebra
- t()
+ Mike
- t()
@@ -0,0 +1,9 @@
+ OrderingFixtures$DefaultSiblingOrder
+ Alpha
- t()
+ Delta
- t()
+ Zebra
- t()
+ Mike
- t()
@@ -0,0 +1,9 @@
+ OrderingFixtures$DefaultSiblingOrder
+ Alpha
- t()
+ Delta
- t()
+ Zebra
- t()
+ Mike
- t()
@@ -0,0 +1,10 @@
+ OrderingFixtures$ClassOrderOuter
+ Alpha
- t()
+ Mike
- t()
+ Zebra
+ Charlie
- t()
+ Delta
- t()
@@ -0,0 +1,8 @@
+ OrderingFixtures$MethodOrderInheritanceOuter
- alpha()
- bravo()
- charlie()
+ PlainNestedChild
- yankee()
- xray()
- zulu()
@@ -0,0 +1,8 @@
+ OrderingFixtures$MethodOrderInheritanceOuter
- alpha()
- bravo()
- charlie()
+ PlainNestedChild
- xray()
- yankee()
- zulu()
@@ -0,0 +1,3 @@
$ javac -cp <junit-jupiter-6.1.3 and its dependencies> src/broken/MethodOrdererDefaultDemo.java
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=36237 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
(compiled cleanly)
@@ -0,0 +1,8 @@
$ javac -cp <junit-jupiter-5.14.4 and its dependencies> src/broken/MethodOrdererDefaultDemo.java
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=36237 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
src/broken/MethodOrdererDefaultDemo.java:22: error: cannot find symbol
@TestMethodOrder(MethodOrderer.Default.class) // <-- does not exist before JUnit 6.0
^
symbol: class Default
location: interface MethodOrderer
1 error
@@ -0,0 +1,38 @@
$ mvn test -Dtest=LoanDeskTest (no tag filter - everything runs)
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=36237 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
[INFO] Running com.ankurm.tutorials.junit.organising.LoanDeskTest
[INFO] Running When no books are on loan
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.162 s -- in When no books are on loan
[INFO] Running When a book is ten days overdue
[INFO] Running and the member disputes the fee
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.004 s -- in and the member disputes the fee
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.075 s -- in When a book is ten days overdue
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.289 s -- in com.ankurm.tutorials.junit.organising.LoanDeskTest
[INFO]
[INFO] Results:
$ mvn test -Dgroups=fast -Dtest=LoanDeskTest
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=36237 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
[INFO] Running com.ankurm.tutorials.junit.organising.LoanDeskTest
[INFO] Running When no books are on loan
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.149 s -- in When no books are on loan
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.201 s -- in com.ankurm.tutorials.junit.organising.LoanDeskTest
[INFO]
[INFO] Results:
$ mvn test -Dgroups=slow -Dtest=LoanDeskTest
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=36237 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
[INFO] Running com.ankurm.tutorials.junit.organising.LoanDeskTest
[INFO] Running When a book is ten days overdue
[INFO] Running and the member disputes the fee
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.016 s -- in and the member disputes the fee
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.170 s -- in When a book is ten days overdue
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.205 s -- in com.ankurm.tutorials.junit.organising.LoanDeskTest
[INFO]
[INFO] Results:
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurTutorials</groupId>
<artifactId>organising-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- override with -Djunit.version=5.14.4 to run the same suite on JUnit 5. MethodOrderer.Default
and ClassOrderer.Default do not exist on 5.14.4 - see src/broken/ and docs/output/06-*.txt -->
<junit.version>6.1.3</junit.version>
<!-- -Dgroups=fast / -DexcludedGroups=slow / -Dgroups="unit &amp; !slow" from the CLI -->
<groups></groups>
<excludedGroups></excludedGroups>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</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>
<!-- used by RunHelper/OrderProbe to execute fixtures programmatically and inspect the real report tree -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
<configuration>
<!-- -Dgroups=fast / -DexcludedGroups=slow / -Dgroups="unit & !slow" from the CLI -->
<groups>${groups}</groups>
<excludedGroups>${excludedGroups}</excludedGroups>
</configuration>
</plugin>
</plugins>
</build>
</project>
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Compiles src/broken/MethodOrdererDefaultDemo.java against both JUnit versions and captures the
# REAL compiler output. MethodOrderer.Default was added in JUnit 6.0; this file is excluded from the
# normal build (it lives under src/broken, not src/test) precisely because it will not compile on 5.x.
set -uo pipefail
cd "$(dirname "$0")/.."
mvn -q -B dependency:build-classpath -Dmdep.outputFile=/tmp/cp-6.1.3.txt
mvn -q -B dependency:build-classpath -Dmdep.outputFile=/tmp/cp-5.14.4.txt -Djunit.version=5.14.4
mkdir -p /tmp/broken-compile-out/6 /tmp/broken-compile-out/5
{
echo '$ javac -cp <junit-jupiter-6.1.3 and its dependencies> src/broken/MethodOrdererDefaultDemo.java'
javac -cp "$(cat /tmp/cp-6.1.3.txt)" -d /tmp/broken-compile-out/6 src/broken/MethodOrdererDefaultDemo.java
echo "(compiled cleanly)"
} > output/15-broken-compile-junit6.1.3.txt 2>&1
{
echo '$ javac -cp <junit-jupiter-5.14.4 and its dependencies> src/broken/MethodOrdererDefaultDemo.java'
javac -cp "$(cat /tmp/cp-5.14.4.txt)" -d /tmp/broken-compile-out/5 src/broken/MethodOrdererDefaultDemo.java
} > output/16-broken-compile-junit5.14.4.txt 2>&1
cat output/15-broken-compile-junit6.1.3.txt
echo
cat output/16-broken-compile-junit5.14.4.txt
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Captures the real Surefire console output of LoanDeskTest run with no filter, -Dgroups=fast, and
# -Dgroups=slow - this is what a reader actually sees typing these commands, not a paraphrase.
set -uo pipefail
cd "$(dirname "$0")/.."
{
echo '$ mvn test -Dtest=LoanDeskTest (no tag filter - everything runs)'
mvn -B test -Dtest=LoanDeskTest 2>&1 | sed -n '/T E S T S/,/^\[INFO\] Results/p'
echo
echo '$ mvn test -Dgroups=fast -Dtest=LoanDeskTest'
mvn -B test -Dgroups=fast -Dtest=LoanDeskTest 2>&1 | sed -n '/T E S T S/,/^\[INFO\] Results/p'
echo
echo '$ mvn test -Dgroups=slow -Dtest=LoanDeskTest'
mvn -B test -Dgroups=slow -Dtest=LoanDeskTest 2>&1 | sed -n '/T E S T S/,/^\[INFO\] Results/p'
} > output/17-mvn-cli-tag-filter.txt
cat output/17-mvn-cli-tag-filter.txt
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Regenerates every file in output/ from scratch. Requires JDK 17+ and Maven on PATH.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -rf output
mkdir -p output
echo "== 1/4: full suite on the default version (JUnit 6.1.3) =="
mvn -q -B test
echo "== 2/4: same suite on JUnit 5.14.4, keep only the two version-sensitive transcripts =="
mvn -q -B test -Djunit.version=5.14.4
cp output/12-nested-sibling-order-run1.txt output/12-nested-sibling-order-junit5.14.4.txt
cp output/14-method-order-inheritance.txt output/14-method-order-inheritance-junit5.14.4.txt
echo "== 3/4: back to the default version so output/ reflects JUnit 6.1.3 everywhere else =="
mvn -q -B test
echo "== 4/4: compiler-message transcripts for the broken/ demo, and a real mvn CLI tag-filter run =="
./scripts/broken-compile.sh
./scripts/mvn-cli-tag-filter.sh
echo "Done. See output/ for every transcript the article links to."
@@ -0,0 +1,28 @@
package com.ankurm.tutorials.junit.organising.broken;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Excluded from the normal build (lives under src/broken, not src/test). Compiles on JUnit
* 6.1.3 and fails on JUnit 5.14.4: MethodOrderer.Default was added in JUnit 6.0 to let a @Nested
* class opt back out of an outer class's inherited @TestMethodOrder - see scripts/broken-compile.sh
* and output/15-broken-compile-6.1.3.txt / output/16-broken-compile-5.14.4.txt for the real
* compiler output on both versions.
*/
@TestMethodOrder(MethodOrderer.MethodName.class)
class MethodOrdererDefaultDemo {
@Test void charlie() {}
@Test void alpha() {}
@Test void bravo() {}
@Nested
@TestMethodOrder(MethodOrderer.Default.class) // <-- does not exist before JUnit 6.0
class OptedBackOutOfInheritedOrdering {
@Test void zulu() {}
@Test void yankee() {}
@Test void xray() {}
}
}
@@ -0,0 +1,67 @@
package com.ankurm.tutorials.junit.organising;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
/**
* Four small classes, same two tests each, one per generator - run through RunHelper.displayTree so
* the article can show the SAME test reported four different ways. None of these is itself picked
* up by Surefire (the name doesn't end in *Test/*Tests), which is deliberate: they are driven only
* through the Launcher from DisplayNameTest.
*/
final class DisplayNameFixtures {
private DisplayNameFixtures() {}
/** No annotation at all - this is what every reader sees by default. */
static class PlainDefault {
@Test
void total_price_is_zero_when_cart_is_empty() {}
@Test
void addingAnItemIncreasesTheCount() {}
}
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
static class Underscored {
@Test
void total_price_is_zero_when_cart_is_empty() {}
@Test
void adding_an_item_increases_the_count() {}
}
@DisplayName("A shopping cart")
@DisplayNameGeneration(DisplayNameGenerator.IndicativeSentences.class)
static class Sentences {
@Test
@DisplayName("has a total of zero when it is empty")
void total_price_is_zero_when_cart_is_empty() {}
@Nested
@DisplayName("once an item has been added")
class OnceAnItemHasBeenAdded {
@Test
@DisplayName("has a total equal to that item's price")
void total_equals_item_price() {}
}
}
@DisplayNameGeneration(SentenceCaseGenerator.class)
static class CustomSentenceCase {
@Test
void shouldReportZeroForAnEmptyCart() {}
@Test
void shouldRejectANegativeQuantity(TestInfo info) {}
}
/** Used by the "no annotation, configured project-wide" scenario. */
static class ConfiguredByDefault {
@Test
void total_price_is_zero_when_cart_is_empty() {}
}
}
@@ -0,0 +1,61 @@
package com.ankurm.tutorials.junit.organising;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
/** Runs the same two tests through four generators and pins the exact strings a reader will see. */
class DisplayNameTest {
@Test
void defaultGeneratorUsesTheRawMethodSignature() {
List<String> tree = RunHelper.displayTree(DisplayNameFixtures.PlainDefault.class);
Events.write("01-default-generator.txt", tree);
assertTrue(tree.stream().anyMatch(s -> s.contains("total_price_is_zero_when_cart_is_empty()")));
assertTrue(tree.stream().anyMatch(s -> s.contains("addingAnItemIncreasesTheCount()")));
}
@Test
void replaceUnderscoresTurnsSnakeCaseIntoSpaces() {
List<String> tree = RunHelper.displayTree(DisplayNameFixtures.Underscored.class);
Events.write("02-replace-underscores.txt", tree);
assertTrue(tree.stream().anyMatch(s -> s.contains("total price is zero when cart is empty")));
assertTrue(tree.stream().anyMatch(s -> s.contains("adding an item increases the count")));
// ReplaceUnderscores only touches underscores - camelCase in the method itself would stay camelCase
}
@Test
void indicativeSentencesJoinsClassAndMethodIntoOneSentence() {
List<String> tree = RunHelper.displayTree(DisplayNameFixtures.Sentences.class);
Events.write("03-indicative-sentences.txt", tree);
assertTrue(tree.stream().anyMatch(s -> s.contains("A shopping cart")));
assertTrue(tree.stream().anyMatch(s -> s.contains("has a total of zero when it is empty")));
assertTrue(tree.stream().anyMatch(s -> s.contains("once an item has been added")));
}
@Test
void customGeneratorAppliesTeamSpecificRules() {
List<String> tree = RunHelper.displayTree(DisplayNameFixtures.CustomSentenceCase.class);
Events.write("04-custom-generator.txt", tree);
assertTrue(tree.stream().anyMatch(s -> s.contains("Custom sentence case")));
assertTrue(tree.stream().anyMatch(s -> s.contains("Should report zero for an empty cart")));
assertTrue(tree.stream().anyMatch(s -> s.contains("Should reject a negative quantity(TestInfo)")));
}
@Test
void defaultGeneratorCanBeSetProjectWideWithoutTouchingTheClass() {
List<String> plain = RunHelper.displayTree(DisplayNameFixtures.ConfiguredByDefault.class);
Events.write("05-no-config-param.txt", plain);
assertTrue(plain.stream().anyMatch(s -> s.contains("total_price_is_zero_when_cart_is_empty()")));
// same class, same jar, ONLY a configuration parameter changes - no annotation anywhere
List<String> configured = RunHelper.displayTreeWithConfig(
Map.of(org.junit.jupiter.api.DisplayNameGenerator.DEFAULT_GENERATOR_PROPERTY_NAME,
"org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores"),
DisplayNameFixtures.ConfiguredByDefault.class);
Events.write("06-configured-default-generator.txt", configured);
assertTrue(configured.stream().anyMatch(s -> s.contains("total price is zero when cart is empty")));
}
}
@@ -0,0 +1,31 @@
package com.ankurm.tutorials.junit.organising;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Ordered event log shared by the fixtures; the tests assert on it and write it to output/. */
public final class Events {
private static final List<String> LOG = new ArrayList<>();
private Events() {}
public static synchronized void add(String s) { LOG.add(s); }
public static synchronized void clear() { LOG.clear(); }
public static synchronized List<String> snapshot() { return new ArrayList<>(LOG); }
/** Writes the lines to output/&lt;name&gt; relative to the module directory. */
public static void write(String name, List<String> lines) {
try {
Path dir = Path.of("output");
Files.createDirectories(dir);
Files.write(dir.resolve(name), lines);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,45 @@
package com.ankurm.tutorials.junit.organising;
import java.util.HashMap;
import java.util.Map;
/**
* Tiny production class under test - a library loan desk. Not the point of the article; it exists
* so the @Nested / @Tag examples assert on real behaviour instead of empty test bodies.
*/
class LoanDesk {
private static final int LOAN_PERIOD_DAYS = 7;
private static final int FINE_PER_DAY = 10;
private final Map<String, Integer> loanedDaysAgo = new HashMap<>();
private boolean feeFrozen;
private int frozenFee;
void loanBook(String isbn, int daysAgo) {
loanedDaysAgo.put(isbn, daysAgo);
}
void returnBook(String isbn) {
if (!loanedDaysAgo.containsKey(isbn)) {
throw new IllegalStateException("not on loan: " + isbn);
}
loanedDaysAgo.remove(isbn);
}
/** Fee owed right now, or the frozen amount if a dispute is in progress. */
int outstandingFee() {
if (feeFrozen) return frozenFee;
return loanedDaysAgo.values().stream()
.mapToInt(daysAgo -> Math.max(0, daysAgo - LOAN_PERIOD_DAYS) * FINE_PER_DAY)
.sum();
}
void disputeFee() {
frozenFee = outstandingFee();
feeFrozen = true;
}
boolean isFeeFrozen() {
return feeFrozen;
}
}
@@ -0,0 +1,84 @@
package com.ankurm.tutorials.junit.organising;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/**
* The worked example for the article: @DisplayNameGeneration + @Nested + @Tag used TOGETHER on one
* class, which the three source posts (4841, 5020, 5023) each show separately. Tags on an outer or
* middle class are inherited by every test underneath it - that is asserted here, not just claimed.
*/
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@Tag("loan-desk")
class LoanDeskTest {
private LoanDesk desk;
@BeforeEach
void freshDesk() {
desk = new LoanDesk();
}
@Nested
@Tag("fast")
@DisplayName("When no books are on loan")
class When_no_books_are_on_loan {
@Test
void outstanding_fee_is_zero() {
assertEquals(0, desk.outstandingFee());
}
@Test
void returning_an_unknown_book_is_rejected() {
assertThrows(IllegalStateException.class, () -> desk.returnBook("ISBN-404"));
}
}
@Nested
@Tag("slow")
@DisplayName("When a book is ten days overdue")
class When_a_book_is_ten_days_overdue {
@BeforeEach
void loanAnOverdueBook() {
desk.loanBook("ISBN-1", 10); // loan period is 7 days, so this one is 3 days late
}
@Test
void outstanding_fee_accrues_at_ten_rupees_per_day_late() {
assertEquals(30, desk.outstandingFee());
}
@Test
void returning_the_book_clears_the_loan() {
desk.returnBook("ISBN-1");
assertEquals(0, desk.outstandingFee());
}
@Nested
@DisplayName("and the member disputes the fee")
class And_the_member_disputes_the_fee {
@BeforeEach
void raiseDispute() {
desk.disputeFee();
}
@Test
void fee_is_frozen_at_the_disputed_amount() {
assertTrue(desk.isFeeFrozen());
assertEquals(30, desk.outstandingFee());
}
}
}
}
@@ -0,0 +1,79 @@
package com.ankurm.tutorials.junit.organising;
import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestClassOrder;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Fixtures for the @Nested ordering scenario. Names are deliberately out of both declaration order
* and alphabetical order so an observed order actually means something.
*/
final class OrderingFixtures {
private OrderingFixtures() {}
/** No explicit orderer anywhere - whatever JUnit does by default for sibling @Nested classes. */
static class DefaultSiblingOrder {
@Nested
class Zebra {
@Test void t() {}
}
@Nested
class Mike {
@Test void t() {}
}
@Nested
class Alpha {
@Test void t() {}
}
@Nested
class Delta {
@Test void t() {}
}
}
/** @TestClassOrder on the outer class - does it reach a grandchild @Nested class two levels down? */
@TestClassOrder(ClassOrderer.ClassName.class)
static class ClassOrderOuter {
@Nested
class Zebra {
@Nested
class Delta {
@Test void t() {}
}
@Nested
class Charlie {
@Test void t() {}
}
}
@Nested
class Mike {
@Test void t() {}
}
@Nested
class Alpha {
@Test void t() {}
}
}
/**
* @TestMethodOrder on the outer class. The outer methods are alphabetical by construction
* (MethodName orderer). Whether the plain @Nested child also runs alphabetically is exactly the
* JUnit 6.0 change ("inheritance of @TestMethodOrder by enclosed @Nested classes, recursively").
*/
@TestMethodOrder(MethodOrderer.MethodName.class)
static class MethodOrderInheritanceOuter {
@Test void charlie() {}
@Test void alpha() {}
@Test void bravo() {}
@Nested
class PlainNestedChild {
@Test void zulu() {}
@Test void yankee() {}
@Test void xray() {}
}
}
}
@@ -0,0 +1,36 @@
package com.ankurm.tutorials.junit.organising;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import org.junit.jupiter.api.Test;
class OrderingTest {
@Test
void siblingNestedClassesRunInAStableOrderAcrossRepeatedRuns() {
List<String> run1 = RunHelper.displayTree(OrderingFixtures.DefaultSiblingOrder.class);
List<String> run2 = RunHelper.displayTree(OrderingFixtures.DefaultSiblingOrder.class);
List<String> run3 = RunHelper.displayTree(OrderingFixtures.DefaultSiblingOrder.class);
Events.write("12-nested-sibling-order-run1.txt", run1);
Events.write("12-nested-sibling-order-run2.txt", run2);
Events.write("12-nested-sibling-order-run3.txt", run3);
assertEquals(run1, run2, "the engine must produce the same order every run, within one JUnit version");
assertEquals(run1, run3, "the engine must produce the same order every run, within one JUnit version");
}
@Test
void testClassOrderReachesTwoLevelsOfNesting() {
List<String> tree = RunHelper.displayTree(OrderingFixtures.ClassOrderOuter.class);
Events.write("13-class-order-two-levels.txt", tree);
}
@Test
void testMethodOrderOnOuterClassAndWhatThePlainNestedChildDoes() {
List<String> tree = RunHelper.displayTree(OrderingFixtures.MethodOrderInheritanceOuter.class);
Events.write("14-method-order-inheritance.txt", tree);
// outer methods: MethodOrderer.MethodName always worked here, both 5.x and 6.x
List<String> outerOnly = tree.stream().filter(s -> s.startsWith(" - ")).toList();
assertEquals(List.of(" - alpha()", " - bravo()", " - charlie()"), outerOnly);
}
}
@@ -0,0 +1,115 @@
package com.ankurm.tutorials.junit.organising;
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.engine.discovery.ClassSelector;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.PostDiscoveryFilter;
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestIdentifier;
import org.junit.platform.launcher.TagFilter;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
/**
* Runs fixture classes through the real JUnit Platform Launcher - the same engine a build tool
* drives - and returns what actually happened. Every transcript in output/ comes from one of
* these two methods, never from a hand-typed example.
*/
final class RunHelper {
private RunHelper() {}
private static String describe(Throwable t) {
StringBuilder sb = new StringBuilder(" " + t.getClass().getSimpleName() + ": " + t.getMessage());
for (Throwable s : t.getSuppressed())
sb.append("\n suppressed ").append(s.getClass().getSimpleName()).append(": ").append(s.getMessage());
return sb.toString();
}
/** Pass/fail status lines per node, in completion order. Used for lifecycle- and failure-mode style checks. */
static List<String> run(Class<?>... classes) {
return run(Map.of(), (PostDiscoveryFilter[]) null, classes);
}
static List<String> run(Map<String, String> config, Class<?>... classes) {
return run(config, (PostDiscoveryFilter[]) null, classes);
}
static List<String> run(Map<String, String> config, PostDiscoveryFilter[] filters, Class<?>... classes) {
Events.clear();
var b = LauncherDiscoveryRequestBuilder.request();
List<ClassSelector> selectors = new ArrayList<>();
for (Class<?> c : classes) selectors.add(selectClass(c));
b.selectors(selectors);
config.forEach(b::configurationParameter);
if (filters != null) b.filters(filters);
LauncherDiscoveryRequest request = b.build();
List<String> results = new ArrayList<>();
TestExecutionListener l = new TestExecutionListener() {
@Override
public void executionSkipped(TestIdentifier id, String reason) {
results.add("SKIPPED " + id.getDisplayName() + " (" + reason + ")");
}
@Override
public void executionFinished(TestIdentifier id, TestExecutionResult r) {
boolean root = id.getParentId().isEmpty();
if (root && r.getStatus() == TestExecutionResult.Status.SUCCESSFUL) return;
String what = r.getThrowable().map(RunHelper::describe).orElse("");
results.add(String.format("%-10s %s%s", r.getStatus(), root ? "<engine>" : id.getDisplayName(), what));
}
};
LauncherFactory.create().execute(request, l);
List<String> all = new ArrayList<>(Events.snapshot());
all.add("-- results --");
all.addAll(results);
return all;
}
/**
* Runs the given classes and returns the real display-name tree, indented by depth, in the
* order each container/test was REPORTED STARTED by the engine - i.e. the order a reader
* would see scroll past in an IDE or a console run. This is what actually answers
* "what order does JUnit run @Nested classes in" - not a guess from reading the javadoc.
*/
static List<String> displayTree(Class<?>... classes) {
return displayTreeWithConfig(Map.of(), classes);
}
static List<String> displayTreeWithConfig(Map<String, String> config, Class<?>... classes) {
var b = LauncherDiscoveryRequestBuilder.request();
for (Class<?> c : classes) b.selectors(selectClass(c));
config.forEach(b::configurationParameter);
LauncherDiscoveryRequest request = b.build();
List<String> lines = new ArrayList<>();
var launcher = LauncherFactory.create();
var plan = launcher.discover(request);
TestExecutionListener l = new TestExecutionListener() {
@Override
public void executionStarted(TestIdentifier id) {
if (id.getParentId().isEmpty()) return; // skip the synthetic engine root
int depth = 0;
var parent = plan.getParent(id);
while (parent.isPresent()) {
depth++;
parent = plan.getParent(parent.get());
}
lines.add(" ".repeat(depth - 1) + (id.isContainer() ? "+ " : "- ") + id.getDisplayName());
}
};
launcher.execute(request, l);
return lines;
}
/** Convenience: run with include/exclude tag filters, ANDed together exactly as Surefire's groups/excludedGroups would be. */
static List<String> runWithTags(List<String> include, List<String> exclude, Class<?>... classes) {
List<PostDiscoveryFilter> filters = new ArrayList<>();
if (!include.isEmpty()) filters.add(TagFilter.includeTags(include));
if (!exclude.isEmpty()) filters.add(TagFilter.excludeTags(exclude));
return run(Map.of(), filters.toArray(new PostDiscoveryFilter[0]), classes);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.tutorials.junit.organising;
import java.lang.reflect.Method;
import java.util.regex.Pattern;
import org.junit.jupiter.api.DisplayNameGenerator;
/**
* A custom DisplayNameGenerator: strips a trailing "Test", splits camelCase/PascalCase into words,
* and lower-cases everything except the first letter - "shouldRejectExpiredCoupon" becomes
* "Should reject expired coupon". This is the shape you write when neither built-in generator
* matches your team's report style.
*/
public class SentenceCaseGenerator implements DisplayNameGenerator {
private static final Pattern WORD_BOUNDARY =
Pattern.compile("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|_");
@Override
public String generateDisplayNameForClass(Class<?> testClass) {
return toSentence(stripSuffix(testClass.getSimpleName(), "Test"));
}
@Override
public String generateDisplayNameForNestedClass(java.util.List<Class<?>> enclosingInstanceTypes, Class<?> nestedClass) {
return toSentence(nestedClass.getSimpleName());
}
@Override
public String generateDisplayNameForMethod(java.util.List<Class<?>> enclosingInstanceTypes, Class<?> testClass, Method testMethod) {
return toSentence(testMethod.getName()) + DisplayNameGenerator.parameterTypesAsString(testMethod);
}
private static String stripSuffix(String s, String suffix) {
return s.endsWith(suffix) ? s.substring(0, s.length() - suffix.length()) : s;
}
private static String toSentence(String identifier) {
String[] words = WORD_BOUNDARY.split(identifier);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < words.length; i++) {
if (words[i].isEmpty()) continue;
if (sb.length() > 0) sb.append(' ');
String w = words[i].toLowerCase(java.util.Locale.ROOT);
sb.append(sb.length() == 0 ? Character.toUpperCase(w.charAt(0)) + w.substring(1) : w);
}
return sb.toString();
}
}
@@ -0,0 +1,73 @@
package com.ankurm.tutorials.junit.organising;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Tags;
import org.junit.jupiter.api.Test;
/** Tag-filtering fixtures - driven only through RunHelper, never picked up directly by Surefire. */
final class TagFixtures {
private TagFixtures() {}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Tag("unit")
@Tag("fast")
@interface FastUnitTest {}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Tag("integration")
@Tag("slow")
@interface SlowIntegrationTest {}
@Tag("catalog")
static class CatalogServiceTest {
@FastUnitTest
@Test
void priceLookupIsCaseInsensitive() {
Events.add("catalog.priceLookupIsCaseInsensitive ran");
}
@SlowIntegrationTest
@Test
void reindexingHitsTheRealSearchCluster() {
Events.add("catalog.reindexingHitsTheRealSearchCluster ran");
}
@Test
@Tag("fast")
void emptyQueryReturnsNoResults() {
Events.add("catalog.emptyQueryReturnsNoResults ran");
}
}
/** Leading/trailing whitespace is silently TRIMMED - this is treated exactly as @Tag("fast"). */
@Tag(" fast ")
static class TrimmedTagClass {
@Test
void stillRuns() {
Events.add("TrimmedTagClass.stillRuns ran");
}
}
/** Whitespace INSIDE a tag is not trimmable, so it is invalid - this one really is dropped with a warning. */
@Tag("fast lane")
static class InternalWhitespaceTagClass {
@Test
void stillRunsButTagIsDropped() {
Events.add("InternalWhitespaceTagClass.stillRunsButTagIsDropped ran");
}
}
@Tags({@Tag("fast"), @Tag("")})
static class BlankTagClass {
@Test
void neverActuallyRuns() {
Events.add("BlankTagClass.neverActuallyRuns ran");
}
}
}
@@ -0,0 +1,98 @@
package com.ankurm.tutorials.junit.organising;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.platform.launcher.TagFilter;
class TagTest {
@Test
void includingFastRunsOnlyTheFastTests() {
List<String> log = RunHelper.runWithTags(List.of("fast"), List.of(), TagFixtures.CatalogServiceTest.class);
Events.write("07-tag-include-fast.txt", log);
assertTrue(log.contains("catalog.priceLookupIsCaseInsensitive ran"));
assertTrue(log.contains("catalog.emptyQueryReturnsNoResults ran"));
assertFalse(log.contains("catalog.reindexingHitsTheRealSearchCluster ran"));
}
@Test
void excludingSlowSkipsTheIntegrationTest() {
List<String> log = RunHelper.runWithTags(List.of(), List.of("slow"), TagFixtures.CatalogServiceTest.class);
Events.write("08-tag-exclude-slow.txt", log);
assertTrue(log.contains("catalog.priceLookupIsCaseInsensitive ran"));
assertTrue(log.contains("catalog.emptyQueryReturnsNoResults ran"));
assertFalse(log.contains("catalog.reindexingHitsTheRealSearchCluster ran"));
}
/**
* TagFilter.includeTags(List.of("a", "b")) is OR, not AND - a test matching EITHER expression is
* included. Passing two separate strings is NOT the same as requiring both tags; to require
* both you write one tag EXPRESSION with "&amp;". Verified here, not assumed - the first run
* proves OR (everything with "catalog" OR "fast" gets in, including the slow one), the second
* proves "&amp;" inside one expression narrows it back down.
*/
@Test
void multipleIncludeTagsAreCombinedWithOrNotAnd() {
List<String> orResult = RunHelper.runWithTags(List.of("integration", "unit"), List.of(),
TagFixtures.CatalogServiceTest.class);
Events.write("09-tag-or-semantics.txt", orResult);
assertTrue(orResult.contains("catalog.priceLookupIsCaseInsensitive ran"), "has 'unit'");
assertTrue(orResult.contains("catalog.reindexingHitsTheRealSearchCluster ran"), "has 'integration'");
assertFalse(orResult.contains("catalog.emptyQueryReturnsNoResults ran"), "has neither tag");
List<String> andResult = RunHelper.run(java.util.Map.of(), new org.junit.platform.launcher.PostDiscoveryFilter[] {
TagFilter.includeTags("catalog & fast")
}, TagFixtures.CatalogServiceTest.class);
Events.write("09b-tag-and-expression.txt", andResult);
assertTrue(andResult.contains("catalog.priceLookupIsCaseInsensitive ran"), "catalog + fast");
assertTrue(andResult.contains("catalog.emptyQueryReturnsNoResults ran"), "catalog + fast");
assertFalse(andResult.contains("catalog.reindexingHitsTheRealSearchCluster ran"), "catalog but not fast");
}
/**
* Leading/trailing whitespace in a @Tag is silently TRIMMED - no warning, and "-Dgroups=fast"
* matches it exactly as if you had written @Tag("fast"). Confirmed by actually filtering for it,
* not by reading the javadoc: a hand-typed stray space here is harmless.
*/
@Test
void leadingAndTrailingWhitespaceInATagIsSilentlyTrimmed() {
List<String> fullRun = RunHelper.run(TagFixtures.TrimmedTagClass.class);
Events.write("10-trimmed-tag-still-runs.txt", fullRun);
assertTrue(fullRun.contains("TrimmedTagClass.stillRuns ran"));
List<String> filteredByFast = RunHelper.runWithTags(List.of("fast"), List.of(), TagFixtures.TrimmedTagClass.class);
Events.write("10b-trimmed-tag-matches-fast-filter.txt", filteredByFast);
assertTrue(filteredByFast.contains("TrimmedTagClass.stillRuns ran"),
"\" fast \" is trimmed to \"fast\" before matching, so the filter still finds it");
}
/**
* Whitespace INSIDE a tag (not at the edges) is not trimmable - THAT is what actually triggers
* the "Invalid tag syntax... Tag will be ignored" warning. It is a non-critical discovery issue,
* not a build failure: the test body still runs as part of the full suite, but the bad tag is
* dropped, so a filter looking for it will never find this test again.
*/
@Test
void internalWhitespaceMakesATagInvalidAndItIsDroppedWithAWarning() {
List<String> fullRun = RunHelper.run(TagFixtures.InternalWhitespaceTagClass.class);
Events.write("10c-invalid-tag-internal-whitespace.txt", fullRun);
assertTrue(fullRun.contains("InternalWhitespaceTagClass.stillRunsButTagIsDropped ran"), "the test body still runs");
// "fast lane" is not even a legal tag EXPRESSION (a bare space isn't an operator), which is
// further proof it could never have been registered as a real tag either.
List<String> filtered = RunHelper.runWithTags(List.of("fast"), List.of(), TagFixtures.InternalWhitespaceTagClass.class);
Events.write("10d-invalid-tag-never-matches-filter.txt", filtered);
assertFalse(filtered.contains("InternalWhitespaceTagClass.stillRunsButTagIsDropped ran"),
"the invalid tag was dropped at discovery, so no filter can ever match it");
}
@Test
void aBlankTagFromATagsContainerIsAlsoJustDropped() {
List<String> log = RunHelper.run(TagFixtures.BlankTagClass.class);
Events.write("11-invalid-tag-blank.txt", log);
assertTrue(log.contains("BlankTagClass.neverActuallyRuns ran"), "the test body still runs");
}
}