Skip to main content

The Complete Guide to JUnit 5 @ParameterizedTest: Write Smarter, Faster, and Cleaner Java Tests

Every official way to feed a @ParameterizedTest, built and run against a real JUnit 6.1.3 module: @ValueSource through @ArgumentsSource, how display names actually compile to a MessageFormat pattern, two real anti-patterns caught failing on purpose, and three genuine bugs found by rebuilding this guide’s original examples from scratch instead of copying them forward.

Here’s a pattern that shows up on almost every Java team: someone writes a clean test for a validation method. Two weeks later a bug surfaces with a different input. They copy the test, change two values, rename it testValidate_withNull. A month later there are eight near-identical copies, each testing the same two lines of logic with slightly different numbers typed in by hand β€” and when the method’s signature changes, all eight have to change with it.

@ParameterizedTest is JUnit 5’s answer: one test method, a list of inputs, and JUnit runs the method once per input, reporting each run as its own line in your IDE and CI output. This post builds every official way to supply those inputs against a real, compiled, committed module β€” and along the way finds three real bugs sitting in a widely-read older version of this exact guide, plus one genuinely easy-to-miss interaction between two JUnit features that neither feature’s own documentation calls out on its own.

Versions used in this post. JUnit Jupiter / Platform 6.1.3, the current GA release verified against maven-metadata.xml on Maven Central Β· JDK 25 LTS. This is a rewrite of an older guide originally written against the JUnit 5.11.x line β€” every example below was rebuilt from scratch against 6.1.3, not copied forward, which is precisely how the three corrections later in this post were found. Every rule, every display name, and every line of console output below came out of a real mvn test run (or a JUnit Platform Console Launcher run, where a per-invocation name is the point) against this post’s companion repository.

The smallest correct mental model: one method, many invocations

Every other JUnit test you’ve written is one method, called once, that either passes or fails. A parameterized test is still one method β€” but JUnit calls it once per argument set a source hands it, and each of those calls is reported, independently, as its own test. A four-row @CsvSource on a method with three parameters doesn’t produce “one test with four rows” the way a spreadsheet person might picture it; it produces four completely separate test results, each with its own pass/fail and its own name.

One method, called once per argument set, reported as separate tests @CsvSource (4 rows) shouldAddNumbers(a, b, expected) one method, written once “10” + “5” = “15” ✓ “0” + “0” = “0” ✓ “-3” + “7” = “4” ✓ “100” + “-50” = “50” ✓ Four independent entries in the test report, not one test with four rows inside it. Each has its own pass/fail and its own display name β€” built from the string this post spends a whole section on later, because how that string is built has a real surprise in it.

That last point is worth planting early and cashing in later: every one of those four green boxes has a display name, and JUnit builds that name from a real template string. The default template is serviceable but generic β€” name = "{0} should be a palindrome" appears all over this post precisely because a custom name is one of the cheapest things you can do to make a CI failure self-explanatory. The section on “How parameterized display names actually work” later in this post is where that template mechanism, and a genuine mistake it’s easy to make with it, gets the full explanation.

Picking the right source

JUnit ships eight ways to feed a parameterized test, and which one fits depends entirely on where the data already lives and how much structure it needs:

You want to…Use
Test with a list of single primitive or string values@ValueSource
Include null and empty alongside other values@NullAndEmptySource + @ValueSource
Test with multiple arguments per row, inline@CsvSource
Load many rows from an external file@CsvFileSource
Iterate over enum constants (all or a subset)@EnumSource
Pass complex objects or programmatically-built data@MethodSource
Use a static field (constant list) as the source@FieldSource (added in JUnit 5.11)
Full custom control: database, files, external systems@ArgumentsSource

Every row in that table gets its own section below, in the same order, each with a real example built against this post’s companion module. @ParameterizedTest itself lives in junit-jupiter-params, which the junit-jupiter aggregate dependency pulls in automatically β€” the companion module’s own pom.xml depends on nothing but the aggregate. If a project avoids the aggregate and wants the params module on its own:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>6.1.3</version>
    <scope>test</scope>
</dependency>

This module itself skips this explicit dependency and relies on the aggregate instead β€” see parameterized/pom.xml.

The smallest thing that works: @ValueSource

