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 againstmaven-metadata.xmlon 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 realmvn testrun (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.
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
@ValueSourceelement 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.
@NullSourceand@EmptySourcealso 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 benullbut 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
useHeadersInDisplayNameactually 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 fullArgumentsProviderinterface, includingParameterDeclarations, 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 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
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 alongsideuseHeadersInDisplayName = trueas 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
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())
);
}
β ββ [1] Config[callCount=0] β
β ββ [2] Config[callCount=0] β
Full transcript: parameterized/docs/output/10-shared-mutable-argument-fixed.txt
Both display names above correctly readcallCount=0, not1β that is not a bug. JUnit resolves each invocation’s display name from the argument’s owntoString()at argument-resolution time, before the test method body (and itsconfig.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
}
β β ββ [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));
}
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
@MethodSourceexampleβs three expectedisActive()outcomes couldnβt be satisfied by any single-field rule for “still valid.” This rewrite’sUseruses a renewal date plus a grace period plus a separateenabledflag β see the@MethodSourcesection above for why all three original expected outcomes now genuinely hold. - The original
@NullAndEmptySourceexample used the literal letters"t"and"n"where it clearly meant the escape sequences"\t"and"\n"β the letters are not blank perString.isBlank(), which silently broke the exampleβs own stated premise. - The original custom
nametemplate referenced CSV header names directly ({USER_ID},{ROLE}) alongsideuseHeadersInDisplayName = 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
- parameterized companion repository β versions, quickstart, the full source/output index, and the complete list of what changed from the original guide
- Organising and Displaying JUnit Tests: @DisplayName, @Nested, @Tag β for display-name control that applies to every test, not only parameterized ones
- ArchUnit for Spring Boot: Enforcing Layered Architecture as a Unit Test β another test in this series that reads real compiled output rather than a remembered one
- JUnit 6 Deep Dive: What’s New, What Changed, and How to Migrate β for the 5.x β 6.1.3 jump this postβs companion module was built against
- JUnit 6 with Mockito: Mocking, Spying, and Best Practices β for combining parameterized tests with mocked collaborators
- JUnit 5 User Guide: Parameterized Tests, the primary reference this post samples from
- junit-jupiter-params on Maven Central, for checking the current GA version against what this post used
No Comments yet!