Add parameterized module: JUnit 5 @ParameterizedTest argument sources, display names, and two real anti-pattern failures
This commit is contained in:
@@ -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.
|
||||||
@@ -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 + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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
|
||||||
|
}
|
||||||
+16
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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
|
||||||
|
Reference in New Issue
Block a user