From bfc67986bccbdf19453c1a9f4ec70451843316cf Mon Sep 17 00:00:00 2001 From: asmhatre Date: Thu, 1 Oct 2026 04:38:28 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01YXCrLgRKFgCh9RHKW8xaqJ --- organising/.gitignore | 1 + organising/README.md | 59 +++++++++ organising/output/01-default-generator.txt | 3 + organising/output/02-replace-underscores.txt | 3 + organising/output/03-indicative-sentences.txt | 4 + organising/output/04-custom-generator.txt | 3 + organising/output/05-no-config-param.txt | 2 + .../06-configured-default-generator.txt | 2 + organising/output/07-tag-include-fast.txt | 6 + organising/output/08-tag-exclude-slow.txt | 6 + organising/output/09-tag-or-semantics.txt | 6 + organising/output/09b-tag-and-expression.txt | 6 + .../output/10-trimmed-tag-still-runs.txt | 4 + .../10b-trimmed-tag-matches-fast-filter.txt | 4 + .../10c-invalid-tag-internal-whitespace.txt | 4 + .../10d-invalid-tag-never-matches-filter.txt | 1 + organising/output/11-invalid-tag-blank.txt | 4 + .../12-nested-sibling-order-junit5.14.4.txt | 9 ++ .../output/12-nested-sibling-order-run1.txt | 9 ++ .../output/12-nested-sibling-order-run2.txt | 9 ++ .../output/12-nested-sibling-order-run3.txt | 9 ++ .../output/13-class-order-two-levels.txt | 10 ++ ...4-method-order-inheritance-junit5.14.4.txt | 8 ++ .../output/14-method-order-inheritance.txt | 8 ++ .../output/15-broken-compile-junit6.1.3.txt | 3 + .../output/16-broken-compile-junit5.14.4.txt | 8 ++ organising/output/17-mvn-cli-tag-filter.txt | 38 ++++++ organising/pom.xml | 62 ++++++++++ organising/scripts/broken-compile.sh | 26 ++++ organising/scripts/mvn-cli-tag-filter.sh | 18 +++ organising/scripts/run-all.sh | 24 ++++ .../src/broken/MethodOrdererDefaultDemo.java | 28 +++++ .../junit/organising/DisplayNameFixtures.java | 67 ++++++++++ .../junit/organising/DisplayNameTest.java | 61 ++++++++++ .../tutorials/junit/organising/Events.java | 31 +++++ .../tutorials/junit/organising/LoanDesk.java | 45 +++++++ .../junit/organising/LoanDeskTest.java | 84 +++++++++++++ .../junit/organising/OrderingFixtures.java | 79 ++++++++++++ .../junit/organising/OrderingTest.java | 36 ++++++ .../tutorials/junit/organising/RunHelper.java | 115 ++++++++++++++++++ .../organising/SentenceCaseGenerator.java | 48 ++++++++ .../junit/organising/TagFixtures.java | 73 +++++++++++ .../tutorials/junit/organising/TagTest.java | 98 +++++++++++++++ 43 files changed, 1124 insertions(+) create mode 100644 organising/.gitignore create mode 100644 organising/README.md create mode 100644 organising/output/01-default-generator.txt create mode 100644 organising/output/02-replace-underscores.txt create mode 100644 organising/output/03-indicative-sentences.txt create mode 100644 organising/output/04-custom-generator.txt create mode 100644 organising/output/05-no-config-param.txt create mode 100644 organising/output/06-configured-default-generator.txt create mode 100644 organising/output/07-tag-include-fast.txt create mode 100644 organising/output/08-tag-exclude-slow.txt create mode 100644 organising/output/09-tag-or-semantics.txt create mode 100644 organising/output/09b-tag-and-expression.txt create mode 100644 organising/output/10-trimmed-tag-still-runs.txt create mode 100644 organising/output/10b-trimmed-tag-matches-fast-filter.txt create mode 100644 organising/output/10c-invalid-tag-internal-whitespace.txt create mode 100644 organising/output/10d-invalid-tag-never-matches-filter.txt create mode 100644 organising/output/11-invalid-tag-blank.txt create mode 100644 organising/output/12-nested-sibling-order-junit5.14.4.txt create mode 100644 organising/output/12-nested-sibling-order-run1.txt create mode 100644 organising/output/12-nested-sibling-order-run2.txt create mode 100644 organising/output/12-nested-sibling-order-run3.txt create mode 100644 organising/output/13-class-order-two-levels.txt create mode 100644 organising/output/14-method-order-inheritance-junit5.14.4.txt create mode 100644 organising/output/14-method-order-inheritance.txt create mode 100644 organising/output/15-broken-compile-junit6.1.3.txt create mode 100644 organising/output/16-broken-compile-junit5.14.4.txt create mode 100644 organising/output/17-mvn-cli-tag-filter.txt create mode 100644 organising/pom.xml create mode 100755 organising/scripts/broken-compile.sh create mode 100755 organising/scripts/mvn-cli-tag-filter.sh create mode 100755 organising/scripts/run-all.sh create mode 100644 organising/src/broken/MethodOrdererDefaultDemo.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/DisplayNameFixtures.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/DisplayNameTest.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/Events.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/LoanDesk.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/LoanDeskTest.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/OrderingFixtures.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/OrderingTest.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/RunHelper.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/SentenceCaseGenerator.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/TagFixtures.java create mode 100644 organising/src/test/java/com/ankurm/tutorials/junit/organising/TagTest.java diff --git a/organising/.gitignore b/organising/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/organising/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/organising/README.md b/organising/README.md new file mode 100644 index 0000000..115a31e --- /dev/null +++ b/organising/README.md @@ -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. diff --git a/organising/output/01-default-generator.txt b/organising/output/01-default-generator.txt new file mode 100644 index 0000000..547ea15 --- /dev/null +++ b/organising/output/01-default-generator.txt @@ -0,0 +1,3 @@ ++ DisplayNameFixtures$PlainDefault + - total_price_is_zero_when_cart_is_empty() + - addingAnItemIncreasesTheCount() diff --git a/organising/output/02-replace-underscores.txt b/organising/output/02-replace-underscores.txt new file mode 100644 index 0000000..015037d --- /dev/null +++ b/organising/output/02-replace-underscores.txt @@ -0,0 +1,3 @@ ++ DisplayNameFixtures$Underscored + - total price is zero when cart is empty + - adding an item increases the count diff --git a/organising/output/03-indicative-sentences.txt b/organising/output/03-indicative-sentences.txt new file mode 100644 index 0000000..f3bfea8 --- /dev/null +++ b/organising/output/03-indicative-sentences.txt @@ -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 diff --git a/organising/output/04-custom-generator.txt b/organising/output/04-custom-generator.txt new file mode 100644 index 0000000..0f28a2b --- /dev/null +++ b/organising/output/04-custom-generator.txt @@ -0,0 +1,3 @@ ++ Custom sentence case + - Should report zero for an empty cart() + - Should reject a negative quantity(TestInfo) diff --git a/organising/output/05-no-config-param.txt b/organising/output/05-no-config-param.txt new file mode 100644 index 0000000..31fc7cc --- /dev/null +++ b/organising/output/05-no-config-param.txt @@ -0,0 +1,2 @@ ++ DisplayNameFixtures$ConfiguredByDefault + - total_price_is_zero_when_cart_is_empty() diff --git a/organising/output/06-configured-default-generator.txt b/organising/output/06-configured-default-generator.txt new file mode 100644 index 0000000..57162e6 --- /dev/null +++ b/organising/output/06-configured-default-generator.txt @@ -0,0 +1,2 @@ ++ DisplayNameFixtures$ConfiguredByDefault + - total price is zero when cart is empty diff --git a/organising/output/07-tag-include-fast.txt b/organising/output/07-tag-include-fast.txt new file mode 100644 index 0000000..7972f7c --- /dev/null +++ b/organising/output/07-tag-include-fast.txt @@ -0,0 +1,6 @@ +catalog.priceLookupIsCaseInsensitive ran +catalog.emptyQueryReturnsNoResults ran +-- results -- +SUCCESSFUL priceLookupIsCaseInsensitive() +SUCCESSFUL emptyQueryReturnsNoResults() +SUCCESSFUL TagFixtures$CatalogServiceTest diff --git a/organising/output/08-tag-exclude-slow.txt b/organising/output/08-tag-exclude-slow.txt new file mode 100644 index 0000000..7972f7c --- /dev/null +++ b/organising/output/08-tag-exclude-slow.txt @@ -0,0 +1,6 @@ +catalog.priceLookupIsCaseInsensitive ran +catalog.emptyQueryReturnsNoResults ran +-- results -- +SUCCESSFUL priceLookupIsCaseInsensitive() +SUCCESSFUL emptyQueryReturnsNoResults() +SUCCESSFUL TagFixtures$CatalogServiceTest diff --git a/organising/output/09-tag-or-semantics.txt b/organising/output/09-tag-or-semantics.txt new file mode 100644 index 0000000..786118f --- /dev/null +++ b/organising/output/09-tag-or-semantics.txt @@ -0,0 +1,6 @@ +catalog.reindexingHitsTheRealSearchCluster ran +catalog.priceLookupIsCaseInsensitive ran +-- results -- +SUCCESSFUL reindexingHitsTheRealSearchCluster() +SUCCESSFUL priceLookupIsCaseInsensitive() +SUCCESSFUL TagFixtures$CatalogServiceTest diff --git a/organising/output/09b-tag-and-expression.txt b/organising/output/09b-tag-and-expression.txt new file mode 100644 index 0000000..7972f7c --- /dev/null +++ b/organising/output/09b-tag-and-expression.txt @@ -0,0 +1,6 @@ +catalog.priceLookupIsCaseInsensitive ran +catalog.emptyQueryReturnsNoResults ran +-- results -- +SUCCESSFUL priceLookupIsCaseInsensitive() +SUCCESSFUL emptyQueryReturnsNoResults() +SUCCESSFUL TagFixtures$CatalogServiceTest diff --git a/organising/output/10-trimmed-tag-still-runs.txt b/organising/output/10-trimmed-tag-still-runs.txt new file mode 100644 index 0000000..e174387 --- /dev/null +++ b/organising/output/10-trimmed-tag-still-runs.txt @@ -0,0 +1,4 @@ +TrimmedTagClass.stillRuns ran +-- results -- +SUCCESSFUL stillRuns() +SUCCESSFUL TagFixtures$TrimmedTagClass diff --git a/organising/output/10b-trimmed-tag-matches-fast-filter.txt b/organising/output/10b-trimmed-tag-matches-fast-filter.txt new file mode 100644 index 0000000..e174387 --- /dev/null +++ b/organising/output/10b-trimmed-tag-matches-fast-filter.txt @@ -0,0 +1,4 @@ +TrimmedTagClass.stillRuns ran +-- results -- +SUCCESSFUL stillRuns() +SUCCESSFUL TagFixtures$TrimmedTagClass diff --git a/organising/output/10c-invalid-tag-internal-whitespace.txt b/organising/output/10c-invalid-tag-internal-whitespace.txt new file mode 100644 index 0000000..cbe5698 --- /dev/null +++ b/organising/output/10c-invalid-tag-internal-whitespace.txt @@ -0,0 +1,4 @@ +InternalWhitespaceTagClass.stillRunsButTagIsDropped ran +-- results -- +SUCCESSFUL stillRunsButTagIsDropped() +SUCCESSFUL TagFixtures$InternalWhitespaceTagClass diff --git a/organising/output/10d-invalid-tag-never-matches-filter.txt b/organising/output/10d-invalid-tag-never-matches-filter.txt new file mode 100644 index 0000000..5268677 --- /dev/null +++ b/organising/output/10d-invalid-tag-never-matches-filter.txt @@ -0,0 +1 @@ +-- results -- diff --git a/organising/output/11-invalid-tag-blank.txt b/organising/output/11-invalid-tag-blank.txt new file mode 100644 index 0000000..f7e9449 --- /dev/null +++ b/organising/output/11-invalid-tag-blank.txt @@ -0,0 +1,4 @@ +BlankTagClass.neverActuallyRuns ran +-- results -- +SUCCESSFUL neverActuallyRuns() +SUCCESSFUL TagFixtures$BlankTagClass diff --git a/organising/output/12-nested-sibling-order-junit5.14.4.txt b/organising/output/12-nested-sibling-order-junit5.14.4.txt new file mode 100644 index 0000000..1e8698b --- /dev/null +++ b/organising/output/12-nested-sibling-order-junit5.14.4.txt @@ -0,0 +1,9 @@ ++ OrderingFixtures$DefaultSiblingOrder + + Delta + - t() + + Alpha + - t() + + Mike + - t() + + Zebra + - t() diff --git a/organising/output/12-nested-sibling-order-run1.txt b/organising/output/12-nested-sibling-order-run1.txt new file mode 100644 index 0000000..e59ba7f --- /dev/null +++ b/organising/output/12-nested-sibling-order-run1.txt @@ -0,0 +1,9 @@ ++ OrderingFixtures$DefaultSiblingOrder + + Alpha + - t() + + Delta + - t() + + Zebra + - t() + + Mike + - t() diff --git a/organising/output/12-nested-sibling-order-run2.txt b/organising/output/12-nested-sibling-order-run2.txt new file mode 100644 index 0000000..e59ba7f --- /dev/null +++ b/organising/output/12-nested-sibling-order-run2.txt @@ -0,0 +1,9 @@ ++ OrderingFixtures$DefaultSiblingOrder + + Alpha + - t() + + Delta + - t() + + Zebra + - t() + + Mike + - t() diff --git a/organising/output/12-nested-sibling-order-run3.txt b/organising/output/12-nested-sibling-order-run3.txt new file mode 100644 index 0000000..e59ba7f --- /dev/null +++ b/organising/output/12-nested-sibling-order-run3.txt @@ -0,0 +1,9 @@ ++ OrderingFixtures$DefaultSiblingOrder + + Alpha + - t() + + Delta + - t() + + Zebra + - t() + + Mike + - t() diff --git a/organising/output/13-class-order-two-levels.txt b/organising/output/13-class-order-two-levels.txt new file mode 100644 index 0000000..bb2a44a --- /dev/null +++ b/organising/output/13-class-order-two-levels.txt @@ -0,0 +1,10 @@ ++ OrderingFixtures$ClassOrderOuter + + Alpha + - t() + + Mike + - t() + + Zebra + + Charlie + - t() + + Delta + - t() diff --git a/organising/output/14-method-order-inheritance-junit5.14.4.txt b/organising/output/14-method-order-inheritance-junit5.14.4.txt new file mode 100644 index 0000000..357396d --- /dev/null +++ b/organising/output/14-method-order-inheritance-junit5.14.4.txt @@ -0,0 +1,8 @@ ++ OrderingFixtures$MethodOrderInheritanceOuter + - alpha() + - bravo() + - charlie() + + PlainNestedChild + - yankee() + - xray() + - zulu() diff --git a/organising/output/14-method-order-inheritance.txt b/organising/output/14-method-order-inheritance.txt new file mode 100644 index 0000000..d58397e --- /dev/null +++ b/organising/output/14-method-order-inheritance.txt @@ -0,0 +1,8 @@ ++ OrderingFixtures$MethodOrderInheritanceOuter + - alpha() + - bravo() + - charlie() + + PlainNestedChild + - xray() + - yankee() + - zulu() diff --git a/organising/output/15-broken-compile-junit6.1.3.txt b/organising/output/15-broken-compile-junit6.1.3.txt new file mode 100644 index 0000000..1c7511c --- /dev/null +++ b/organising/output/15-broken-compile-junit6.1.3.txt @@ -0,0 +1,3 @@ +$ javac -cp 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) diff --git a/organising/output/16-broken-compile-junit5.14.4.txt b/organising/output/16-broken-compile-junit5.14.4.txt new file mode 100644 index 0000000..d6349e4 --- /dev/null +++ b/organising/output/16-broken-compile-junit5.14.4.txt @@ -0,0 +1,8 @@ +$ javac -cp 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 diff --git a/organising/output/17-mvn-cli-tag-filter.txt b/organising/output/17-mvn-cli-tag-filter.txt new file mode 100644 index 0000000..5f25060 --- /dev/null +++ b/organising/output/17-mvn-cli-tag-filter.txt @@ -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: diff --git a/organising/pom.xml b/organising/pom.xml new file mode 100644 index 0000000..e0163f0 --- /dev/null +++ b/organising/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + com.ankurTutorials + organising-demo + 1.0-SNAPSHOT + + + 17 + UTF-8 + + 6.1.3 + + + + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + org.junit.jupiter + junit-jupiter + test + + + + org.junit.platform + junit-platform-launcher + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + ${groups} + ${excludedGroups} + + + + + diff --git a/organising/scripts/broken-compile.sh b/organising/scripts/broken-compile.sh new file mode 100755 index 0000000..83f6f08 --- /dev/null +++ b/organising/scripts/broken-compile.sh @@ -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 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 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 diff --git a/organising/scripts/mvn-cli-tag-filter.sh b/organising/scripts/mvn-cli-tag-filter.sh new file mode 100755 index 0000000..3531ff8 --- /dev/null +++ b/organising/scripts/mvn-cli-tag-filter.sh @@ -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 diff --git a/organising/scripts/run-all.sh b/organising/scripts/run-all.sh new file mode 100755 index 0000000..c2ab61f --- /dev/null +++ b/organising/scripts/run-all.sh @@ -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." diff --git a/organising/src/broken/MethodOrdererDefaultDemo.java b/organising/src/broken/MethodOrdererDefaultDemo.java new file mode 100644 index 0000000..4482c6b --- /dev/null +++ b/organising/src/broken/MethodOrdererDefaultDemo.java @@ -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() {} + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/DisplayNameFixtures.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/DisplayNameFixtures.java new file mode 100644 index 0000000..8ac986c --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/DisplayNameFixtures.java @@ -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() {} + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/DisplayNameTest.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/DisplayNameTest.java new file mode 100644 index 0000000..4ce1387 --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/DisplayNameTest.java @@ -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 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 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 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 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 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 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"))); + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/Events.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/Events.java new file mode 100644 index 0000000..e61215c --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/Events.java @@ -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 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 snapshot() { return new ArrayList<>(LOG); } + + /** Writes the lines to output/<name> relative to the module directory. */ + public static void write(String name, List lines) { + try { + Path dir = Path.of("output"); + Files.createDirectories(dir); + Files.write(dir.resolve(name), lines); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/LoanDesk.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/LoanDesk.java new file mode 100644 index 0000000..1da3fd5 --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/LoanDesk.java @@ -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 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; + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/LoanDeskTest.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/LoanDeskTest.java new file mode 100644 index 0000000..a03e1ae --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/LoanDeskTest.java @@ -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()); + } + } + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/OrderingFixtures.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/OrderingFixtures.java new file mode 100644 index 0000000..c0c8ecb --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/OrderingFixtures.java @@ -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() {} + } + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/OrderingTest.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/OrderingTest.java new file mode 100644 index 0000000..9d0378f --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/OrderingTest.java @@ -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 run1 = RunHelper.displayTree(OrderingFixtures.DefaultSiblingOrder.class); + List run2 = RunHelper.displayTree(OrderingFixtures.DefaultSiblingOrder.class); + List 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 tree = RunHelper.displayTree(OrderingFixtures.ClassOrderOuter.class); + Events.write("13-class-order-two-levels.txt", tree); + } + + @Test + void testMethodOrderOnOuterClassAndWhatThePlainNestedChildDoes() { + List 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 outerOnly = tree.stream().filter(s -> s.startsWith(" - ")).toList(); + assertEquals(List.of(" - alpha()", " - bravo()", " - charlie()"), outerOnly); + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/RunHelper.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/RunHelper.java new file mode 100644 index 0000000..e31ddfe --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/RunHelper.java @@ -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 run(Class... classes) { + return run(Map.of(), (PostDiscoveryFilter[]) null, classes); + } + + static List run(Map config, Class... classes) { + return run(config, (PostDiscoveryFilter[]) null, classes); + } + + static List run(Map config, PostDiscoveryFilter[] filters, Class... classes) { + Events.clear(); + var b = LauncherDiscoveryRequestBuilder.request(); + List 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 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 ? "" : id.getDisplayName(), what)); + } + }; + LauncherFactory.create().execute(request, l); + List 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 displayTree(Class... classes) { + return displayTreeWithConfig(Map.of(), classes); + } + + static List displayTreeWithConfig(Map config, Class... classes) { + var b = LauncherDiscoveryRequestBuilder.request(); + for (Class c : classes) b.selectors(selectClass(c)); + config.forEach(b::configurationParameter); + LauncherDiscoveryRequest request = b.build(); + List 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 runWithTags(List include, List exclude, Class... classes) { + List 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); + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/SentenceCaseGenerator.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/SentenceCaseGenerator.java new file mode 100644 index 0000000..289eec2 --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/SentenceCaseGenerator.java @@ -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> enclosingInstanceTypes, Class nestedClass) { + return toSentence(nestedClass.getSimpleName()); + } + + @Override + public String generateDisplayNameForMethod(java.util.List> 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(); + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/TagFixtures.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/TagFixtures.java new file mode 100644 index 0000000..37fbebb --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/TagFixtures.java @@ -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"); + } + } +} diff --git a/organising/src/test/java/com/ankurm/tutorials/junit/organising/TagTest.java b/organising/src/test/java/com/ankurm/tutorials/junit/organising/TagTest.java new file mode 100644 index 0000000..3e8da81 --- /dev/null +++ b/organising/src/test/java/com/ankurm/tutorials/junit/organising/TagTest.java @@ -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 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 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 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 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 fullRun = RunHelper.run(TagFixtures.TrimmedTagClass.class); + Events.write("10-trimmed-tag-still-runs.txt", fullRun); + assertTrue(fullRun.contains("TrimmedTagClass.stillRuns ran")); + + List 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 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 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 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"); + } +}