Parameterize like a pro with JUnit 5 @CsvSource

#testing Oct 19, 2021 6 min Mike Kowalski

Parameterized tests are definitely my favorite feature of JUnit 5. Defining multiple sets of arguments for the same test can significantly reduce the amount of the test code. Recent additions to the JUnit 5 enable us to write such tests in a whole different way, improving both readability and expressiveness.

This article won’t be yet another primer/overview (like this great introduction from @nipafx). Instead, I’d like to show some not-so-obvious ways of defining test inputs in an elegant, tabular way with JUnit 5. Probably, the fans of the Spock framework (and its Data Tables) would already have an idea of how it could look like, but it’s a non-goal to compare these two frameworks here.

@CsvSource 101

From all the available argument sources (ways of providing arguments for the parameterized tests), the @CsvSource is probably the most powerful one. In principle, it is simply supposed “to express argument lists as comma-separated values” like this:

@ParameterizedTest
@CsvSource({
    "hello world, 11",
    "JUnit 5,      7"
})
void calculatesPhraseLength(String phrase, int expectedLength) {
   Assertions.assertEquals(expectedLength, phrase.length());
}

In the output, we will see that our test has been executed two times - each with a different set of arguments:

calculatesPhraseLength(String, int) ✔
├─ [1] hello world, 11 ✔
└─ [2] JUnit 5, 7 ✔

Starting from JUnit 5.8, the input could be also provided using Text Blocks (available when running on Java 15 or later):

@ParameterizedTest
@CsvSource(textBlock = """
    hello world, 11
    JUnit 5,      7       
""")
void calculatesPhraseLength(String phrase, int expectedLength) {
    Assertions.assertEquals(expectedLength, phrase.length());
}

Although there seem to be no functional differences between those two approaches, using Text Blocks feels simply cleaner. All the upcoming examples will use this feature, but they should work with an array of Strings too.

There are two important features to note about the previous snippet. First, we don’t have to worry about additional whitespaces, as the ignoreLeadingAndTrailingWhitespace option has been enabled by default. Thanks to that, we can format our input in a more readable way by simply aligning the columns in a way we like the most.

Secondly, JUnit was smart enough to convert the second parameter to a number. In fact, there multiple built-in implicit type converters for frequently used types including numbers, enums, date-time values, UUIDs, and even files. If needed, we may also rely on the automated String-to-Object conversion, implement our own converter or use argument aggregation to support other types too.

Not so CSV after all

Trimming leading and trailing whitespaces is already somehow against the traditional CSV format, but we can break the rules a little bit more (just like the whole industry does when dealing with CSVs) and replace the comma with a different separator, like a pipe:

@ParameterizedTest
@CsvSource(delimiter = '|', textBlock = """
    Hello world!    | Hallo Welt!   | 12
    Spock           | JUnit Jupiter | 13
                    | Java          |  4
    ''              | ''            |  0
""")
void calculatesMaxLength(String phrase1, String phrase2, int expected) {
    int actual = calculator.maxLength(phrase1, phrase2);
    Assertions.assertEquals(expected, actual);
}

Together with proper columns alignment, we get a readable table of arguments, separated from the actual test code.

The output of this test reveals yet another feature - an ability to pass empty String or null as an argument:

calculatesMaxLength(String, String, int) ✔
├─ [1] Hello world!, Hallo Welt!, 12 ✔
└─ [2] Spock, JUnit Jupiter, 13 ✔
├─ [3] null, Java, 4 ✔
└─ [4] , , 0 ✔

According to the Junit 5 User Guide:

An empty, quoted value '' results in an empty String unless the emptyValue attribute is set; whereas, an entirely empty value is interpreted as a null reference.

What if we could go one step further and replace the single-character delimiter with something more verbose? This would be especially beneficial for tests accepting only two parameters - input and expected output - as we could express the test cases almost as with a dedicated DSL. Let’s see @CsvSource(delimiterString="...") in action:

