Here’s a pattern I’ve seen on almost every Java team I’ve worked with: a developer writes a clean test for a validation method. Two weeks later, a bug is found with a different input. They copy-paste the test, change two values, rename it testValidate_withNull. A month later there are eight copies, each testing the same two lines of logic with slightly different inputs.
This is test bloat. It’s not just an aesthetic problem β it’s a maintenance trap. When the method signature changes, you’re updating eight tests instead of one. When a new edge case surfaces, do you add a ninth copy or finally refactor?
@ParameterizedTest is JUnit 5’s answer to this. One method, many inputs, full per-invocation reporting in your IDE and CI pipeline. This guide covers every source annotation with working examples, and shows you exactly which one to reach for in each situation.
Quick Reference: Which Source Should You Use?
| 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 (JUnit 5.11+) |
| Full custom control: database, files, external systems | @ArgumentsSource |
Setup
Parameterized tests live in junit-jupiter-params. If you use the junit-jupiter aggregate BOM (recommended), it’s already included. To add it explicitly:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
@ValueSource β Single Values, Multiple Runs
The simplest source: provide a literal array and JUnit runs the test method once per value. Supported types are short, byte, int, long, float, double, char, boolean, String, and Class.
@ParameterizedTest(name = "'{0}' should be a palindrome")
@ValueSource(strings = {"racecar", "radar", "level"})
void shouldIdentifyPalindromes(String candidate) {
assertTrue(candidate.equals(
new StringBuilder(candidate).reverse().toString()
));
}
'racecar' should be a palindrome β
'radar' should be a palindrome β
'level' should be a palindrome β
Each invocation appears as its own line in your IDE test tree and CI report β exactly which input caused a failure is visible without opening the test class.
@NullAndEmptySource β Test Unhappy Paths Without Boilerplate
Null and empty inputs break more production code than any other edge case. These annotations inject them without cluttering your CSV data:
@ParameterizedTest(name = "Blank username [{0}] should be rejected")
@NullAndEmptySource
@ValueSource(strings = {" ", "t", "n"})
void shouldRejectBlankUsernames(String username) {
assertThrows(IllegalArgumentException.class,
() -> userService.register(username, "password123"));
}
This produces 5 invocations: null, "", " ", "t", and "n". Each is a separate entry in the test report. @NullSource and @EmptySource are also available separately when you only need one of the two.
@CsvSource β Multiple Arguments Per Invocation
When you need to pass multiple parameters, @CsvSource parses comma-separated strings and converts them to your method’s parameter types automatically.
@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));
}
From JUnit 5.8+, text blocks are supported inside @CsvSource. This is cleaner for larger datasets and supports inline comments and named column headers in the display name:
@ParameterizedTest(name = "user {USER_ID} with role {ROLE}")
@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));
}
@CsvFileSource β Externalize Large Datasets
When your test dataset grows beyond a dozen rows, or when it’s owned by a QA team who doesn’t write Java, move it to a CSV file on the classpath:
@ParameterizedTest(name = "Postcode {0} should map to region {1}")
@CsvFileSource(resources = "/test-data/postcode-regions.csv",
numLinesToSkip = 1) // skip the header row
void shouldMapPostcodeToRegion(String postcode, String expectedRegion) {
assertEquals(expectedRegion, geocoder.getRegion(postcode));
}
Place the file in src/test/resources/test-data/postcode-regions.csv. JUnit streams rows rather than loading the file entirely upfront, so this works with thousands of rows without heap pressure.
@EnumSource β Iterate Over Enum Constants
The names attribute filters which constants are tested. The mode = EXCLUDE variant is underused β instead of hardcoding which roles to exclude, you declare which are exempt, making the test resilient to new enum constants being added later:
// Test only the privileged roles
@ParameterizedTest(name = "{0} should have write access")
@EnumSource(value = Role.class, names = {"ADMIN", "SUPERUSER"})
void privilegedRolesShouldHaveWriteAccess(Role role) {
assertTrue(authService.canWrite(role));
}
// Test all OTHER roles (exclusion mode)
@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));
}
If a new MODERATOR role is added to the enum, the exclusion-mode test automatically includes it β no test update required.
@MethodSource β Programmatic Data and Complex Objects
Use @MethodSource when your test data can’t be expressed as literals β domain objects, collections, or values that require 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)
);
}
The factory method must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS). If the factory method lives in an external class, use the fully-qualified form: @MethodSource("com.example.Fixtures#activeUserProvider").
@FieldSource β New in JUnit 5.11
JUnit 5.11 added @FieldSource, which references a static field as the data source. It fills the gap between @ValueSource (too simple for lists) and @MethodSource (overkill for constant data):
class CurrencyCodeTest {
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));
}
}
The field must be static and implement Iterable, be an array, or be a Supplier of either. Compared to @MethodSource, there’s no factory method boilerplate β the field is the data.
@ArgumentsSource β Full Custom Control
When your test data lives outside the codebase β in a database, a config file, an environment variable β implement ArgumentsProvider:
class ScenarioArgumentsProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(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));
}
Argument Conversion β @ConvertWith
JUnit 5 auto-converts common types (String β int, String β boolean, String β enum). For custom types, 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());
}
Anti-Patterns to Avoid
Mutable Shared Objects in MethodSource
Test ordering in JUnit 5 is not guaranteed by default. If your factory returns mutable objects and tests modify them, results become non-deterministic:
// BAD: shared mutable object across invocations
static Stream<Arguments> mutableProvider() {
Config shared = new Config();
return Stream.of(
Arguments.of(shared), // test 1 may mutate shared
Arguments.of(shared) // test 2 sees the mutated state
);
}
// GOOD: fresh instance per invocation
static Stream<Arguments> safeProvider() {
return Stream.of(
Arguments.of(new Config()),
Arguments.of(new Config())
);
}
Testing Multiple Behaviors in One Parameterized Test
Parameterized tests work best when every invocation tests the same behavior with different data. When you encode different assertions per row, the test becomes hard to reason about when it fails:
// BAD: Different assertions encoded by role
@CsvSource({"ADMIN, true, true", "GUEST, false, true"})
void testPermissions(String role, boolean canDelete, boolean canRead) {
assertEquals(canDelete, auth.canDelete(role));
assertEquals(canRead, auth.canRead(role)); // different assertion per row
}
// BETTER: Split into two focused parameterized tests
@EnumSource(value = Role.class, names = "ADMIN")
void adminShouldDelete(Role role) { assertTrue(auth.canDelete(role)); }
@EnumSource(Role.class)
void allRolesShouldRead(Role role) { assertTrue(auth.canRead(role)); }
Making Test Names Useful in CI
The default display name [1] racecar is barely scannable in a GitHub Actions failure report. Use the name attribute with placeholders to make failed builds self-explanatory without opening the test class:
{index}β 1-based invocation number{0},{1}β¦ β individual argument values{arguments}β all arguments as a comma-separated string{displayName}β the parent method’s 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));
}
CI output: [1] 3 Γ 4 = 12 β instead of [1] 3, 4, 12 β. When a test fails, you see immediately which multiplication case broke β no grep required.
Frequently Asked Questions
Can I use @ParameterizedTest with @SpringBootTest?
Yes. The two annotations compose cleanly. The Spring context is initialized once per test class (unless you use @DirtiesContext), and each parameterized invocation runs within that context. The parameterization itself adds no meaningful overhead β argument resolution is microseconds.
Can I run only the failed invocations after a build failure?
IntelliJ IDEA 2023.1+ lets you re-run individual parameterized invocations from the test results panel. In Gradle, use --tests "com.example.MyTest#shouldMultiply[1]" with the invocation index. Maven Surefire 3.x supports the same via the -Dtest parameter.
When should I use @MethodSource vs @CsvSource?
Use @CsvSource when your data can be expressed as simple literals (strings, numbers, booleans) and fits comfortably inline. Use @MethodSource when you need to pass instantiated objects, collections, or values that require logic to compute. As a rough rule: if you’re writing new Foo() in your data, you need @MethodSource.
See Also
- JUnit 5 Assertions: The Complete Reference
- JUnit 5 Extensions: Writing Custom Test Behaviour
- Mockito Integration with JUnit 5
Conclusion
The ROI of parameterized tests becomes obvious the moment you refactor your first copy-paste test block. Setup is minimal, reporting granularity is excellent, and the sources scale from a four-value inline array to a fully custom data provider backed by any external system.
Start with @CsvSource for simple multi-argument tests. Reach for @MethodSource when your data requires construction. Use @FieldSource (5.11+) for constant lists that don’t need a factory method. And always invest thirty seconds in a descriptive name attribute β the next developer debugging a 2am CI failure will thank you.