The simplest source is a literal array: JUnit runs the method once per element. Supported element types are short, byte, int, long, float, double, char, boolean, String, and Class β€” nothing else.

@ParameterizedTest(name = "{0} should be a palindrome")
@ValueSource(strings = {"racecar", "radar", "level"})
void shouldIdentifyPalindromes(String candidate) {
    assertTrue(candidate.equals(new StringBuilder(candidate).reverse().toString()));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ValueSourceTest.java

β”‚        β”œβ”€ "racecar" should be a palindrome βœ”
β”‚        β”œβ”€ "radar" should be a palindrome βœ”
β”‚        └─ "level" should be a palindrome βœ”

Full transcript: parameterized/docs/output/01-value-source-palindrome-check.txt

Each invocation is its own line in the IDE’s test tree and in CI β€” exactly which input caused a failure is visible without opening the test class. Notice the name template is plain {0}, not ''{0}'': the console runner already quotes a String argument’s own text when it substitutes it in, so adding quote marks inside the template itself just produces the doubled-up '"racecar"' β€” a small but real mistake worth not making.

  • The full list of supported @ValueSource element types, and what happens if you try to mix two types in one annotation (it doesn’t compile), is in the companion repository’s README.

Blank input, without eight copies of the same test

null and empty-string inputs break more production code than any other single edge case, and they’re exactly the inputs a developer is tempted to skip because adding them means either a new test method or a messy array literal. @NullAndEmptySource contributes both cases for free, and stacks cleanly with a plain @ValueSource for the rest of the blank-input sweep:

@ParameterizedTest(name = "blank username [{0}] should be rejected")
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void shouldRejectBlankUsernames(String username) {
    assertThrows(IllegalArgumentException.class, () -> userService.register(username, "password123"));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/NullAndEmptySourceTest.java

β”‚        β”œβ”€ 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 βœ”

Full transcript: parameterized/docs/output/02-null-and-empty-source-blank-usernames.txt

Five invocations from two annotations: @NullAndEmptySource contributes the null and "" cases, @ValueSource contributes the other three. All five need to be genuinely blank per String.isBlank() for this test to mean what it says β€” which means the two string literals have to be real tab and newline characters ("\t", "\n"), not the plain letters t and n. An older version of this exact example used the letters, which are not blank at all; see “What changed from the original guide” near the end of this post.

@NullSource and @EmptySource also exist separately, for when you want exactly one of the two without the other β€” useful on a parameter type where an empty value doesn’t make sense, like a numeric wrapper that can be null but has no meaningful “empty.”

Multiple arguments per invocation: @CsvSource

Once a test needs more than one argument per row, @CsvSource parses comma-separated strings and converts each column to the matching parameter type:

@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));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvSourceTest.java

β”‚        β”œβ”€ "10" + "5" = "15" βœ”
β”‚        β”œβ”€ "0" + "0" = "0" βœ”
β”‚        β”œβ”€ "-3" + "7" = "4" βœ”
β”‚        └─ "100" + "-50" = "50" βœ”

Full transcript: parameterized/docs/output/03-csv-source-add-numbers.txt

From JUnit 5.8 onward, @CsvSource also accepts a text block, which reads better for anything past three or four rows and supports #-prefixed comment lines and a header row:

@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));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvSourceTest.java

β”‚        β”œβ”€ user USER_ID = "1001" with role ROLE = "EDITOR" βœ”
β”‚        β”œβ”€ user USER_ID = "1002" with role ROLE = "ADMIN" βœ”
β”‚        └─ user USER_ID = "1003" with role ROLE = "USER" βœ”

Full transcript: parameterized/docs/output/04-csv-source-custom-name-with-headers.txt

Read that output closely and something doesn’t match the template: name = "user {0} with role {1}" uses plain positional placeholders, the same {0}/{1} syntax as every other example in this post β€” but the rendered name reads user USER_ID = "1001", not user 1001. useHeadersInDisplayName = true is doing that, and exactly what it’s doing gets its own full section below, because it’s the most easily-missed finding in this whole post.

  • How useHeadersInDisplayName actually works, including the failure mode of trying to reference a header by name instead of position, is covered in full later in “How parameterized display names actually work” and “What breaks: a header name is not a valid placeholder” below β€” worth reading in full if this section’s output surprised you.

