Skip to content

Commit 4ef6e70

Browse files
committed
Support CSV headers in display names in parameterized tests
Given the following parameterized test that sets useHeadersInDisplayName to true and uses {arguments} instead of {argumentsWithNames} for its display name pattern... @ParameterizedTest(name = "[{index}] {arguments}") @CsvSource(useHeadersInDisplayName = true, textBlock = """ FRUIT, RANK apple, 1 banana, 2 cherry, 3 """) void test(String fruit, int rank) {} The generated display names are: [1] FRUIT = apple, RANK = 1 [2] FRUIT = banana, RANK = 2 [3] FRUIT = cherry, RANK = 3 See #2759
1 parent 69aed70 commit 4ef6e70

File tree

14 files changed

+345
-84
lines changed

14 files changed

+345
-84
lines changed

documentation/src/docs/asciidoc/release-notes/release-notes-5.8.2.adoc

+7-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*Scope:*
77

88
* Text blocks in `@CsvSource` are treated like CSV files
9+
* CSV headers in display names for `@CsvSource` and `@CsvFileSource`
910
* Custom quote character support in `@CsvSource` and `@CsvFileSource`
1011

1112
For a complete list of all _closed_ issues and pull requests for this release, consult the
@@ -29,8 +30,13 @@ No changes.
2930
quoted strings. See the
3031
<<../user-guide/index.adoc#writing-tests-parameterized-tests-sources-CsvSource, User
3132
Guide>> for details and examples.
33+
* CSV headers can now be used in display names in parameterized tests. See
34+
<<../user-guide/index.adoc#writing-tests-parameterized-tests-sources-CsvSource,
35+
`@CsvSource`>> and
36+
<<../user-guide/index.adoc#writing-tests-parameterized-tests-sources-CsvFileSource,
37+
`@CsvFileSource`>> in the User Guide for details and examples.
3238
* The quote character for _quoted strings_ in `@CsvSource` and `@CsvFileSource` is now
33-
configurable via new `quoteCharacter` attributes in each annotation.
39+
configurable via a new `quoteCharacter` attribute in each annotation.
3440

3541

3642
[[release-notes-5.8.2-junit-vintage]]

documentation/src/docs/asciidoc/user-guide/writing-tests.adoc

