@CsvSource and @CsvFileSource

9 min read

@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,,400

numLinesToSkip = 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 = 1 when 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. Add numLinesToSkip = 1 whenever your CSV starts with column names.
  • Confusing null and '' 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.

  1. Create a simple LoginService with a login(String email, String password) method that returns an int status code: 200 for known credentials, 401 for wrong password, 400 for blank inputs.
  2. Write LoginServiceTest.java with a @CsvSource test covering at least five scenarios (happy path, wrong password, null email using ,password,401, empty string email using '',password,400, empty password).
  3. Move to file. Create src/test/resources/testdata/login-scenarios.csv with a header row and at least 8 rows of data. Write a matching @CsvFileSource test. Run both and confirm they produce the same results.
  4. Add custom display names. Apply name = "[{index}] {0} → expected {2}" to the file-backed test. Run and confirm the report shows readable names.
  5. 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 @CsvFileSource test using delimiter = '|'.
  6. Stretch — type conversion. Add a boolean active column to your user CSV and a LocalDate createdOn column. 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.

// tip to track lessons you complete and pick up where you left off across devices.