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:
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
@@ -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 & !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>
|
||||
Executable
+26
@@ -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
|
||||
Executable
+18
@@ -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
|
||||
Executable
+24
@@ -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() {}
|
||||
}
|
||||
}
|
||||
+67
@@ -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/<name> 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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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 "&". Verified here, not assumed - the first run
|
||||
* proves OR (everything with "catalog" OR "fast" gets in, including the slow one), the second
|
||||
* proves "&" 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user