Externalizing a dataset: @CsvFileSource

Once a dataset grows past a dozen rows, or is owned by someone who doesn’t write Java, move it to a real CSV file on the test classpath instead of a Java string literal:

@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));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/CsvFileSourceTest.java β€” data file: postcode-regions.csv

β”‚        β”œβ”€ 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" βœ”

Full transcript: parameterized/docs/output/07-csv-file-source-postcode-region.txt

The file lives at src/test/resources/test-data/postcode-regions.csv, and numLinesToSkip = 1 skips its header row. JUnit streams rows rather than loading the whole file upfront, so this scales to thousands of rows without heap pressure β€” the mechanism that matters once “a dozen rows” becomes “a QA team’s regression spreadsheet.”

Iterating enum constants: @EnumSource

The names attribute filters which constants run, and the mode = EXCLUDE variant is underused: instead of hardcoding which roles to test, you declare which are exempt β€” which makes the test resilient to a constant being added to the enum later.

@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));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/EnumSourceTest.java


β•·
β”œβ”€ 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 βœ”

Full transcript: parameterized/docs/output/08-enum-source-role-write-access.txt

Between the two tests, every one of Role’s five constants is exercised exactly once. If a MODERATOR constant is added to the enum tomorrow, the EXCLUDE-mode test picks it up automatically and asserts it must not have write access β€” no test file needs to change for the new constant to be covered.

Complex objects: @MethodSource

Reach for @MethodSource the moment test data can’t be written as a literal β€” domain objects, collections, or anything that needs actual computation to build:

@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)
    );
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MethodSourceTest.java

β”‚        β”œβ”€ user alice active=true βœ”
β”‚        β”œβ”€ user bob active=true βœ”
β”‚        └─ user carol active=false βœ”

Full transcript: parameterized/docs/output/11-method-source-user-active-status.txt

alice’s renewal date is a day in the past but still inside the 3-day grace period User.isActive() allows, and she’s enabled β€” active. bob’s renewal date is 30 days out and he’s enabled β€” active. carol’s date is identical to alice’s, still within the grace period, but she’s enabled = false β€” and isActive() requires both, so she’s inactive purely because of the flag, not the date. The factory method must be static unless the test class carries @TestInstance(Lifecycle.PER_CLASS); a factory in another class is referenced with the fully-qualified form, @MethodSource("com.example.Fixtures#activeUserProvider").

This User/isActive example was genuinely broken in the original version of this guide. See “What changed from the original guide” near the end of this post for exactly what was wrong and why.

Constant lists: @FieldSource

@FieldSource, added in JUnit 5.11, reads arguments straight from a static field β€” filling the gap between @ValueSource (too simple once the list is reused across tests) and @MethodSource (overkill for data that never changes):

class FieldSourceTest {

    static final List<String> VALID_ISO_CODES = List.of(
            "USD", "EUR", "GBP", "JPY", "INR", "AUD", "CHF"
    );

    @ParameterizedTest(name = "ISO code {0} should be recognized")
    @FieldSource("VALID_ISO_CODES")
    void shouldRecognizeIsoCurrencyCode(String code) {
        assertTrue(currencyService.isValidCode(code));
    }
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/FieldSourceTest.java

β”‚        β”œβ”€ 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 βœ”

Full transcript: parameterized/docs/output/12-field-source-iso-currency-codes.txt

The field must be static and either implement Iterable, be an array, or be a Supplier of either. Compared to @MethodSource, there is no factory method boilerplate at all β€” the field itself is the data, kept in the companion repository one source lookup away from the real Set the production CurrencyService checks against, so the two can never silently drift apart.

Full custom control: @ArgumentsSource

When test data lives outside the codebase entirely β€” a database, a config file, an environment variable β€” implement ArgumentsProvider as its own class:

class ScenarioArgumentsProvider implements ArgumentsProvider {

    @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)
        );
    }
}

