Parameterized Tests in JUnit 5 with CSV and Method Sources
Parameterized tests let one test method run repeatedly with different inputs. In JUnit 5, this is useful when the behaviour stays the same while values, expected results, or edge cases change. Instead of copying a test for every scenario, you describe the scenarios as arguments.
Two especially practical options are @CsvSource and @MethodSource. The first keeps small datasets close to the test in a readable format. The second lets you build arguments with Java code, which suits complex objects, generated values, and reusable test data.
This approach works well for backend services, REST endpoints, validation rules, and asynchronous workflows. A team working across Sydney, Melbourne, and Perth can keep a compact set of examples in one test while avoiding repeated setup and maintenance.
The examples below use Java and JUnit Jupiter. They apply equally well to Maven and Gradle projects, provided the JUnit 5 parameterized-test dependency is available.
Setting Up Parameterized Tests
A parameterized test needs the JUnit Jupiter parameterized module. With Maven, add the dependency alongside the usual JUnit Jupiter engine:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
The test method is marked with @ParameterizedTest, and the source annotation supplies its arguments. The method parameters must match the values supplied by the source, including their order and compatible types.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PriceCalculatorTest {
@ParameterizedTest
@CsvSource({
"10.00, 2, 20.00",
"7.50, 4, 30.00",
"0.00, 3, 0.00"
})
void calculatesTotal(double unitPrice, int quantity, double expected) {
assertEquals(expected, unitPrice * quantity);
}
}
JUnit converts the CSV values into the declared Java types. A failed invocation reports the supplied arguments, making it easier to identify the failing case than a large loop inside one ordinary test.
Using CsvSource For Compact Cases
@CsvSource is a good fit for short, static examples. Each line represents one invocation, and commas separate the values. The annotation accepts strings, integers, booleans, enums, and many other types through JUnit's argument conversion.
For example, a password validator can be tested with valid and invalid inputs:
@ParameterizedTest(name = "[{index}] password={0}, valid={1}")
@CsvSource({
"'correct-horse-battery-staple', true",
"'short', false",
"'contains space', false",
"'', false"
})
void validatesPassword(String password, boolean expected) {
assertEquals(expected, PasswordRules.isValid(password));
}
The name attribute makes the test output clearer. Quoted values are important when a field contains a comma, leading whitespace, or an empty string. JUnit's CSV parser also supports @CsvSource(textBlock = """ ... """) in modern Java, which can make a longer dataset easier to scan.
CSV data is particularly effective for boundary-value analysis. An Australian address service, for instance, could check postcode rules using "2000, true" for Sydney, "3000, true" for Melbourne, and "999, false" for an invalid value. Keep values simple enough that the row explains the scenario without requiring a separate document.
Handling Nulls And Special Values
Blank CSV fields and null values are different. An empty field can be represented with "", while nullValues tells JUnit which token should become null:
@ParameterizedTest
@CsvSource(
value = {
"admin, ADMIN",
"guest, GUEST",
"NULL, UNKNOWN"
},
nullValues = "NULL"
)
void mapsRole(String input, String expected) {
assertEquals(expected, RoleMapper.normalise(input));
}
The conversion rules deserve attention when testing dates, characters, or domain types. For a LocalDate, use an ISO value such as 2025-01-26; for a char, provide a single character. If the production code distinguishes an omitted field from an empty field, include both cases explicitly.
Currency is another practical concern. Avoid using double for financial assertions merely because it is convenient in CSV. Pass String values and convert them to BigDecimal, or use a custom argument converter. This avoids rounding failures in systems handling Australian dollars, tax, or cents.
A test should communicate intent rather than hide it in parsing details. If several rows require escaping, custom conversion, or large object construction, @MethodSource will usually be easier to maintain.
Building Rich Data With MethodSource
@MethodSource delegates argument creation to a static method, unless the test class uses a per-class lifecycle. The factory can return a Stream<Arguments>, an iterable, an array, or another supported source type.
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;
class ShippingCostTest {
@ParameterizedTest
@MethodSource("shippingCases")
void calculatesShipping(Address address, int weightKg, String expected) {
assertEquals(expected, ShippingCalculator.quote(address, weightKg));
}
static Stream<Arguments> shippingCases() {
return Stream.of(
Arguments.of(new Address("Sydney", "NSW", "2000"), 2, "12.00"),
Arguments.of(new Address("Perth", "WA", "6000"), 5, "24.00"),
Arguments.of(new Address("Hobart", "TAS", "7000"), 1, "15.00")
);
}
}
This format lets each scenario use a real domain object, rather than forcing the test to reconstruct one from comma-separated text. It is also useful for REST request bodies, expected response objects, authentication claims, and combinations of feature flags.
A named source can be shared by several tests:
@MethodSource("validUsers")
The factory may then live in another class and be referenced with a fully qualified method name. Keep shared providers focused. A massive provider that feeds unrelated tests can make failures difficult to understand and can introduce accidental coupling between test cases.
For asynchronous services, the arguments might include a message, expected state, and timeout. The test still needs reliable waiting and clear failure behaviour; parameterization should vary the scenario, not conceal flaky polling logic.
Choosing Data For Realistic Backend Tests
Parameterized tests are valuable when testing a matrix of API behaviours: HTTP status, input payload, authorization role, and expected error code. A REST test can receive a path, request body, status code, and response field as method-source arguments. This keeps the assertion structure consistent while covering meaningful variations.
For browser-facing flows, the same principle applies to data preparation even when the execution tool is different. The Geb and Spock guide shows a separate Groovy-based approach, but the testing lesson carries across: keep scenario data distinct from interaction code.
Use local realities where they represent genuine business rules. Examples might include Australia Post-style postcodes, Australia/Sydney versus Australia/Perth time zones, or public-holiday behaviour that differs between NSW and Victoria. Avoid inserting local names simply for decoration; each case should protect a rule the system actually promises.
Distributed Australian teams also benefit from deterministic time data. A test that depends on “today” can behave differently during an early-morning run in Perth and an afternoon run in Melbourne. Supply an explicit Clock or timestamp through a method source instead of relying on the machine's local timezone.
Comparing The Two Sources
The choice is primarily about how much structure the test data needs. @CsvSource keeps a small data set immediately visible, while @MethodSource offers normal Java expressions and domain objects.
| Concern | @CsvSource |
@MethodSource |
|---|---|---|
| Best for | Small scalar values | Objects and rich scenarios |
| Readability | Excellent for short rows | Clear when construction needs code |
| Type conversion | JUnit performs conversion | Factory creates exact types |
| Reuse | Limited unless duplicated | Easy to share or compose |
| Complex setup | Awkward | Natural |
| Maintenance risk | Long rows become cryptic | Providers can become oversized |
| Typical examples | Validation boundaries and flags | API requests, dates, domain models |
A useful rule is to begin with @CsvSource when every argument can be understood as a short literal. Move to @MethodSource when a row needs nested data, calculated values, reusable fixtures, or comments to explain its construction.
Neither source replaces good test design. Each invocation should be independent, failures should identify the relevant input, and the dataset should cover partitions and boundaries rather than arbitrary volume. Ten carefully selected cases are usually more valuable than a hundred nearly identical rows.
Parameterized testing also fits resilience checks. For actor-based or message-driven systems, vary message type, retry count, and expected outcome through a method source. Concepts such as failure supervision become easier to verify when each recovery policy is represented by a named, repeatable scenario.
The key distinction is simple: use CSV for compact, readable literals and method sources for expressive, programmable data. When the input model matches the source, JUnit 5 tests stay concise, diagnostic, and useful as the application grows.