Add parameterized module: JUnit 5 @ParameterizedTest argument sources, display names, and two real anti-pattern failures

This commit is contained in:
Claude
2026-10-03 21:50:52 +00:00
parent 8be1245951
commit ee75bfa792
45 changed files with 1179 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
# JUnit 5 @ParameterizedTest — Companion Module
Companion module for [The Complete Guide to JUnit 5 @ParameterizedTest: Write Smarter,
Faster, and Cleaner Java Tests](https://ankurm.com/the-complete-guide-to-junit-5-parameterizedtest-write-smarter-faster-and-cleaner-java-tests/)
on [ankurm.com](https://ankurm.com). Every code sample and every console transcript quoted
in that post comes from the files in this directory — nothing was hand-typed into the
article.
This module backs a **rewrite** of a post that first went up when JUnit 5 was on the
5.x line. Rebuilding every example from scratch against current JUnit surfaced three real
bugs in the original article's own code samples (see "What changed from the original post"
below) — this is not a cosmetic refresh.
## Versions this was built and run against
| Component | Version | Notes |
|---|---|---|
| JUnit Jupiter / Platform | **6.1.3** | current GA per `maven-metadata.xml` on Maven Central at the time of writing — a major-version jump from the 5.x line the original post was written against |
| JDK | **25 (Temurin, LTS)** | build and run; `maven.compiler.release` is set to 17 |
| Maven | 3.9.11 | |
| Maven Surefire Plugin | 3.5.2 | |
| JUnit Platform Console Standalone | 6.1.3 | used only to capture per-invocation display names for `docs/output/` — not a runtime dependency of the module itself |
`junit-jupiter` is the only test-scoped dependency in `pom.xml`; it transitively pulls in
`junit-jupiter-params`, so no separate dependency is needed to use `@ParameterizedTest` and
its argument sources.
## Quickstart
```bash
mvn test # all 17 test classes, ~3s total, 57 tests, 2 intentionally skipped
mvn test -Dtest=CsvSourceTest # just the @CsvSource examples
mvn test -Dtest=HeaderNamePlaceholderMistakeTest # the disabled "what breaks" exhibit (passes trivially while disabled)
```
To see the real per-invocation display names this module's post quotes (Surefire's own
summary only reports pass/fail counts, not individual names), run the
[JUnit Platform Console Standalone](https://junit.org/junit5/docs/current/user-guide/#running-tests-console-launcher)
launcher directly:
```bash
mvn -q test-compile
java -jar junit-platform-console-standalone-6.1.3.jar execute --details=tree \
--class-path target/test-classes:target/classes \
--select-class com.ankurm.tutorials.junit.parameterized.CsvSourceTest
```
To see either of the two deliberately-disabled "what breaks" exhibits fail for real, remove
the `@Disabled` annotation from the method named in the comment above it and re-run `mvn test
-Dtest=<ThatClass>` — then put the annotation back before committing, which is exactly how
`docs/output/06-header-name-placeholder-throws.txt` and
`docs/output/09-shared-mutable-argument-failure.txt` were captured.
## Source files
| File | Demonstrates |
|---|---|
| [`ValueSourceTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/ValueSourceTest.java) | `@ValueSource` — the simplest single-argument source |
| [`NullAndEmptySourceTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/NullAndEmptySourceTest.java) | `@NullAndEmptySource` combined with `@ValueSource` for a full blank-input sweep |
| [`CsvSourceTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/CsvSourceTest.java) | `@CsvSource` — inline rows, a custom `name`, and `useHeadersInDisplayName` interacting with that custom `name` |
| [`CsvFileSourceTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/CsvFileSourceTest.java) + [`postcode-regions.csv`](src/test/resources/test-data/postcode-regions.csv) | `@CsvFileSource` reading real rows from a real classpath file |
| [`EnumSourceTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/EnumSourceTest.java) | `@EnumSource` in both `INCLUDE` and `EXCLUDE` mode over the same enum |
| [`MethodSourceTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/MethodSourceTest.java) | `@MethodSource` producing real domain objects (`User`) rather than primitives |
| [`FieldSourceTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/FieldSourceTest.java) | `@FieldSource` reading arguments from a static field kept in lock-step with production code |
| [`ScenarioArgumentsProvider.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/ScenarioArgumentsProvider.java) + [`ArgumentsSourceTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/ArgumentsSourceTest.java) | `@ArgumentsSource` with a standalone `ArgumentsProvider`, using the current (non-deprecated) `provideArguments(ParameterDeclarations, ExtensionContext)` overload |
| [`DashDateConverter.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/DashDateConverter.java) + [`ConvertWithTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/ConvertWithTest.java) | `@ConvertWith` and a custom `SimpleArgumentConverter` |
| [`MixedAssertionsAntiPatternTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/MixedAssertionsAntiPatternTest.java) | the BAD shape (two unrelated assertions in one parameterized test) next to the GOOD fix (split into two focused tests) |
| [`DisplayNameTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/DisplayNameTest.java) | a custom `name` combining `{index}` with positional placeholders and a literal Unicode character |
| [`HeaderNamePlaceholderMistakeTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/HeaderNamePlaceholderMistakeTest.java) | **what breaks**: a `name` template referencing a CSV header by name instead of position — kept `@Disabled` so the build stays green |
| [`SharedMutableArgumentAntiPatternTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/SharedMutableArgumentAntiPatternTest.java) + [`Config.java`](src/main/java/com/ankurm/tutorials/junit/parameterized/Config.java) | **what breaks**: one shared mutable argument instance handed to every invocation, next to the fresh-instance-per-invocation fix — the failing half kept `@Disabled` so the build stays green |
| `Calculator.java`, `UserService.java`, `Geocoder.java`, `Role.java`, `AuthService.java`, `User.java`, `CurrencyService.java`, `Cms.java`, `ScenarioProcessor.java` | the small production classes every test above exercises for real — none of them exist solely for the test to pass trivially |
## Captured output
| File | What it shows |
|---|---|
| [`01-value-source-palindrome-check.txt`](docs/output/01-value-source-palindrome-check.txt) | clean positional display names, and the nested-quoting mistake they fix |
| [`02-null-and-empty-source-blank-usernames.txt`](docs/output/02-null-and-empty-source-blank-usernames.txt) | all five blank-input cases, including real tab and newline characters |
| [`03-csv-source-add-numbers.txt`](docs/output/03-csv-source-add-numbers.txt) | the plainest inline `@CsvSource` shape |
| [`04-csv-source-custom-name-with-headers.txt`](docs/output/04-csv-source-custom-name-with-headers.txt) | the genuinely new finding: `useHeadersInDisplayName` changes what a *custom* name's `{0}`/`{1}` placeholders resolve to, not just the default name |
| [`05-csv-source-headers-default-display-name.txt`](docs/output/05-csv-source-headers-default-display-name.txt) | the same flag's documented effect on the auto-generated default name, isolated for comparison against 04 |
| [`06-header-name-placeholder-throws.txt`](docs/output/06-header-name-placeholder-throws.txt) | the real `JUnitException`/`NumberFormatException` from referencing a header by name inside a `name` template |
| [`07-csv-file-source-postcode-region.txt`](docs/output/07-csv-file-source-postcode-region.txt) | `@CsvFileSource` reading a real file |
| [`08-enum-source-role-write-access.txt`](docs/output/08-enum-source-role-write-access.txt) | `INCLUDE` and `EXCLUDE` mode together covering every enum constant exactly once |
| [`09-shared-mutable-argument-failure.txt`](docs/output/09-shared-mutable-argument-failure.txt) | the real `AssertionFailedError` from a shared mutable argument's second invocation |
| [`10-shared-mutable-argument-fixed.txt`](docs/output/10-shared-mutable-argument-fixed.txt) | the fresh-instance fix, passing, plus why both display names correctly read `callCount=0` |
| [`11-method-source-user-active-status.txt`](docs/output/11-method-source-user-active-status.txt) | `@MethodSource` producing real `User` objects with corrected, coherent semantics |
| [`12-field-source-iso-currency-codes.txt`](docs/output/12-field-source-iso-currency-codes.txt) | `@FieldSource` kept in lock-step with the real production `Set` |
| [`13-arguments-source-external-scenarios.txt`](docs/output/13-arguments-source-external-scenarios.txt) | a standalone `ArgumentsProvider`, on the current non-deprecated overload |
| [`14-convert-with-dates-in-2023.txt`](docs/output/14-convert-with-dates-in-2023.txt) | `@ConvertWith` and a custom `SimpleArgumentConverter` |
| [`15-mixed-assertions-anti-pattern.txt`](docs/output/15-mixed-assertions-anti-pattern.txt) | the two-assertions-in-one-test smell next to its single-assertion fix |
| [`16-display-name-custom-multiply.txt`](docs/output/16-display-name-custom-multiply.txt) | `{index}` combined with positional placeholders and a literal Unicode character |
| [`17-full-suite-final.txt`](docs/output/17-full-suite-final.txt) | all 57 tests, exactly as committed, green in under four seconds, 2 intentional skips |
## What changed from the original post
Rebuilding every example against current JUnit (rather than copying the original article's
code) surfaced three real defects in the **original published post**, not in JUnit itself:
- The original `@MethodSource` example's three expected `isActive()` outcomes could not be
satisfied by any single-field "still valid" rule. `User` here uses a `renewalDueDate` plus
a 3-day grace period plus an `enabled` flag — see
[`11-method-source-user-active-status.txt`](docs/output/11-method-source-user-active-status.txt)
for why all three original expected outcomes now genuinely hold.
- The original `@NullAndEmptySource` example used the literal letters `"t"` and `"n"` where
it clearly meant the escape sequences `"\t"` and `"\n"` — the letters are not blank per
`String.isBlank()`, which silently broke the example's own stated premise. Fixed here to
use real tab and newline characters.
- The original custom `name` template referenced CSV header names directly (`{USER_ID}`,
`{ROLE}`) alongside `useHeadersInDisplayName = true`. That is not valid syntax —
`@ParameterizedTest`'s `name` attribute compiles to a real `java.text.MessageFormat`
pattern, which only understands numeric positional indices. Rather than silently fixing
this and hiding the mistake, it is kept here as
[`HeaderNamePlaceholderMistakeTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/HeaderNamePlaceholderMistakeTest.java),
a deliberately `@Disabled` exhibit with its real failure captured in
[`06-header-name-placeholder-throws.txt`](docs/output/06-header-name-placeholder-throws.txt).
Two further things were added that the original post didn't cover at all: the
shared-mutable-argument anti-pattern
([`SharedMutableArgumentAntiPatternTest.java`](src/test/java/com/ankurm/tutorials/junit/parameterized/SharedMutableArgumentAntiPatternTest.java)),
and the interaction between `useHeadersInDisplayName` and a custom `name` template's own
positional placeholders (04/05 above) — neither is documented anywhere as clearly as a real,
run, captured failure makes it.
## License
MIT, matching the rest of this repository.
@@ -0,0 +1,24 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
ValueSourceTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ ValueSourceTest ✔
│ └─ shouldIdentifyPalindromes(String) ✔
│ ├─ "racecar" should be a palindrome ✔
│ ├─ "radar" should be a palindrome ✔
│ └─ "level" should be a palindrome ✔
└─ JUnit Vintage ✔
Test run finished after 171 ms
[ 3 tests successful ]
[ 0 tests failed ]
name = "{0} should be a palindrome" is the whole trick: {0} is a positional reference to the
single @ValueSource argument, and the console runner always quotes a String argument's own text
when it substitutes it in -- which is why the output reads "racecar" should be a palindrome
rather than requiring quotes inside the template itself. An earlier draft of this template used
name = "''{0}'' should be a palindrome", which doubles up with the console's own quoting and
renders as the much uglier '"racecar"' should be a palindrome -- fixed here before publishing by
simply dropping the extra quote marks from the template.
@@ -0,0 +1,23 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
NullAndEmptySourceTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ NullAndEmptySourceTest ✔
│ └─ shouldRejectBlankUsernames(String) ✔
│ ├─ blank username [null] should be rejected ✔
│ ├─ blank username [""] should be rejected ✔
│ ├─ blank username [" "] should be rejected ✔
│ ├─ blank username ["\t"] should be rejected ✔
│ └─ blank username ["\n"] should be rejected ✔
└─ JUnit Vintage ✔
Test run finished after 185 ms
[ 5 tests successful ]
[ 0 tests failed ]
Five invocations, not three: @NullAndEmptySource contributes the null and "" cases, and the
@ValueSource(strings = {" ", "\t", "\n"}) alongside it contributes the other three. All five are
blank per String.isBlank() -- which is the actual point of the test, and the reason it needs real
tab and newline characters rather than the letters "t" and "n", which are not blank at all.
@@ -0,0 +1,20 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
CsvSourceTest#shouldAddNumbers, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ CsvSourceTest ✔
│ └─ shouldAddNumbers(int, int, int) ✔
│ ├─ "10" + "5" = "15" ✔
│ ├─ "0" + "0" = "0" ✔
│ ├─ "-3" + "7" = "4" ✔
│ └─ "100" + "-50" = "50" ✔
└─ JUnit Vintage ✔
Test run finished after 228 ms
[ 4 tests successful ]
[ 0 tests failed ]
The plainest @CsvSource shape: one inline comma-separated row per invocation, three columns
mapped positionally onto the method's three int parameters, no header row involved at all.
@@ -0,0 +1,27 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
CsvSourceTest#testPublishingPermissions, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ CsvSourceTest ✔
│ └─ testPublishingPermissions(int, String, boolean) ✔
│ ├─ user USER_ID = "1001" with role ROLE = "EDITOR" ✔
│ ├─ user USER_ID = "1002" with role ROLE = "ADMIN" ✔
│ └─ user USER_ID = "1003" with role ROLE = "USER" ✔
└─ JUnit Vintage ✔
Test run finished after 228 ms
[ 3 tests successful ]
[ 0 tests failed ]
This is the genuinely new, easy-to-miss finding in this rewrite. The custom template is
name = "user {0} with role {1}" -- plain positional placeholders, the same {0}/{1} syntax used
everywhere else in this module. But because @CsvSource(useHeadersInDisplayName = true) is also
set, each individual {0} and {1} does not resolve to the bare value ("1001", "EDITOR") the way
it would without that flag -- it resolves to "HEADER = value" ("USER_ID = \"1001\"",
"ROLE = \"EDITOR\""). useHeadersInDisplayName changes what every positional placeholder expands
to, not just the auto-generated default name used when no custom name is given at all. Compare
this against docs/output/05-csv-source-headers-default-display-name.txt (no custom name, same
data, same flag) and docs/output/06-header-name-placeholder-throws.txt (an attempt to put the
header name directly inside the template, which is a different thing entirely and throws).
@@ -0,0 +1,24 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
CsvSourceTest#testPublishingPermissionsDefaultDisplayName, compiled and executed for real. Same
@CsvSource(useHeadersInDisplayName = true) data as testPublishingPermissions, but with no custom
`name` attribute at all, to isolate what the flag does on its own.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ CsvSourceTest ✔
│ └─ testPublishingPermissionsDefaultDisplayName(int, String, boolean) ✔
│ └─ [1] USER_ID = "1001", ROLE = "EDITOR", CAN_PUBLISH = "true" ✔
└─ JUnit Vintage ✔
Test run finished after 228 ms
[ 1 tests successful ]
[ 0 tests failed ]
With no custom name, useHeadersInDisplayName = true changes JUnit's own auto-generated default
from the bare [1] "1001", "EDITOR", "true" you would otherwise get to the header-qualified
[1] USER_ID = "1001", ROLE = "EDITOR", CAN_PUBLISH = "true" shown here. That is the documented,
intended effect of the flag. The surprise in
docs/output/04-csv-source-custom-name-with-headers.txt is that the exact same flag also changes
what a *custom* name's positional placeholders resolve to, which is easy to miss if you only
ever tested the flag against the default name.
@@ -0,0 +1,33 @@
Captured from a real `mvn test -Dtest=HeaderNamePlaceholderMistakeTest` run with the
@Disabled annotation temporarily removed -- i.e. exactly
name = "user {USER_ID} with role {ROLE}" on a real @CsvSource(useHeadersInDisplayName = true)
test, run for real.
-------------------------------------------------------------------------------
Test set: com.ankurm.tutorials.junit.parameterized.HeaderNamePlaceholderMistakeTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.050 s <<< FAILURE! -- in com.ankurm.tutorials.junit.parameterized.HeaderNamePlaceholderMistakeTest
com.ankurm.tutorials.junit.parameterized.HeaderNamePlaceholderMistakeTest.nameTemplateCannotReferenceHeadersByName(int, String, boolean) -- Time elapsed: 0.034 s <<< ERROR!
org.junit.platform.commons.JUnitException: The display name pattern defined for the parameterized test is invalid. See nested exception for further details.
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
Caused by: java.lang.IllegalArgumentException: can't parse argument number: USER_ID
at java.base/java.text.MessageFormat.setFormatFromPattern(MessageFormat.java:1644)
at java.base/java.text.MessageFormat.applyPatternImpl(MessageFormat.java:660)
at java.base/java.text.MessageFormat.<init>(MessageFormat.java:516)
... 2 more
Caused by: java.lang.NumberFormatException: For input string: "USER_ID"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:565)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at java.base/java.text.MessageFormat.setFormatFromPattern(MessageFormat.java:1642)
... 4 more
The stack trace gives away the mechanism: @ParameterizedTest's `name` attribute is not its own
template language -- it compiles down to a real java.text.MessageFormat pattern, and
MessageFormat only understands numeric argument indices ({0}, {1}, ...). "USER_ID" is not a
number, so MessageFormat's own pattern parser throws NumberFormatException trying to read it
as one, before the test ever runs a single invocation. useHeadersInDisplayName = true changes
what JUnit generates automatically when no custom name is given (see
docs/output/05-csv-source-headers-default-display-name.txt) -- it does not add a new kind of
placeholder you can reference yourself.
@@ -0,0 +1,23 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
CsvFileSourceTest, compiled and executed for real against the committed
src/test/resources/test-data/postcode-regions.csv fixture.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ CsvFileSourceTest ✔
│ └─ shouldMapPostcodeToRegion(String, String) ✔
│ ├─ postcode "SW1A" should map to region "London" ✔
│ ├─ postcode "M1" should map to region "Manchester" ✔
│ ├─ postcode "EH1" should map to region "Edinburgh" ✔
│ ├─ postcode "CF10" should map to region "Cardiff" ✔
│ └─ postcode "BT1" should map to region "Belfast" ✔
└─ JUnit Vintage ✔
Test run finished after 208 ms
[ 5 tests successful ]
[ 0 tests failed ]
@CsvFileSource reads real rows from a real file on the classpath instead of inline strings --
the five rows above are read from the CSV fixture's header row (postcode,region) plus five data
rows, which exactly match the five entries Geocoder.java hardcodes internally.
@@ -0,0 +1,25 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
EnumSourceTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ EnumSourceTest ✔
│ ├─ nonPrivilegedRolesMustNotWrite(Role) ✔
│ │ ├─ EDITOR must NOT have write access ✔
│ │ ├─ USER must NOT have write access ✔
│ │ └─ GUEST must NOT have write access ✔
│ └─ privilegedRolesShouldHaveWriteAccess(Role) ✔
│ ├─ ADMIN should have write access ✔
│ └─ SUPERUSER should have write access ✔
└─ JUnit Vintage ✔
Test run finished after 212 ms
[ 5 tests successful ]
[ 0 tests failed ]
Two @EnumSource tests over the same Role enum, using the two opposite selection modes:
privilegedRolesShouldHaveWriteAccess uses mode = INCLUDE with names = {"ADMIN", "SUPERUSER"},
and nonPrivilegedRolesMustNotWrite uses mode = EXCLUDE with the same two names -- so between
them every one of the five Role constants is exercised exactly once, and AuthService.canWrite's
real logic is what each assertion checks against.
@@ -0,0 +1,19 @@
Captured from a real `mvn test` run with badSharedConfigAccumulatesTouches NOT yet marked
@Disabled -- i.e. exactly the code shape in the post's "BAD" example, run for real.
-------------------------------------------------------------------------------
Test set: com.ankurm.tutorials.junit.parameterized.SharedMutableArgumentAntiPatternTest
-------------------------------------------------------------------------------
Tests run: 4, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.017 s <<< FAILURE! -- in com.ankurm.tutorials.junit.parameterized.SharedMutableArgumentAntiPatternTest
com.ankurm.tutorials.junit.parameterized.SharedMutableArgumentAntiPatternTest.badSharedConfigAccumulatesTouches(Config)[2] -- Time elapsed: 0.005 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
at com.ankurm.tutorials.junit.parameterized.SharedMutableArgumentAntiPatternTest.badSharedConfigAccumulatesTouches(SharedMutableArgumentAntiPatternTest.java:23)
The first invocation passes (expected 1, got 1 -- the shared Config has been touched once).
The second invocation fails with exactly the same assertion, same line number, same test
method -- but now expects 1 and gets 2, because it is the SAME Config instance the first
invocation already touched. Nothing about invocation 2's own code is wrong; the bug is that
sharedProvider() built one Config and handed it to both Arguments.of(...) calls. This is
exactly the failure mode the "fresh instance per invocation" fix in goodFreshConfigIsAlwaysTouchedOnce
avoids -- that test passes all the way through for the identical reason this one fails.
@@ -0,0 +1,26 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
SharedMutableArgumentAntiPatternTest, compiled and executed for real (badSharedConfigAccumulatesTouches
stays @Disabled here so the module's build remains green; see
docs/output/09-shared-mutable-argument-failure.txt for what it produces when that annotation is
removed).
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ SharedMutableArgumentAntiPatternTest ✔
│ ├─ badSharedConfigAccumulatesTouches(Config) ↷ Kept disabled so the module's build stays green -- see docs/output/09-shared-mutable-argument-failure.txt for the real failure this produces on its second invocation when the @Disabled annotation is removed.
│ └─ goodFreshConfigIsAlwaysTouchedOnce(Config) ✔
│ ├─ [1] Config[callCount=0] ✔
│ └─ [2] Config[callCount=0] ✔
└─ JUnit Vintage ✔
Test run finished after 161 ms
[ 2 tests successful ]
[ 0 tests failed ]
freshProvider() builds a new Config() inside the Stream for each Arguments.of(...) call, so each
invocation gets its own isolated instance -- the whole fix is that simple. Both display names
read Config[callCount=0], which is correct and not a bug: JUnit resolves each invocation's
display name from the argument's toString() at argument-resolution time, before the test method
body (and its config.touch() call) has run, so callCount is still 0 at the moment the name is
built even though the assertion afterwards confirms it becomes 1.
@@ -0,0 +1,25 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
MethodSourceTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ MethodSourceTest ✔
│ └─ testUserActiveStatus(User, boolean) ✔
│ ├─ user alice active=true ✔
│ ├─ user bob active=true ✔
│ └─ user carol active=false ✔
└─ JUnit Vintage ✔
Test run finished after 196 ms
[ 3 tests successful ]
[ 0 tests failed ]
activeUserProvider() hands out three real User objects built with real LocalDate renewal dates.
alice's renewalDueDate is one day in the past but still inside the 3-day grace period
isActive() allows, and she's enabled -- active. bob's is 30 days in the future and he's
enabled -- active. carol's date is identical to alice's (one day in the past, still within the
grace period), but she's enabled = false -- and isActive() requires both conditions, so she's
inactive purely because of the enabled flag, not the date. All three outcomes match
User.isActive()'s real logic -- see the module README for why this differs from the original
post's version of the same example.
@@ -0,0 +1,25 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
FieldSourceTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ FieldSourceTest ✔
│ └─ shouldRecognizeIsoCurrencyCode(String) ✔
│ ├─ ISO code "USD" should be recognized ✔
│ ├─ ISO code "EUR" should be recognized ✔
│ ├─ ISO code "GBP" should be recognized ✔
│ ├─ ISO code "JPY" should be recognized ✔
│ ├─ ISO code "INR" should be recognized ✔
│ ├─ ISO code "AUD" should be recognized ✔
│ └─ ISO code "CHF" should be recognized ✔
└─ JUnit Vintage ✔
Test run finished after 217 ms
[ 7 tests successful ]
[ 0 tests failed ]
@FieldSource reads its arguments straight from a static field rather than a method or inline
literals -- here FieldSourceTest's own static VALID_ISO_CODES field, which is kept in lock-step
with the real Set CurrencyService.isValidCode checks against, so the seven invocations above are
exactly the seven codes the real service recognizes.
@@ -0,0 +1,23 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
ArgumentsSourceTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ ArgumentsSourceTest ✔
│ └─ testWithExternalScenarios(String, int, boolean) ✔
│ ├─ scenario: "happy_path" ✔
│ ├─ scenario: "zero_value" ✔
│ └─ scenario: "max_boundary" ✔
└─ JUnit Vintage ✔
Test run finished after 212 ms
[ 3 tests successful ]
[ 0 tests failed ]
@ArgumentsSource(ScenarioArgumentsProvider.class) delegates argument production to a real,
separately-compiled class implementing ArgumentsProvider -- the escape hatch to reach for once a
data source needs more structure than @MethodSource's single static method comfortably holds
(loading fixtures, branching on environment, composing other providers). ScenarioArgumentsProvider
overrides the current provideArguments(ParameterDeclarations, ExtensionContext) overload rather
than the older single-argument form, which still compiles but is now deprecated.
@@ -0,0 +1,25 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
ConvertWithTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ ConvertWithTest ✔
│ └─ testDatesAreIn2023(LocalDate) ✔
│ ├─ date "2023-01-01" should be in 2023 ✔
│ ├─ date "2023-06-15" should be in 2023 ✔
│ └─ date "2023-12-31" should be in 2023 ✔
└─ JUnit Vintage ✔
Test run finished after 146 ms
[ 3 tests successful ]
[ 0 tests failed ]
The @ValueSource arguments are plain ISO-format date strings; @ConvertWith(DashDateConverter.class)
is what turns each one into a real java.time.LocalDate before the test method ever sees it, via
DashDateConverter's own SimpleArgumentConverter.convert() override. JUnit actually has an
implicit String-to-LocalDate conversion for this exact ISO shape, so this particular example
would work without @ConvertWith too -- it's shown here because the same mechanism is what you
reach for once the source strings need a format or extra validation JUnit's implicit converters
don't cover, and DashDateConverter.java is the smallest possible shape to build that converter
in when the need arises.
@@ -0,0 +1,31 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
MixedAssertionsAntiPatternTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ MixedAssertionsAntiPatternTest ✔
│ ├─ adminShouldDelete(Role) ✔
│ │ └─ [1] ADMIN ✔
│ ├─ testPermissions(String, boolean, boolean) ✔
│ │ ├─ [1] "ADMIN", "true", "true" ✔
│ │ └─ [2] "GUEST", "false", "true" ✔
│ └─ allRolesShouldRead(Role) ✔
│ ├─ [1] ADMIN ✔
│ ├─ [2] SUPERUSER ✔
│ ├─ [3] EDITOR ✔
│ ├─ [4] USER ✔
│ └─ [5] GUEST ✔
└─ JUnit Vintage ✔
Test run finished after 246 ms
[ 8 tests successful ]
[ 0 tests failed ]
testPermissions is the BAD example: one parameterized test asserting two unrelated behaviors
(canDelete and canRead) per row, so a failure in either one just says "testPermissions failed"
without telling you which behavior broke. It passes here -- this is a design-smell illustration,
not a test that's expected to fail -- but a reader who only sees it pass has no way to tell
canDelete and canRead apart from the test name alone. adminShouldDelete and allRolesShouldRead
are the fix: the same two behaviors, split into two single-assertion parameterized tests, each
with a display name that says exactly what broke if it ever does.
@@ -0,0 +1,21 @@
Captured from a real `junit-platform-console-standalone --details=tree` run against
DisplayNameTest, compiled and executed for real.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ DisplayNameTest ✔
│ └─ shouldMultiply(int, int, int) ✔
│ ├─ [1] "3" × "4" = "12" ✔
│ ├─ [2] "0" × "5" = "0" ✔
│ └─ [3] "-2" × "3" = "-6" ✔
└─ JUnit Vintage ✔
Test run finished after 173 ms
[ 3 tests successful ]
[ 0 tests failed ]
name = "[{index}] {0} × {1} = {2}" combines the built-in {index} keyword (the invocation's
1-based position) with the three positional argument placeholders and a literal Unicode
multiplication sign, to produce a display name that reads like the actual arithmetic the test
is checking rather than a generic "[1] 3, 4, 12".
@@ -0,0 +1,46 @@
Captured from a real `mvn clean test` run against the whole module, as committed.
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.ankurm.tutorials.junit.parameterized.ConvertWithTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.129 s -- in com.ankurm.tutorials.junit.parameterized.ConvertWithTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.HeaderNamePlaceholderMistakeTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.003 s -- in com.ankurm.tutorials.junit.parameterized.HeaderNamePlaceholderMistakeTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.MethodSourceTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.073 s -- in com.ankurm.tutorials.junit.parameterized.MethodSourceTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.NullAndEmptySourceTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.022 s -- in com.ankurm.tutorials.junit.parameterized.NullAndEmptySourceTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.EnumSourceTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.022 s -- in com.ankurm.tutorials.junit.parameterized.EnumSourceTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.MixedAssertionsAntiPatternTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.089 s -- in com.ankurm.tutorials.junit.parameterized.MixedAssertionsAntiPatternTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.CsvSourceTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.046 s -- in com.ankurm.tutorials.junit.parameterized.CsvSourceTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.CsvFileSourceTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.024 s -- in com.ankurm.tutorials.junit.parameterized.CsvFileSourceTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.FieldSourceTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.025 s -- in com.ankurm.tutorials.junit.parameterized.FieldSourceTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.ArgumentsSourceTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s -- in com.ankurm.tutorials.junit.parameterized.ArgumentsSourceTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.DisplayNameTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.012 s -- in com.ankurm.tutorials.junit.parameterized.DisplayNameTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.SharedMutableArgumentAntiPatternTest
[WARNING] Tests run: 3, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.009 s -- in com.ankurm.tutorials.junit.parameterized.SharedMutableArgumentAntiPatternTest
[INFO] Running com.ankurm.tutorials.junit.parameterized.ValueSourceTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in com.ankurm.tutorials.junit.parameterized.ValueSourceTest
[INFO]
[INFO] Results:
[INFO]
[WARNING] Tests run: 57, Failures: 0, Errors: 0, Skipped: 2
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
57 tests, 0 failures, 2 skipped. The two skips are HeaderNamePlaceholderMistakeTest and
SharedMutableArgumentAntiPatternTest#badSharedConfigAccumulatesTouches -- both deliberately
@Disabled so the committed build stays green, each with its own captured real failure
(docs/output/06-header-name-placeholder-throws.txt and
docs/output/09-shared-mutable-argument-failure.txt) from the one time each was temporarily
re-enabled and run for real.
+53
View File
@@ -0,0 +1,53 @@
<?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>parameterized-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.3</junit.version>
</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>
<!-- The junit-jupiter aggregate pulls in junit-jupiter-params automatically,
no separate dependency needed to use @ParameterizedTest and its sources. -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<!-- Real per-invocation display names (e.g. "'racecar' should be a palindrome")
only show up in Surefire's own text report with this on. -->
<printSummary>true</printSummary>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,20 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.Set;
public class AuthService {
private static final Set<Role> WRITE_ROLES = Set.of(Role.ADMIN, Role.SUPERUSER);
public boolean canWrite(Role role) {
return WRITE_ROLES.contains(role);
}
public boolean canRead(Role role) {
return true;
}
public boolean canDelete(Role role) {
return role == Role.ADMIN;
}
}
@@ -0,0 +1,12 @@
package com.ankurm.tutorials.junit.parameterized;
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int multiply(int a, int b) {
return a * b;
}
}
@@ -0,0 +1,12 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.Set;
public class Cms {
private static final Set<String> PUBLISHING_ROLES = Set.of("EDITOR", "ADMIN");
public boolean canPublish(int userId, String role) {
return PUBLISHING_ROLES.contains(role);
}
}
@@ -0,0 +1,20 @@
package com.ankurm.tutorials.junit.parameterized;
/** A deliberately mutable object, used to demonstrate the shared-mutable-argument anti-pattern. */
public class Config {
private int callCount = 0;
public void touch() {
callCount++;
}
public int getCallCount() {
return callCount;
}
@Override
public String toString() {
return "Config[callCount=" + callCount + "]";
}
}
@@ -0,0 +1,14 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.Set;
public class CurrencyService {
private static final Set<String> KNOWN_ISO_CODES = Set.of(
"USD", "EUR", "GBP", "JPY", "INR", "AUD", "CHF"
);
public boolean isValidCode(String code) {
return KNOWN_ISO_CODES.contains(code);
}
}
@@ -0,0 +1,22 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.Map;
public class Geocoder {
private static final Map<String, String> REGION_BY_POSTCODE = Map.of(
"SW1A", "London",
"M1", "Manchester",
"EH1", "Edinburgh",
"CF10", "Cardiff",
"BT1", "Belfast"
);
public String getRegion(String postcode) {
String region = REGION_BY_POSTCODE.get(postcode);
if (region == null) {
throw new IllegalArgumentException("unknown postcode: " + postcode);
}
return region;
}
}
@@ -0,0 +1,5 @@
package com.ankurm.tutorials.junit.parameterized;
public enum Role {
ADMIN, SUPERUSER, EDITOR, USER, GUEST
}
@@ -0,0 +1,16 @@
package com.ankurm.tutorials.junit.parameterized;
public class ScenarioProcessor {
private static final int MAX_ALLOWED = 999;
public boolean evaluate(String scenarioId, int value) {
if (value < 0 || value > MAX_ALLOWED) {
return false;
}
return switch (scenarioId) {
case "zero_value" -> false;
default -> true;
};
}
}
@@ -0,0 +1,36 @@
package com.ankurm.tutorials.junit.parameterized;
import java.time.LocalDate;
/**
* A subscription account. {@code renewalDueDate} is when the subscription is next due for
* renewal; an overdue renewal still counts as active for a short grace window, as long as the
* account itself hasn't been disabled.
*/
public class User {
private static final int RENEWAL_GRACE_PERIOD_DAYS = 3;
private final String name;
private final LocalDate renewalDueDate;
private final boolean enabled;
public User(String name, LocalDate renewalDueDate, boolean enabled) {
this.name = name;
this.renewalDueDate = renewalDueDate;
this.enabled = enabled;
}
public String getName() {
return name;
}
public boolean isActive() {
return enabled && renewalDueDate.isAfter(LocalDate.now().minusDays(RENEWAL_GRACE_PERIOD_DAYS));
}
@Override
public String toString() {
return name;
}
}
@@ -0,0 +1,16 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.HashMap;
import java.util.Map;
public class UserService {
private final Map<String, String> passwordsByUsername = new HashMap<>();
public void register(String username, String password) {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("username must not be null or blank");
}
passwordsByUsername.put(username, password);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ArgumentsSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArgumentsSourceTest {
private final ScenarioProcessor processor = new ScenarioProcessor();
@ParameterizedTest(name = "scenario: {0}")
@ArgumentsSource(ScenarioArgumentsProvider.class)
void testWithExternalScenarios(String scenarioId, int value, boolean expected) {
assertEquals(expected, processor.evaluate(scenarioId, value));
}
}
@@ -0,0 +1,18 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.converter.ConvertWith;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ConvertWithTest {
@ParameterizedTest(name = "date {0} should be in 2023")
@ValueSource(strings = {"2023-01-01", "2023-06-15", "2023-12-31"})
void testDatesAreIn2023(@ConvertWith(DashDateConverter.class) LocalDate date) {
assertEquals(2023, date.getYear());
}
}
@@ -0,0 +1,17 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvFileSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CsvFileSourceTest {
private final Geocoder geocoder = new Geocoder();
@ParameterizedTest(name = "postcode {0} should map to region {1}")
@CsvFileSource(resources = "/test-data/postcode-regions.csv", numLinesToSkip = 1)
void shouldMapPostcodeToRegion(String postcode, String expectedRegion) {
assertEquals(expectedRegion, geocoder.getRegion(postcode));
}
}
@@ -0,0 +1,50 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CsvSourceTest {
private final Calculator calculator = new Calculator();
private final Cms cms = new Cms();
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"10, 5, 15",
"0, 0, 0",
"-3, 7, 4",
"100, -50, 50"
})
void shouldAddNumbers(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest(name = "user {0} with role {1}")
@CsvSource(useHeadersInDisplayName = true, textBlock = """
USER_ID, ROLE, CAN_PUBLISH
# Elevated users
1001, EDITOR, true
1002, ADMIN, true
# Standard users
1003, USER, false
""")
void testPublishingPermissions(int userId, String role, boolean canPublish) {
assertEquals(canPublish, cms.canPublish(userId, role));
}
// Same data source, no custom `name` -- shows what useHeadersInDisplayName actually changes
// on its own: the auto-generated default display name. Compare this invocation's names
// against testPublishingPermissions' above (same data, custom name) and against
// HeaderNamePlaceholderMistakeTest (same data, an attempt to reference headers from a
// custom name, which fails).
@ParameterizedTest
@CsvSource(useHeadersInDisplayName = true, textBlock = """
USER_ID, ROLE, CAN_PUBLISH
1001, EDITOR, true
""")
void testPublishingPermissionsDefaultDisplayName(int userId, String role, boolean canPublish) {
assertEquals(canPublish, cms.canPublish(userId, role));
}
}
@@ -0,0 +1,13 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.converter.SimpleArgumentConverter;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
class DashDateConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) {
return LocalDate.parse((String) source, DateTimeFormatter.ISO_LOCAL_DATE);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DisplayNameTest {
private final Calculator calculator = new Calculator();
@ParameterizedTest(name = "[{index}] {0} × {1} = {2}")
@CsvSource({"3, 4, 12", "0, 5, 0", "-2, 3, -6"})
void shouldMultiply(int a, int b, int expected) {
assertEquals(expected, calculator.multiply(a, b));
}
}
@@ -0,0 +1,26 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EnumSourceTest {
private final AuthService authService = new AuthService();
@ParameterizedTest(name = "{0} should have write access")
@EnumSource(value = Role.class, names = {"ADMIN", "SUPERUSER"})
void privilegedRolesShouldHaveWriteAccess(Role role) {
assertTrue(authService.canWrite(role));
}
@ParameterizedTest(name = "{0} must NOT have write access")
@EnumSource(value = Role.class,
names = {"ADMIN", "SUPERUSER"},
mode = EnumSource.Mode.EXCLUDE)
void nonPrivilegedRolesMustNotWrite(Role role) {
assertFalse(authService.canWrite(role));
}
}
@@ -0,0 +1,23 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.FieldSource;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FieldSourceTest {
static final List<String> VALID_ISO_CODES = List.of(
"USD", "EUR", "GBP", "JPY", "INR", "AUD", "CHF"
);
private final CurrencyService currencyService = new CurrencyService();
@ParameterizedTest(name = "ISO code {0} should be recognized")
@FieldSource("VALID_ISO_CODES")
void shouldRecognizeIsoCurrencyCode(String code) {
assertTrue(currencyService.isValidCode(code));
}
}
@@ -0,0 +1,27 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/**
* This class is not meant to compile into a passing test -- it exists only to let
* docs/output/06-header-name-placeholder-throws.txt capture the real exception that a
* {@code name} template referencing a CSV header by name (instead of a positional index)
* produces. The method is {@code @Disabled} in the committed version; the failure was captured
* by temporarily removing that annotation and running this class alone. See the companion post
* for why {@code {USER_ID}} is not valid syntax here even with {@code useHeadersInDisplayName}.
*/
class HeaderNamePlaceholderMistakeTest {
@org.junit.jupiter.api.Disabled("Kept disabled so the module's build stays green -- "
+ "see docs/output/06-header-name-placeholder-throws.txt for the real failure "
+ "this produces when the @Disabled annotation is removed.")
@ParameterizedTest(name = "user {USER_ID} with role {ROLE}")
@CsvSource(useHeadersInDisplayName = true, textBlock = """
USER_ID, ROLE, CAN_PUBLISH
1001, EDITOR, true
""")
void nameTemplateCannotReferenceHeadersByName(int userId, String role, boolean canPublish) {
// Never reached -- JUnit rejects the display-name pattern before this method runs.
}
}
@@ -0,0 +1,27 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.time.LocalDate;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MethodSourceTest {
@ParameterizedTest(name = "user {0} active={1}")
@MethodSource("activeUserProvider")
void testUserActiveStatus(User user, boolean expectedActive) {
assertEquals(expectedActive, user.isActive());
}
static Stream<Arguments> activeUserProvider() {
return Stream.of(
Arguments.of(new User("alice", LocalDate.now().minusDays(1), true), true),
Arguments.of(new User("bob", LocalDate.now().plusDays(30), true), true),
Arguments.of(new User("carol", LocalDate.now().minusDays(1), false), false)
);
}
}
@@ -0,0 +1,34 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MixedAssertionsAntiPatternTest {
private final AuthService auth = new AuthService();
// BAD: different assertions encoded by role, in one parameterized test.
@ParameterizedTest
@CsvSource({"ADMIN, true, true", "GUEST, false, true"})
void testPermissions(String role, boolean canDelete, boolean canRead) {
assertEquals(canDelete, auth.canDelete(Role.valueOf(role)));
assertEquals(canRead, auth.canRead(Role.valueOf(role))); // different assertion per row
}
// BETTER: split into two focused parameterized tests, one behavior each.
@ParameterizedTest
@EnumSource(value = Role.class, names = "ADMIN")
void adminShouldDelete(Role role) {
assertTrue(auth.canDelete(role));
}
@ParameterizedTest
@EnumSource(Role.class)
void allRolesShouldRead(Role role) {
assertTrue(auth.canRead(role));
}
}
@@ -0,0 +1,20 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertThrows;
class NullAndEmptySourceTest {
private final UserService userService = new UserService();
@ParameterizedTest(name = "blank username [{0}] should be rejected")
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void shouldRejectBlankUsernames(String username) {
assertThrows(IllegalArgumentException.class,
() -> userService.register(username, "password123"));
}
}
@@ -0,0 +1,24 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.support.ParameterDeclarations;
import java.util.stream.Stream;
class ScenarioArgumentsProvider implements ArgumentsProvider {
// ArgumentsProvider has carried two provideArguments overloads since 5.13: this one, which
// also receives the test method's ParameterDeclarations, and a legacy single-argument form
// that still compiles but is now deprecated. Prefer this one in new code.
@Override
public Stream<? extends Arguments> provideArguments(ParameterDeclarations parameters, ExtensionContext ctx) {
// Could load from a test database, JSON fixture, environment, etc.
return Stream.of(
Arguments.of("happy_path", 100, true),
Arguments.of("zero_value", 0, false),
Arguments.of("max_boundary", 999, true)
);
}
}
@@ -0,0 +1,52 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Two parameterized tests, same shape, one real difference: whether the data source hands out
* one shared {@link Config} instance or a fresh one per invocation. Both tests assert the same
* thing -- "this invocation's Config has been touched exactly once" -- which is true in
* isolation but only reliable in the GOOD version.
*/
class SharedMutableArgumentAntiPatternTest {
@org.junit.jupiter.api.Disabled("Kept disabled so the module's build stays green -- see "
+ "docs/output/09-shared-mutable-argument-failure.txt for the real failure this "
+ "produces on its second invocation when the @Disabled annotation is removed.")
@ParameterizedTest
@MethodSource("sharedProvider")
void badSharedConfigAccumulatesTouches(Config config) {
config.touch();
assertEquals(1, config.getCallCount());
}
@ParameterizedTest
@MethodSource("freshProvider")
void goodFreshConfigIsAlwaysTouchedOnce(Config config) {
config.touch();
assertEquals(1, config.getCallCount());
}
// BAD: one Config instance is built once and handed to every invocation.
static Stream<Arguments> sharedProvider() {
Config shared = new Config();
return Stream.of(
Arguments.of(shared), // invocation 1 touches it -> callCount becomes 1
Arguments.of(shared) // invocation 2 touches the SAME instance -> callCount becomes 2
);
}
// GOOD: a fresh Config is built inside the Stream for every invocation.
static Stream<Arguments> freshProvider() {
return Stream.of(
Arguments.of(new Config()),
Arguments.of(new Config())
);
}
}
@@ -0,0 +1,15 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ValueSourceTest {
@ParameterizedTest(name = "{0} should be a palindrome")
@ValueSource(strings = {"racecar", "radar", "level"})
void shouldIdentifyPalindromes(String candidate) {
assertTrue(candidate.equals(new StringBuilder(candidate).reverse().toString()));
}
}
@@ -0,0 +1,6 @@
postcode,region
SW1A,London
M1,Manchester
EH1,Edinburgh
CF10,Cardiff
BT1,Belfast
1 postcode region
2 SW1A London
3 M1 Manchester
4 EH1 Edinburgh
5 CF10 Cardiff
6 BT1 Belfast