@ParameterizedTest(name = "scenario: {0}")
@ArgumentsSource(ScenarioArgumentsProvider.class)
void testWithExternalScenarios(String scenarioId, int value, boolean expected) {
    assertEquals(expected, processor.evaluate(scenarioId, value));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/ScenarioArgumentsProvider.java and ArgumentsSourceTest.java

β”‚        β”œβ”€ scenario: "happy_path" βœ”
β”‚        β”œβ”€ scenario: "zero_value" βœ”
β”‚        └─ scenario: "max_boundary" βœ”

Full transcript: parameterized/docs/output/13-arguments-source-external-scenarios.txt

ArgumentsProvider carries two provideArguments overloads: an older single-argument form that still compiles but is now deprecated, and the current one shown above, which also receives the test method’s ParameterDeclarations. Prefer the current overload in new code β€” it’s the one this module’s provider implements, with no deprecation warning anywhere in its build.

  • This is the escape hatch to reach for once a data source needs more structure than @MethodSource‘s single static method comfortably holds β€” loading real fixtures, branching on environment, or composing several other providers together. The full ArgumentsProvider interface, including ParameterDeclarations, is documented in the JUnit 5 Javadoc.

Converting raw arguments: @ConvertWith

JUnit auto-converts common types β€” String to int, boolean, enums, and several java.time types in their standard ISO formats. For anything else, implement ArgumentConverter:

class DashDateConverter extends SimpleArgumentConverter {
    @Override
    protected Object convert(Object source, Class<?> targetType) {
        return LocalDate.parse((String) source, DateTimeFormatter.ISO_LOCAL_DATE);
    }
}

@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());
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/DashDateConverter.java and ConvertWithTest.java

β”‚        β”œβ”€ date "2023-01-01" should be in 2023 βœ”
β”‚        β”œβ”€ date "2023-06-15" should be in 2023 βœ”
β”‚        └─ date "2023-12-31" should be in 2023 βœ”

Full transcript: parameterized/docs/output/14-convert-with-dates-in-2023.txt

This particular example would actually pass without @ConvertWith at all β€” JUnit already converts ISO-format strings to LocalDate implicitly β€” and it’s shown that way deliberately: @ConvertWith is for the moment the source strings need a format, or extra validation, the implicit converters don’t cover, and DashDateConverter is the smallest possible shape to write that converter in once the need shows up for real.

How parameterized display names actually work

Every example so far has used a custom name template, and this is the section that pays that off. @ParameterizedTest’s name attribute is not its own little template language β€” it compiles directly into a real java.text.MessageFormat pattern, and MessageFormat only understands two kinds of placeholder inside a parameterized test’s name: numeric positional indices ({0}, {1}, …) for the method’s own arguments, and a small set of keywords JUnit resolves before handing the string to MessageFormat at all β€” {index} (the 1-based invocation number), {arguments} (every argument, comma-joined), and {displayName} (the method’s own display name).

@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));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/DisplayNameTest.java

β”‚        β”œβ”€ [1] "3" Γ— "4" = "12" βœ”
β”‚        β”œβ”€ [2] "0" Γ— "5" = "0" βœ”
β”‚        └─ [3] "-2" Γ— "3" = "-6" βœ”

Full transcript: parameterized/docs/output/16-display-name-custom-multiply.txt

{index} combined with the three positional placeholders and a literal Unicode multiplication sign produces a name that reads like the actual arithmetic under test, instead of the generic [1] 3, 4, 12 the default template would give. That’s the whole mechanism β€” until @CsvSource(useHeadersInDisplayName = true) gets involved, which changes what a positional placeholder resolves to in a way that isn’t obvious from either feature’s own documentation read in isolation.

The same {0} placeholder, three different outcomes name = “user {0}…” MessageFormat pattern no useHeadersInDisplayName “user 1001” β€” bare value useHeadersInDisplayName = true “user USER_ID = "1001"” name = “{USER_ID}” header name, not a position β€” throws before any test runs useHeadersInDisplayName changes what a POSITIONAL placeholder expands to — it does not create a new kind of placeholder. MessageFormat still only understands numbers.

The top branch is what every other section in this post relies on. The middle branch is the new finding from “Multiple arguments per invocation” above: @CsvSource(useHeadersInDisplayName = true) changes what every {0}, {1}, … placeholder expands to β€” from the bare value to "HEADER = value" β€” even inside a fully custom name template that never mentions headers at all. The bottom branch is a different mistake entirely, covered next.

