@ValueSource gives you one value per invocation. Real API tests need paired data: an email and a password and an expected status code. @CsvSource is how Jupiter handles multi-column test data inline. When the dataset grows beyond a dozen rows or needs to be shared across teams, @CsvFileSource moves it to an external file. This lesson covers both, including the edge cases — null values, quoted strings, custom delimiters, and making the report readable.
@CsvSource — inline multi-column data
Each string in @CsvSource is one CSV row. JUnit splits it on commas and maps the columns left-to-right to the test method's parameters:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
class LoginApiTest {
@ParameterizedTest
@CsvSource({
"admin@test.com, AdminPass123, 200",
"user@test.com, UserPass123, 200",
"wrong@test.com, BadPass, 401",
"'', password, 400",
"admin@test.com, '', 400"
})
void testLogin(String email, String password, int expectedStatus) {
Response response = apiClient.login(email, password);
assertEquals(expectedStatus, response.getStatusCode());
}
}Five rows produce five separate test executions. Notice the empty-string handling: '' (single-quoted pair) is an empty string "", not null. JUnit strips the quotes and passes the empty string to the parameter.
Null values vs empty strings
The distinction matters for validation logic:
@CsvSource({
"alice@test.com, AdminPass, 200", // normal row
", password, 400", // null email — comma with nothing before it
"'', password, 400", // empty string email — ''
"alice@test.com, , 400" // null password — trailing comma
})
void testLoginNullCases(String email, String password, int expected) {
// email is null for row 2, "" for row 3
// password is null for row 4
}When a column is blank (nothing between commas, or at the end of the row), JUnit injects null. When it's '', JUnit injects "". This mirrors how most backend validators differ between "missing field" (null) and "present but empty field" ("").
@CsvFileSource — external file
When your dataset grows — hundreds of login scenarios, product combinations from a spreadsheet — move it to a CSV file:
@ParameterizedTest
@CsvFileSource(resources = "/testdata/login-data.csv", numLinesToSkip = 1)
void testLoginFromFile(String email, String password, int expectedStatus) {
Response response = apiClient.login(email, password);
assertEquals(expectedStatus, response.getStatusCode());
}Place the file at src/test/resources/testdata/login-data.csv:
email,password,expectedStatus
admin@test.com,AdminPass123,200
user@test.com,UserPass123,200
wrong@test.com,BadPass,401
,password,400
admin@test.com,,400numLinesToSkip = 1 discards the header row. Without it, JUnit tries to inject "email" into the String email parameter, which is technically valid but creates a confusing spurious test case. Always set numLinesToSkip = 1 when your file has a header.
Multiple files in one annotation:
@CsvFileSource(resources = {
"/testdata/valid-logins.csv",
"/testdata/invalid-logins.csv"
}, numLinesToSkip = 1)JUnit processes all files in order and produces one execution per row across all of them.
Custom delimiters
Commas in your data values break naive CSV parsing. Use a different delimiter — pipe is common:
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"admin@test.com | Admin, the Manager | 200",
"user@test.com | User, Ordinary | 200"
})
void testLoginWithCommaNamesInData(String email, String fullName, int expected) {
// fullName contains a comma — pipe delimiter handles it cleanly
assertDoesNotThrow(() -> apiClient.login(email, expected));
}For @CsvFileSource, the delimiter option works the same way:
@CsvFileSource(resources = "/testdata/pipe-data.csv", delimiter = '|', numLinesToSkip = 1)Custom display names in the report
By default, each parameterised execution is labelled [1] admin@test.com, AdminPass123, 200. You can override the name template:
@ParameterizedTest(name = "[{index}] {0} → HTTP {2}")
@CsvSource({
"admin@test.com, AdminPass123, 200",
"wrong@test.com, BadPass, 401"
})
void testLogin(String email, String password, int expectedStatus) { ... }Report shows:
[1] admin@test.com → HTTP 200 ✅
[2] wrong@test.com → HTTP 401 ✅
Template variables: {index} is the 1-based row number, {0} through {N} are the parameter values by position. Keep names short — they appear in CI logs.
Type conversion
JUnit converts CSV string columns to method parameter types automatically for common types: int, long, double, boolean, char, LocalDate, Instant, and any type with a valueOf(String) factory method. For custom types you can register a TypeConverter.
@CsvSource({
"alice@test.com, true, 2024-01-15",
"bob@test.com, false, 2024-06-30"
})
void testUserWithDate(String email, boolean active, LocalDate joinDate) {
// JUnit converts "true" → boolean, "2024-01-15" → LocalDate automatically
}Inline vs external file
@CsvSource vs @CsvFileSource
@CsvSource
Data lives in the test file
No separate file to manage. Good for 3–15 rows.
Immediately visible
Reviewer sees the test and its inputs at once — no jumping to another file.
Harder to maintain at scale
50 rows of CSV strings in a @CsvSource annotation are hard to read and modify.
No spreadsheet tooling
Cannot be edited in Excel or generated by a product owner.
@CsvFileSource
Data lives in src/test/resources
The file is version-controlled separately. Teams can update test data without touching test code.
Shareable and toolable
Product owners or BA teams can update a CSV in Excel and commit it.
numLinesToSkip for headers
Always set numLinesToSkip = 1 when the file has a column-name header row.
Good for 20+ rows
Hundreds of login combinations, product SKUs, or form inputs belong in a file, not in annotation strings.
Comparison with TestNG @DataProvider
In TestNG you return Object[][] from a @DataProvider method. @CsvSource replaces this for simple multi-column data without needing a provider method at all. @CsvFileSource replaces the pattern of reading a CSV file inside a @DataProvider method — you give JUnit the file path and it handles the reading.
The trade-off: TestNG's @DataProvider can compute values programmatically (read from a database, apply transforms). @CsvSource and @CsvFileSource are purely declarative — they read static values. For computed data, use @MethodSource (next lesson).
⚠️ Common mistakes
- Forgetting
numLinesToSkip = 1when the CSV has a header. JUnit treats the header row as a test case. The type converter will try to parse"email"as whatever the first parameter type is. AddnumLinesToSkip = 1whenever your CSV starts with column names. - Confusing
nulland''in@CsvSource. A row like", password, 400"injects null for the first parameter. A row like"'', password, 400"injects empty string. If your validator behaves differently for null vs blank, test both explicitly. - Putting too many rows in
@CsvSource. More than 10–15 rows of inline CSV become hard to maintain. If you find yourself scrolling past a 30-row@CsvSource, move it to@CsvFileSource. Keep the annotation readable.
🎯 Practice task
Build a data-driven login test suite. 25–35 minutes.
- Create a simple
LoginServicewith alogin(String email, String password)method that returns anintstatus code:200for known credentials,401for wrong password,400for blank inputs. - Write
LoginServiceTest.javawith a@CsvSourcetest covering at least five scenarios (happy path, wrong password, null email using,password,401, empty string email using'',password,400, empty password). - Move to file. Create
src/test/resources/testdata/login-scenarios.csvwith a header row and at least 8 rows of data. Write a matching@CsvFileSourcetest. Run both and confirm they produce the same results. - Add custom display names. Apply
name = "[{index}] {0} → expected {2}"to the file-backed test. Run and confirm the report shows readable names. - Custom delimiter. Add a row to your CSV where the email contains a comma (use
alice+work@test.com— no comma, actually fine). Instead, test a product name field like"Widget, Pro Edition"by switching to pipe delimiter. Create a product CSV with|as delimiter and write a@CsvFileSourcetest usingdelimiter = '|'. - Stretch — type conversion. Add a
boolean activecolumn to your user CSV and aLocalDate createdOncolumn. Write a test method that receives both. Confirm JUnit converts them automatically without any extra code.
Next lesson: @MethodSource — factory methods that return complex objects, Stream<Arguments>, and external data sources.