@ParameterizedTest
@CsvSource(delimiterString = "->", textBlock = """
    fooBar        -> FooBar
    junit_jupiter -> JunitJupiter
    CsvSource     -> CsvSource
""")
void convertsToUpperCamelCase(String input, String expected) {
    String converted = caseConverter.toUpperCamelCase(input);
    Assertions.assertEquals(expected, converted);
}

Of course, such a delimiter does not have to be a symbol. However, when setting it to a word or a phrase we may want to consider single-quoting the arguments to improve readability:

@ParameterizedTest
@CsvSource(delimiterString = "maps to", textBlock = """
    'foo'    maps to  'bar'
    'junit'  maps to  'jupiter'
""")
void shouldMapPair(String input, String expected) {
    String actual = pairMapper.map(input);
    Assertions.assertEquals(expected, actual);
}

Such an approach would work best for tests accepting only two parameters: input and expected output, as currently only one delimiter could be defined at a time. This makes it a reasonable choice for testing various mappers and converters.

If only the @CsvSource would accept a list of supported delimiters, this could become a simple yet powerful way of defining more complex test inputs via DSL-like syntax. However, at the time of writing there is no such possibility.

Update 2023-05-02: I’ve created a small library called JUnit 5 FormattedSource that allows writing tests like this, without the limitations of @CsvSource. You can find more details in this post.

Parameterizing test case names

When all test inputs have been defined as a table, why not define the test case display names in a similar way? Luckily, @ParameterizedTest accepts the name option together with a simple templating solution:

@ParameterizedTest(name = "{index} => calculates the sum of {0}: ({1}, {2})")
@CsvSource(delimiter = '|', textBlock = """
    positive numbers      |   10  |      6  |   16
    positive and negative |   -4  |      2  |   -2
    negative numbers      |   -6  |   -100  | -106
""")
void calculatesSum(String description, int a, int b, int expectedSum) {
    int actual = calculator.sum(a, b);
    Assertions.assertEquals(expectedSum, actual);
}

In the example above, {index} refers to the ordinal number of the test case (starting from 1), while {n} is the value of the n-th argument (starting from 0). As a result, the following output should be presented:

calculatesSum(String, int, int) ✔
├─ 1 => calculates the sum of positive numbers: (10, 6) ✔
└─ 2 => calculates the sum of positive and negative: (-4, 2) ✔
└─ 3 => calculates the sum of negative numbers: (-6, -100) ✔

There is one more improvement we can introduce here: define a human-readable display name for the whole test (via @DisplayName) instead of using method signature:

@DisplayName("Calculates the sum of:")
@ParameterizedTest(name = "{index} => {0}: ({1}, {2})")
@CsvSource(delimiter = '|', textBlock = """
    positive numbers      |   10  |      6  |   16
    positive and negative |   -4  |      2  |   -2        
    negative numbers      |   -6  |   -100  | -106
""")
void calculatesSum(String description, int a, int b, int expectedSum) {
    int actual = calculator.sum(a, b);
    Assertions.assertEquals(expectedSum, actual);
}

The resulting output looks just like expected:

Calculates the sum of: ✔
├─ 1 => positive numbers: (10, 6) ✔
└─ 2 => positive and negative: (-4, 2) ✔
└─ 3 => negative numbers: (-6, -100) ✔

Of course, the drawback of such an approach is that the argument used as a test case name would be left unused (@SupppressWarnings("unused") may be needed).

Summary

JUnit 5 @CsvSource with a little bit of customization can take testing to a whole new level of expressiveness. It not only allows us to easily define the arguments as a “table” but even express them (in some cases) in a more semantically meaningful way.

Although some of the proposed solutions seem to be quite far from the original CSV format assumptions, I believe the tools we have should be used as efficiently as possible. Therefore, I encourage you to take a look at the official JUnit 5 User Guide covering all the features available in the most popular Java testing toolkit.

Mike Kowalski

Software engineer believing in craftsmanship and the power of fresh espresso. Writing in & about Java, distributed systems, and beyond. Mikes his own opinions and bytes.