Going deeper: the flag’s effect on the auto-generated default name, isolated
with no custom name at all, useHeadersInDisplayName = true changes JUnit’s own default from the bare [1] "1001", "EDITOR", "true" you’d otherwise get to the header-qualified [1] USER_ID = "1001", ROLE = "EDITOR", CAN_PUBLISH = "true" β€” which is the flag’s documented, intended behavior. The real transcript proving it, run with the same flag and headers as the custom-name example above β€” reduced to one representative row, with the name attribute removed entirely β€” is captured at 05-csv-source-headers-default-display-name.txt. What’s easy to miss is that the same flag reaches into a custom name’s positional placeholders too, which is the surprise this section exists to document.

What breaks: a header name is not a valid placeholder

The bottom branch in the diagram above is worth seeing fail for real, not just described. useHeadersInDisplayName makes the column headers available for JUnit’s own auto-generated default name β€” it does not create a new placeholder syntax a custom name can reference directly. Writing {USER_ID} where a positional index is expected is a real, immediate failure:

@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.
}

This method is kept @Disabled in the committed repository so the module’s build stays green; the real failure below was captured by temporarily removing that annotation and running the class alone. Source: parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/HeaderNamePlaceholderMistakeTest.java

org.junit.platform.commons.JUnitException: The display name pattern defined for the parameterized test is invalid. See nested exception for further details.
Caused by: java.lang.IllegalArgumentException: can't parse argument number: USER_ID
Caused by: java.lang.NumberFormatException: For input string: "USER_ID"

Full transcript: parameterized/docs/output/06-header-name-placeholder-throws.txt

The stack trace gives away the exact mechanism from the previous section: MessageFormat’s own pattern parser tries to read USER_ID as a number, because that’s the only kind of token it knows how to parse inside braces, and Integer.parseInt("USER_ID") throws before a single invocation of the test method ever runs. This is a setup-time failure, not a runtime one β€” the test reports a single error, and it has nothing to do with the three argument values (userId, role, canPublish) being wrong or right.

This is a real bug that was in a widely-read older version of this guide. An earlier draft used exactly this {USER_ID}/{ROLE} template alongside useHeadersInDisplayName = true as if it were valid, working syntax. It was never run. See “What changed from the original guide” near the end of this post.

What breaks: a shared mutable argument

JUnit does not guarantee the order parameterized invocations run in, and it does not give each invocation a fresh copy of whatever argument a factory method hands it β€” if that argument is a mutable object and the factory hands the same instance to more than one invocation, each invocation sees whatever the previous one left behind:

@ParameterizedTest
@MethodSource("sharedProvider")
void badSharedConfigAccumulatesTouches(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
    );
}

This method is kept @Disabled in the committed repository so the module’s build stays green; the real failure below was captured by temporarily removing that annotation. Source: parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/SharedMutableArgumentAntiPatternTest.java

com.ankurm.tutorials.junit.parameterized.SharedMutableArgumentAntiPatternTest.badSharedConfigAccumulatesTouches(Config)[2] -- Time elapsed: 0.005 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

Full transcript: parameterized/docs/output/09-shared-mutable-argument-failure.txt

One shared instance accumulates state; a fresh instance per invocation can’t BAD — sharedProvider() new Config() invocation 1 touch() -> count=1, pass invocation 2 touch() -> count=2, FAIL same object both times GOOD — freshProvider() new Config() #1 invocation 1 count=1, pass new Config() #2 invocation 2 count=1, pass freshProvider() builds a new Config() inside the Stream for every Arguments.of(…) call — the whole fix

The fix is the right half of that diagram: freshProvider() constructs a new Config inside the stream for each Arguments.of(...) call, so no two invocations can ever see each other’s state:

@ParameterizedTest
@MethodSource("freshProvider")
void goodFreshConfigIsAlwaysTouchedOnce(Config config) {
    config.touch();
    assertEquals(1, config.getCallCount());
}

// 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())
    );
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/SharedMutableArgumentAntiPatternTest.java

β”‚        β”œβ”€ [1] Config[callCount=0] βœ”
β”‚        └─ [2] Config[callCount=0] βœ”

Full transcript: parameterized/docs/output/10-shared-mutable-argument-fixed.txt

Both display names above correctly read callCount=0, not 1 β€” that is not a bug. JUnit resolves each invocation’s display name from the argument’s own toString() at argument-resolution time, before the test method body (and its config.touch() call) has run at all.

What breaks: mixing unrelated assertions in one invocation