+44-5
Original file line numberDiff line numberDiff line change
@@ -1332,7 +1332,9 @@ include::{testDir}/example/ExternalMethodSourceDemo.java[tags=external_MethodSou
13321332

13331333
`@CsvSource` allows you to express argument lists as comma-separated values (i.e., CSV
13341334
`String` literals). Each string provided via the `value` attribute in `@CsvSource`
1335-
represents a CSV record and results in one invocation of the parameterized test.
1335+
represents a CSV record and results in one invocation of the parameterized test. The first
1336+
record may optionally be used to supply CSV headers (see the Javadoc for the
1337+
`useHeadersInDisplayName` attribute for details and an example).
13361338

13371339
[source,java,indent=0]
13381340
----
@@ -1375,12 +1377,16 @@ by default. This behavior can be changed by setting the
13751377
If the programming language you are using supports _text blocks_ -- for example, Java SE
13761378
15 or higher -- you can alternatively use the `textBlock` attribute of `@CsvSource`. Each
13771379
record within a text block represents a CSV record and results in one invocation of the
1378-
parameterized test. Using a text block, the previous example can be implemented as follows.
1380+
parameterized test. The first record may optionally be used to supply CSV headers by
1381+
setting the `useHeadersInDisplayName` attribute to `true` as in the example below.
1382+
1383+
Using a text block, the previous example can be implemented as follows.
13791384

13801385
[source,java,indent=0]
13811386
----
1382-
@ParameterizedTest
1383-
@CsvSource(textBlock = """
1387+
@ParameterizedTest(name = "[{index}] {arguments}")
1388+
@CsvSource(useHeadersInDisplayName = true, textBlock = """
1389+
FRUIT, RANK
13841390
apple, 1
13851391
banana, 2
13861392
'lemon, lime', 0xF1
@@ -1391,6 +1397,15 @@ void testWithCsvSource(String fruit, int rank) {
13911397
}
13921398
----
13931399

1400+
The generated display names for the previous example include the CSV header names.
1401+
1402+
----
1403+
[1] FRUIT = apple, RANK = 1
1404+
[2] FRUIT = banana, RANK = 2
1405+
[3] FRUIT = lemon, lime, RANK = 0xF1
1406+
[4] FRUIT = strawberry, RANK = 700_000
1407+
----
1408+
13941409
In contrast to CSV records supplied via the `value` attribute, a text block can contain
13951410
comments. Any line beginning with a `+++#+++` symbol will be treated as a comment and
13961411
ignored. Note, however, that the `+++#+++` symbol must be the first character on the line
@@ -1435,7 +1450,11 @@ your text block.
14351450

14361451
`@CsvFileSource` lets you use comma-separated value (CSV) files from the classpath or the
14371452
local file system. Each record from a CSV file results in one invocation of the
1438-
parameterized test.
1453+
parameterized test. The first record may optionally be used to supply CSV headers. You can
1454+
instruct JUnit to ignore the headers via the `numLinesToSkip` attribute. If you would like
1455+
for the headers to be used in the display names, you can set the `useHeadersInDisplayName`
1456+
attribute to `true`. The examples below demonstrate the use of `numLinesToSkip` and
1457+
`useHeadersInDisplayName`.
14391458

14401459
The default delimiter is a comma (`,`), but you can use another character by setting the
14411460
`delimiter` attribute. Alternatively, the `delimiterString` attribute allows you to use a
@@ -1457,6 +1476,26 @@ include::{testDir}/example/ParameterizedTestDemo.java[tags=CsvFileSource_example
14571476
include::{testResourcesDir}/two-column.csv[]
14581477
----
14591478

1479+
The following listing shows the generated display names for the first two parameterized
1480+
test methods above.
1481+
1482+
----
1483+
[1] country=Sweden, reference=1
1484+
[2] country=Poland, reference=2
1485+
[3] country=United States of America, reference=3
1486+
[4] country=France, reference=700_000
1487+
----
1488+
1489+
The following listing shows the generated display names for the last parameterized test
1490+
method above that uses CSV header names.
1491+
1492+
----
1493+
[1] COUNTRY = Sweden, REFERENCE = 1
1494+
[2] COUNTRY = Poland, REFERENCE = 2
1495+
[3] COUNTRY = United States of America, REFERENCE = 3
1496+
[4] COUNTRY = France, REFERENCE = 700_000
1497+
----
1498+
14601499
In contrast to the default syntax used in `@CsvSource`, `@CsvFileSource` uses a double
14611500
quote (`+++"+++`) as the quote character by default, but this can be changed via the
14621501
`quoteCharacter` attribute. See the `"United States of America"` value in the example

documentation/src/test/java/example/ParameterizedTestDemo.java

+7
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,13 @@ void testWithCsvFileSourceFromFile(String country, int reference) {
236236
assertNotNull(country);
237237
assertNotEquals(0, reference);
238238
}
239+
240+
@ParameterizedTest(name = "[{index}] {arguments}")
241+
@CsvFileSource(resources = "/two-column.csv", useHeadersInDisplayName = true)
242+
void testWithCsvFileSourceAndHeaders(String country, int reference) {
243+
assertNotNull(country);
244+
assertNotEquals(0, reference);
245+
}
239246
// end::CsvFileSource_example[]
240247

241248
// tag::ArgumentsSource_example[]

documentation/src/test/resources/two-column.csv

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Country, Reference
1+
COUNTRY, REFERENCE
22
Sweden, 1
33
Poland, 2
44
"United States of America", 3

junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java

+69-27
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import java.io.StringReader;
1717
import java.lang.annotation.Annotation;
18+
import java.util.ArrayList;
1819
import java.util.Arrays;
1920
import java.util.List;
2021
import java.util.Set;
@@ -23,6 +24,7 @@
2324

2425
import com.univocity.parsers.csv.CsvParser;
2526

27+
import org.junit.jupiter.api.Named;
2628
import org.junit.jupiter.api.extension.ExtensionContext;
2729
import org.junit.jupiter.params.support.AnnotationConsumer;
2830
import org.junit.platform.commons.PreconditionViolationException;
@@ -53,56 +55,96 @@ public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
5355
Preconditions.condition(this.annotation.value().length > 0 ^ textBlockDeclared,
5456
() -> "@CsvSource must be declared with either `value` or `textBlock` but not both");
5557

56-
if (textBlockDeclared) {
57-
return parseTextBlock(this.annotation.textBlock()).stream().map(Arguments::of);
58-
}
59-
60-
AtomicInteger index = new AtomicInteger(0);
61-
// @formatter:off
62-
return Arrays.stream(this.annotation.value())
63-
.map(line -> parseLine(line, index.incrementAndGet()))
64-
.map(Arguments::of);
65-
// @formatter:on
58+
return textBlockDeclared ? parseTextBlock() : parseValueArray();
6659
}
6760

68-
private List<String[]> parseTextBlock(String textBlock) {
61+
private Stream<Arguments> parseTextBlock() {
62+
String textBlock = this.annotation.textBlock();
63+
boolean useHeadersInDisplayName = this.annotation.useHeadersInDisplayName();
64+
List<Arguments> argumentsList = new ArrayList<>();
65+
6966
try {
70-
AtomicInteger index = new AtomicInteger(0);
7167
List<String[]> csvRecords = this.csvParser.parseAll(new StringReader(textBlock));
68+
String[] headers = useHeadersInDisplayName ? getHeaders(this.csvParser) : null;
69+
70+
AtomicInteger index = new AtomicInteger(0);
7271
for (String[] csvRecord : csvRecords) {
7372
index.incrementAndGet();
7473
Preconditions.notNull(csvRecord,
75-
() -> "Line at index " + index.get() + " contains invalid CSV: \"\"\"\n" + textBlock + "\n\"\"\"");
76-
processNullValues(csvRecord, this.nullValues);
74+
() -> "Record at index " + index + " contains invalid CSV: \"\"\"\n" + textBlock + "\n\"\"\"");
75+
argumentsList.add(processCsvRecord(csvRecord, this.nullValues, useHeadersInDisplayName, headers));
7776
}
78-
return csvRecords;
7977
}
8078
catch (Throwable throwable) {
8179
throw handleCsvException(throwable, this.annotation);
8280
}
81+
82+
return argumentsList.stream();
8383
}
8484

85-
private String[] parseLine(String line, int index) {
85+
private Stream<Arguments> parseValueArray() {
86+
boolean useHeadersInDisplayName = this.annotation.useHeadersInDisplayName();
87+
List<Arguments> argumentsList = new ArrayList<>();
88+
8689
try {
87-
String[] csvRecord = this.csvParser.parseLine(line + LINE_SEPARATOR);
88-
Preconditions.notNull(csvRecord,
89-
() -> "Line at index " + index + " contains invalid CSV: \"" + line + "\"");
90-
processNullValues(csvRecord, this.nullValues);
91-
return csvRecord;
90+
String[] headers = null;
91+
AtomicInteger index = new AtomicInteger(0);
92+
for (String input : this.annotation.value()) {
93+
index.incrementAndGet();
94+
String[] csvRecord = this.csvParser.parseLine(input + LINE_SEPARATOR);
95+
// Lazily retrieve headers if necessary.
96+
if (useHeadersInDisplayName && headers == null) {
97+
headers = getHeaders(this.csvParser);
98+
}
99+
Preconditions.notNull(csvRecord,
100+
() -> "Record at index " + index + " contains invalid CSV: \"" + input + "\"");
101+
argumentsList.add(processCsvRecord(csvRecord, this.nullValues, useHeadersInDisplayName, headers));
102+
}
92103
}
93104
catch (Throwable throwable) {
94105
throw handleCsvException(throwable, this.annotation);
95106
}
107+
108+
return argumentsList.stream();
96109
}
97110

98-
static void processNullValues(String[] csvRecord, Set<String> nullValues) {
99-
if (!nullValues.isEmpty()) {
100-
for (int i = 0; i < csvRecord.length; i++) {
101-
if (nullValues.contains(csvRecord[i])) {
102-
csvRecord[i] = null;
103-
}
111+
// Cannot get parsed headers until after parsing has started.
112+
static String[] getHeaders(CsvParser csvParser) {
113+
return Arrays.stream(csvParser.getContext().parsedHeaders())//
114+
.map(String::trim)//
115+
.toArray(String[]::new);
116+
}
117+
118+
/**
119+
* Processes custom null values, supports wrapping of column values in
120+
* {@link Named} if necessary (for CSV header support), and returns the
121+
* CSV record wrapped in an {@link Arguments} instance.
122+
*/
123+
static Arguments processCsvRecord(Object[] csvRecord, Set<String> nullValues, boolean useHeadersInDisplayName,
124+
String[] headers) {
125+
126+
// Nothing to process?
127+
if (nullValues.isEmpty() && !useHeadersInDisplayName) {
128+
return Arguments.of(csvRecord);
129+
}
130+
131+
Preconditions.condition(!useHeadersInDisplayName || (csvRecord.length <= headers.length),
132+
() -> String.format(
133+
"The number of columns (%d) exceeds the number of supplied headers (%d) in CSV record: %s",
134+
csvRecord.length, headers.length, Arrays.toString(csvRecord)));
135+
136+
Object[] arguments = new Object[csvRecord.length];
137+
for (int i = 0; i < csvRecord.length; i++) {
138+
Object column = csvRecord[i];
139+
if (nullValues.contains(column)) {
140+
column = null;
141+
}
142+
if (useHeadersInDisplayName) {
143+
column = Named.of(headers[i] + " = " + column, column);
104144
}
145+
arguments[i] = column;
105146
}
147+
return Arguments.of(arguments);
106148
}
107149

108150
/**

junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java

+18-10
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
import static java.util.Spliterators.spliteratorUnknownSize;
1414
import static java.util.stream.Collectors.toList;
1515
import static java.util.stream.StreamSupport.stream;
16-
import static org.junit.jupiter.params.provider.Arguments.arguments;
16+
import static org.junit.jupiter.params.provider.CsvArgumentsProvider.getHeaders;
1717
import static org.junit.jupiter.params.provider.CsvArgumentsProvider.handleCsvException;
18-
import static org.junit.jupiter.params.provider.CsvArgumentsProvider.processNullValues;
18+
import static org.junit.jupiter.params.provider.CsvArgumentsProvider.processCsvRecord;
1919
import static org.junit.jupiter.params.provider.CsvParserFactory.createParserFor;
2020
import static org.junit.platform.commons.util.CollectionUtils.toSet;
2121

@@ -119,41 +119,49 @@ private static class CsvParserIterator implements Iterator<Arguments> {
119119

120120
private final CsvParser csvParser;
121121
private final CsvFileSource annotation;
122+
private final boolean useHeadersInDisplayName;
122123
private final Set<String> nullValues;
123-
private Object[] nextCsvRecord;
124+
private Arguments nextArguments;
125+
private String[] headers;
124126

125127
CsvParserIterator(CsvParser csvParser, CsvFileSource annotation) {
126128
this.csvParser = csvParser;
127129
this.annotation = annotation;
130+
this.useHeadersInDisplayName = annotation.useHeadersInDisplayName();
128131
this.nullValues = toSet(annotation.nullValues());
129132
advance();
130133
}
131134

132135
@Override
133136
public boolean hasNext() {
134-
return this.nextCsvRecord != null;
137+
return this.nextArguments != null;
135138
}
136139

137140
@Override
138141
public Arguments next() {
139-
Arguments result = arguments(this.nextCsvRecord);
142+
Arguments result = this.nextArguments;
140143
advance();
141144
return result;
142145
}
143146

144147
private void advance() {
145-
String[] csvRecord = null;
146148
try {
147-
csvRecord = this.csvParser.parseNext();
149+
String[] csvRecord = this.csvParser.parseNext();
148150
if (csvRecord != null) {
149-
processNullValues(csvRecord, this.nullValues);
151+
// Lazily retrieve headers if necessary.
152+
if (this.useHeadersInDisplayName && this.headers == null) {
153+
this.headers = getHeaders(this.csvParser);
154+
}
155+
this.nextArguments = processCsvRecord(csvRecord, this.nullValues, this.useHeadersInDisplayName,
156+
this.headers);
157+
}
158+
else {
159+
this.nextArguments = null;
150160
}
151161
}
152162
catch (Throwable throwable) {
153163
handleCsvException(throwable, this.annotation);
154164
}
155-
156-
this.nextCsvRecord = csvRecord;
157165
}
158166

159167
}

junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java

+31-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
* or {@link #files}.
2828
*
2929
* <p>The CSV records parsed from these resources and files will be provided as
30-
* arguments to the annotated {@code @ParameterizedTest} method.
30+
* arguments to the annotated {@code @ParameterizedTest} method. Note that the
31+
* first record may optionally be used to supply CSV headers (see
32+
* {@link #useHeadersInDisplayName}).
3133
*
3234
* <p>Any line beginning with a {@code #} symbol will be interpreted as a comment
3335
* and will be ignored.
@@ -95,6 +97,34 @@
9597
*/
9698
String lineSeparator() default "\n";
9799

100+
/**
101+
* Configures whether the first CSV record should be treated as header names
102+
* for columns.
103+
*
104+
* <p>When set to {@code true}, the header names will be used in the
105+
* generated display name for each {@code @ParameterizedTest} method
106+
* invocation. When using this feature, you must ensure that the display name
107+
* pattern for {@code @ParameterizedTest} includes
108+
* {@value org.junit.jupiter.params.ParameterizedTest#ARGUMENTS_PLACEHOLDER} instead of
109+
* {@value org.junit.jupiter.params.ParameterizedTest#ARGUMENTS_WITH_NAMES_PLACEHOLDER}
110+
* as demonstrated in the example below.
111+
*
112+
* <p>Defaults to {@code false}.
113+
*
114+
*
115+
* <h4>Example</h4>
116+
* <pre class="code">
117+
* {@literal @}ParameterizedTest(name = "[{index}] {arguments}")
118+
* {@literal @}CsvFileSource(resources = "fruits.csv", useHeadersInDisplayName = true)
119+
* void test(String fruit, int rank) {
120+
* // ...
121+
* }</pre>
122+
*
123+
* @since 5.8.2
124+
*/
125+
@API(status = EXPERIMENTAL, since = "5.8.2")
126+
boolean useHeadersInDisplayName() default false;
127+
98128
/**
99129
* The quote character to use for <em>quoted strings</em>.
100130
*

0 commit comments

Comments
 (0)