diff --git a/parameterized/README.md b/parameterized/README.md new file mode 100644 index 0000000..05d8377 --- /dev/null +++ b/parameterized/README.md @@ -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=` — 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. diff --git a/parameterized/docs/output/01-value-source-palindrome-check.txt b/parameterized/docs/output/01-value-source-palindrome-check.txt new file mode 100644 index 0000000..12ac87a --- /dev/null +++ b/parameterized/docs/output/01-value-source-palindrome-check.txt @@ -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. diff --git a/parameterized/docs/output/02-null-and-empty-source-blank-usernames.txt b/parameterized/docs/output/02-null-and-empty-source-blank-usernames.txt new file mode 100644 index 0000000..d829448 --- /dev/null +++ b/parameterized/docs/output/02-null-and-empty-source-blank-usernames.txt @@ -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. diff --git a/parameterized/docs/output/03-csv-source-add-numbers.txt b/parameterized/docs/output/03-csv-source-add-numbers.txt new file mode 100644 index 0000000..4f474a5 --- /dev/null +++ b/parameterized/docs/output/03-csv-source-add-numbers.txt @@ -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. diff --git a/parameterized/docs/output/04-csv-source-custom-name-with-headers.txt b/parameterized/docs/output/04-csv-source-custom-name-with-headers.txt new file mode 100644 index 0000000..c5993eb --- /dev/null +++ b/parameterized/docs/output/04-csv-source-custom-name-with-headers.txt @@ -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). diff --git a/parameterized/docs/output/05-csv-source-headers-default-display-name.txt b/parameterized/docs/output/05-csv-source-headers-default-display-name.txt new file mode 100644 index 0000000..fe067a8 --- /dev/null +++ b/parameterized/docs/output/05-csv-source-headers-default-display-name.txt @@ -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. diff --git a/parameterized/docs/output/06-header-name-placeholder-throws.txt b/parameterized/docs/output/06-header-name-placeholder-throws.txt new file mode 100644 index 0000000..c8bb217 --- /dev/null +++ b/parameterized/docs/output/06-header-name-placeholder-throws.txt @@ -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.(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. diff --git a/parameterized/docs/output/07-csv-file-source-postcode-region.txt b/parameterized/docs/output/07-csv-file-source-postcode-region.txt new file mode 100644 index 0000000..6cb19da --- /dev/null +++ b/parameterized/docs/output/07-csv-file-source-postcode-region.txt @@ -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. diff --git a/parameterized/docs/output/08-enum-source-role-write-access.txt b/parameterized/docs/output/08-enum-source-role-write-access.txt new file mode 100644 index 0000000..8774001 --- /dev/null +++ b/parameterized/docs/output/08-enum-source-role-write-access.txt @@ -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. diff --git a/parameterized/docs/output/09-shared-mutable-argument-failure.txt b/parameterized/docs/output/09-shared-mutable-argument-failure.txt new file mode 100644 index 0000000..aa0ee90 --- /dev/null +++ b/parameterized/docs/output/09-shared-mutable-argument-failure.txt @@ -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. diff --git a/parameterized/docs/output/10-shared-mutable-argument-fixed.txt b/parameterized/docs/output/10-shared-mutable-argument-fixed.txt new file mode 100644 index 0000000..951882a --- /dev/null +++ b/parameterized/docs/output/10-shared-mutable-argument-fixed.txt @@ -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. diff --git a/parameterized/docs/output/11-method-source-user-active-status.txt b/parameterized/docs/output/11-method-source-user-active-status.txt new file mode 100644 index 0000000..a6d5010 --- /dev/null +++ b/parameterized/docs/output/11-method-source-user-active-status.txt @@ -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. diff --git a/parameterized/docs/output/12-field-source-iso-currency-codes.txt b/parameterized/docs/output/12-field-source-iso-currency-codes.txt new file mode 100644 index 0000000..fddda01 --- /dev/null +++ b/parameterized/docs/output/12-field-source-iso-currency-codes.txt @@ -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. diff --git a/parameterized/docs/output/13-arguments-source-external-scenarios.txt b/parameterized/docs/output/13-arguments-source-external-scenarios.txt new file mode 100644 index 0000000..6096d45 --- /dev/null +++ b/parameterized/docs/output/13-arguments-source-external-scenarios.txt @@ -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. diff --git a/parameterized/docs/output/14-convert-with-dates-in-2023.txt b/parameterized/docs/output/14-convert-with-dates-in-2023.txt new file mode 100644 index 0000000..e725617 --- /dev/null +++ b/parameterized/docs/output/14-convert-with-dates-in-2023.txt @@ -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. diff --git a/parameterized/docs/output/15-mixed-assertions-anti-pattern.txt b/parameterized/docs/output/15-mixed-assertions-anti-pattern.txt new file mode 100644 index 0000000..1342729 --- /dev/null +++ b/parameterized/docs/output/15-mixed-assertions-anti-pattern.txt @@ -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. diff --git a/parameterized/docs/output/16-display-name-custom-multiply.txt b/parameterized/docs/output/16-display-name-custom-multiply.txt new file mode 100644 index 0000000..2baa33e --- /dev/null +++ b/parameterized/docs/output/16-display-name-custom-multiply.txt @@ -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". diff --git a/parameterized/docs/output/17-full-suite-final.txt b/parameterized/docs/output/17-full-suite-final.txt new file mode 100644 index 0000000..ce5e58d --- /dev/null +++ b/parameterized/docs/output/17-full-suite-final.txt @@ -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. diff --git a/parameterized/pom.xml b/parameterized/pom.xml new file mode 100644 index 0000000..608bc8f --- /dev/null +++ b/parameterized/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + com.ankurTutorials + parameterized-demo + 1.0-SNAPSHOT + + + 17 + UTF-8 + 6.1.3 + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + true + + + + + diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/AuthService.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/AuthService.java new file mode 100644 index 0000000..bd32bd5 --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/AuthService.java @@ -0,0 +1,20 @@ +package com.ankurm.tutorials.junit.parameterized; + +import java.util.Set; + +public class AuthService { + + private static final Set 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; + } +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Calculator.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Calculator.java new file mode 100644 index 0000000..3d0e87d --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Calculator.java @@ -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; + } +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Cms.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Cms.java new file mode 100644 index 0000000..ddb655d --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Cms.java @@ -0,0 +1,12 @@ +package com.ankurm.tutorials.junit.parameterized; + +import java.util.Set; + +public class Cms { + + private static final Set PUBLISHING_ROLES = Set.of("EDITOR", "ADMIN"); + + public boolean canPublish(int userId, String role) { + return PUBLISHING_ROLES.contains(role); + } +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Config.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Config.java new file mode 100644 index 0000000..22b42db --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Config.java @@ -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 + "]"; + } +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/CurrencyService.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/CurrencyService.java new file mode 100644 index 0000000..70c7aa7 --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/CurrencyService.java @@ -0,0 +1,14 @@ +package com.ankurm.tutorials.junit.parameterized; + +import java.util.Set; + +public class CurrencyService { + + private static final Set KNOWN_ISO_CODES = Set.of( + "USD", "EUR", "GBP", "JPY", "INR", "AUD", "CHF" + ); + + public boolean isValidCode(String code) { + return KNOWN_ISO_CODES.contains(code); + } +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Geocoder.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Geocoder.java new file mode 100644 index 0000000..fad94c2 --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Geocoder.java @@ -0,0 +1,22 @@ +package com.ankurm.tutorials.junit.parameterized; + +import java.util.Map; + +public class Geocoder { + + private static final Map 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; + } +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Role.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Role.java new file mode 100644 index 0000000..38b3867 --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/Role.java @@ -0,0 +1,5 @@ +package com.ankurm.tutorials.junit.parameterized; + +public enum Role { + ADMIN, SUPERUSER, EDITOR, USER, GUEST +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/ScenarioProcessor.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/ScenarioProcessor.java new file mode 100644 index 0000000..08f4917 --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/ScenarioProcessor.java @@ -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; + }; + } +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/User.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/User.java new file mode 100644 index 0000000..72c9168 --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/User.java @@ -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; + } +} diff --git a/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/UserService.java b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/UserService.java new file mode 100644 index 0000000..628ed79 --- /dev/null +++ b/parameterized/src/main/java/com/ankurm/tutorials/junit/parameterized/UserService.java @@ -0,0 +1,16 @@ +package com.ankurm.tutorials.junit.parameterized; + +import java.util.HashMap; +import java.util.Map; + +public class UserService { + + private final Map 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); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ArgumentsSourceTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ArgumentsSourceTest.java new file mode 100644 index 0000000..1125629 --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ArgumentsSourceTest.java @@ -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)); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ConvertWithTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ConvertWithTest.java new file mode 100644 index 0000000..07d1f3c --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ConvertWithTest.java @@ -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()); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvFileSourceTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvFileSourceTest.java new file mode 100644 index 0000000..3799110 --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvFileSourceTest.java @@ -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)); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvSourceTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvSourceTest.java new file mode 100644 index 0000000..cce018b --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvSourceTest.java @@ -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)); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/DashDateConverter.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/DashDateConverter.java new file mode 100644 index 0000000..a61b601 --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/DashDateConverter.java @@ -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); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/DisplayNameTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/DisplayNameTest.java new file mode 100644 index 0000000..af0567b --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/DisplayNameTest.java @@ -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)); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/EnumSourceTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/EnumSourceTest.java new file mode 100644 index 0000000..61d302e --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/EnumSourceTest.java @@ -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)); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/FieldSourceTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/FieldSourceTest.java new file mode 100644 index 0000000..4f8eb50 --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/FieldSourceTest.java @@ -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 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)); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/HeaderNamePlaceholderMistakeTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/HeaderNamePlaceholderMistakeTest.java new file mode 100644 index 0000000..623d0d2 --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/HeaderNamePlaceholderMistakeTest.java @@ -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. + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MethodSourceTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MethodSourceTest.java new file mode 100644 index 0000000..795fa1f --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MethodSourceTest.java @@ -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 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) + ); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MixedAssertionsAntiPatternTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MixedAssertionsAntiPatternTest.java new file mode 100644 index 0000000..b57421a --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MixedAssertionsAntiPatternTest.java @@ -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)); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/NullAndEmptySourceTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/NullAndEmptySourceTest.java new file mode 100644 index 0000000..26ed3ec --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/NullAndEmptySourceTest.java @@ -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")); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ScenarioArgumentsProvider.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ScenarioArgumentsProvider.java new file mode 100644 index 0000000..1a1b62c --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ScenarioArgumentsProvider.java @@ -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 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) + ); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/SharedMutableArgumentAntiPatternTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/SharedMutableArgumentAntiPatternTest.java new file mode 100644 index 0000000..da0301e --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/SharedMutableArgumentAntiPatternTest.java @@ -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 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 freshProvider() { + return Stream.of( + Arguments.of(new Config()), + Arguments.of(new Config()) + ); + } +} diff --git a/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ValueSourceTest.java b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ValueSourceTest.java new file mode 100644 index 0000000..03eb22c --- /dev/null +++ b/parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ValueSourceTest.java @@ -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())); + } +} diff --git a/parameterized/src/test/resources/test-data/postcode-regions.csv b/parameterized/src/test/resources/test-data/postcode-regions.csv new file mode 100644 index 0000000..5bc6b4a --- /dev/null +++ b/parameterized/src/test/resources/test-data/postcode-regions.csv @@ -0,0 +1,6 @@ +postcode,region +SW1A,London +M1,Manchester +EH1,Edinburgh +CF10,Cardiff +BT1,Belfast