A parameterized test works best when every invocation checks the same single behavior with different data. Encode two unrelated behaviors into one test, keyed off which row happens to be running, and a failure stops telling you anything specific:

// BAD: two unrelated assertions, chosen 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))); // a different assertion per row
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MixedAssertionsAntiPatternTest.java

β”‚     β”‚  β”œβ”€ [1] "ADMIN", "true", "true" βœ”
β”‚     β”‚  └─ [2] "GUEST", "false", "true" βœ”
β”‚     └─ allRolesShouldRead(Role) βœ”
β”‚        β”œβ”€ [1] ADMIN βœ”
β”‚        β”œβ”€ [2] SUPERUSER βœ”
β”‚        β”œβ”€ [3] EDITOR βœ”
β”‚        β”œβ”€ [4] USER βœ”
β”‚        └─ [5] GUEST βœ”

Full transcript: parameterized/docs/output/15-mixed-assertions-anti-pattern.txt

This test passes here, against real AuthService logic β€” it’s a design-smell illustration, not a test expected to fail. But a reader who only ever sees it pass has no way to separate canDelete from canRead by the test name alone, and the day it fails, the report says only “testPermissions failed”, with no clue which of the two unrelated behaviors actually broke. The fix is to split by behavior, not by row:

// GOOD: one focused parameterized test per behavior.
@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));
}

parameterized/src/test/java/com/ankurm/tutorials/junit/parameterized/MixedAssertionsAntiPatternTest.java

Now a canDelete regression fails adminShouldDelete and nothing else, and a canRead regression fails allRolesShouldRead β€” each with a display name that already says exactly what broke, before anyone opens the stack trace.

What the defaults don’t do

Composing with @SpringBootTest

Yes β€” the two annotations compose cleanly. The Spring context initializes once per test class (unless @DirtiesContext says otherwise), and every parameterized invocation runs inside that same context. Parameterization itself adds no meaningful overhead; argument resolution is microseconds against a context startup measured in hundreds of milliseconds.

Re-running only the failed invocations

IntelliJ IDEA 2023.1 and later lets you re-run individual parameterized invocations straight from the test results panel. From the command line, Gradle accepts --tests "com.example.MyTest#shouldMultiply[1]" with the invocation index, and Maven Surefire 3.x supports the equivalent through its own -Dtest parameter β€” both read the same [N] index the display name itself shows.

@MethodSource vs @CsvSource

Reach for @CsvSource when the data is simple literals (strings, numbers, booleans) that fit comfortably inline or in a text block. Reach for @MethodSource the moment the data needs instantiated objects, collections, or values that require logic to compute β€” this post’s User/isActive() example above is exactly that boundary. As a rough rule: writing new Foo() inside the data means @MethodSource, not @CsvSource.

Should every test use @ParameterizedTest? Not every test. The payoff shows up once a method is tested against three or more inputs that exercise the same logic path differently β€” below that, a parameterized test’s ceremony (a source annotation, a name template, sometimes a factory method) can cost more than the one or two extra @Test methods it would have replaced. It also assumes every invocation really does test one behavior, which is exactly what “What breaks: mixing unrelated assertions” above is about β€” a parameterized test that encodes several different behaviors by row is worse than several small plain tests, not better. Start converting the tests that are already copy-pasted three or more times with one or two values changed; those are the ones this feature was built for.

What changed from the original guide

Rebuilding every example from scratch against current JUnit, rather than carrying the original examples forward unverified, surfaced three real defects in the original published guide, not in JUnit itself:

  • The original @MethodSource example’s three expected isActive() outcomes couldn’t be satisfied by any single-field rule for “still valid.” This rewrite’s User uses a renewal date plus a grace period plus a separate enabled flag β€” see the @MethodSource section above 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.
  • The original custom name template referenced CSV header names directly ({USER_ID}, {ROLE}) alongside useHeadersInDisplayName = true. That was never valid syntax β€” see “What breaks: a header name is not a valid placeholder” above for the real exception it throws.

Two things this rewrite covers that the original didn’t touch at all: the shared-mutable-argument anti-pattern, and the interaction between useHeadersInDisplayName and a custom name template’s own positional placeholders. Neither is documented anywhere near as clearly as a real, captured failure makes it.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.