Skip to content

Commit

Permalink
[WIP] Support CSV headers in display names in parameterized tests
Browse files Browse the repository at this point in the history
See #2759
  • Loading branch information
sbrannen committed Oct 29, 2021
1 parent d23c6a9 commit 857d841
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

package org.junit.jupiter.params.provider;

import static java.util.stream.Collectors.toList;
import static org.junit.jupiter.params.provider.CsvParserFactory.createParserFor;
import static org.junit.platform.commons.util.CollectionUtils.toSet;

Expand Down Expand Up @@ -60,60 +59,63 @@ public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return parseTextBlock(this.annotation.textBlock(), this.annotation.useHeadersInDisplayName());
}

AtomicInteger index = new AtomicInteger(0);
// @formatter:off
return Arrays.stream(this.annotation.value())
.map(line -> parseLine(line, index.incrementAndGet()))
.map(Arguments::of);
// @formatter:on
return parseValueArray(this.annotation.value(), this.annotation.useHeadersInDisplayName());
}

private Stream<Arguments> parseTextBlock(String textBlock, boolean useHeadersInDisplayName) {
List<Arguments> argumentsList = new ArrayList<>();

try {
List<String[]> csvRecords = this.csvParser.parseAll(new StringReader(textBlock));
// Cannot get parsed headers until after parsing has started.
List<String> headers = Arrays.stream(this.csvParser.getContext().parsedHeaders())//
.map(String::trim)//
.collect(toList());
String[] headers = useHeadersInDisplayName ? getHeaders(this.csvParser) : null;

List<Arguments> argumentsList = new ArrayList<>();
AtomicInteger index = new AtomicInteger(0);
for (String[] csvRecord : csvRecords) {
index.incrementAndGet();
Preconditions.notNull(csvRecord,
() -> "Line at index " + index.get() + " contains invalid CSV: \"\"\"\n" + textBlock + "\n\"\"\"");
() -> "Record at index " + index + " contains invalid CSV: \"\"\"\n" + textBlock + "\n\"\"\"");
processNullValues(csvRecord, this.nullValues);
Object[] arguments = new Object[csvRecord.length];
for (int i = 0; i < csvRecord.length; i++) {
String column = csvRecord[i];
if (useHeadersInDisplayName) {
arguments[i] = Named.of(headers.get(i) + " = " + column, column);
}
else {
arguments[i] = column;
}
}
argumentsList.add(Arguments.of(arguments));
argumentsList.add(convertToArguments(csvRecord, useHeadersInDisplayName, headers));
}

return argumentsList.stream();
}
catch (Throwable throwable) {
throw handleCsvException(throwable, this.annotation);
}

return argumentsList.stream();
}

private String[] parseLine(String line, int index) {
private Stream<Arguments> parseValueArray(String[] inputRecords, boolean useHeadersInDisplayName) {
List<Arguments> argumentsList = new ArrayList<>();

try {
String[] csvRecord = this.csvParser.parseLine(line + LINE_SEPARATOR);
Preconditions.notNull(csvRecord,
() -> "Line at index " + index + " contains invalid CSV: \"" + line + "\"");
processNullValues(csvRecord, this.nullValues);
return csvRecord;
String[] headers = null;
AtomicInteger index = new AtomicInteger(0);
for (String input : inputRecords) {
index.incrementAndGet();
String[] csvRecord = this.csvParser.parseLine(input + LINE_SEPARATOR);
// Lazily retrieve headers if necessary.
if (useHeadersInDisplayName && headers == null) {
headers = getHeaders(this.csvParser);
}
Preconditions.notNull(csvRecord,
() -> "Record at index " + index + " contains invalid CSV: \"" + input + "\"");
processNullValues(csvRecord, this.nullValues);
argumentsList.add(convertToArguments(csvRecord, useHeadersInDisplayName, headers));
}
}
catch (Throwable throwable) {
throw handleCsvException(throwable, this.annotation);
}

return argumentsList.stream();
}

// Cannot get parsed headers until after parsing has started.
static String[] getHeaders(CsvParser csvParser) {
return Arrays.stream(csvParser.getContext().parsedHeaders())//
.map(String::trim)//
.toArray(String[]::new);
}

static void processNullValues(String[] csvRecord, Set<String> nullValues) {
Expand All @@ -126,6 +128,19 @@ static void processNullValues(String[] csvRecord, Set<String> nullValues) {
}
}

static Arguments convertToArguments(Object[] csvRecord, boolean useHeadersInDisplayName, String[] headers) {
if (!useHeadersInDisplayName) {
return Arguments.of(csvRecord);
}

Object[] arguments = new Object[csvRecord.length];
for (int i = 0; i < csvRecord.length; i++) {
Object column = csvRecord[i];
arguments[i] = Named.of(headers[i] + " = " + column, column);
}
return Arguments.of(arguments);
}

/**
* @return this method always throws an exception and therefore never
* returns anything; the return type is merely present to allow this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
import static java.util.Spliterators.spliteratorUnknownSize;
import static java.util.stream.Collectors.toList;
import static java.util.stream.StreamSupport.stream;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import static org.junit.jupiter.params.provider.CsvArgumentsProvider.convertToArguments;
import static org.junit.jupiter.params.provider.CsvArgumentsProvider.getHeaders;
import static org.junit.jupiter.params.provider.CsvArgumentsProvider.handleCsvException;
import static org.junit.jupiter.params.provider.CsvArgumentsProvider.processNullValues;
import static org.junit.jupiter.params.provider.CsvParserFactory.createParserFor;
Expand Down Expand Up @@ -119,12 +120,15 @@ private static class CsvParserIterator implements Iterator<Arguments> {

private final CsvParser csvParser;
private final CsvFileSource annotation;
private final boolean useHeadersInDisplayName;
private final Set<String> nullValues;
private Object[] nextCsvRecord;
private String[] headers;

CsvParserIterator(CsvParser csvParser, CsvFileSource annotation) {
this.csvParser = csvParser;
this.annotation = annotation;
this.useHeadersInDisplayName = annotation.useHeadersInDisplayName();
this.nullValues = toSet(annotation.nullValues());
advance();
}
Expand All @@ -136,7 +140,7 @@ public boolean hasNext() {

@Override
public Arguments next() {
Arguments result = arguments(this.nextCsvRecord);
Arguments result = convertToArguments(this.nextCsvRecord, this.useHeadersInDisplayName, this.headers);
advance();
return result;
}
Expand All @@ -147,6 +151,9 @@ private void advance() {
csvRecord = this.csvParser.parseNext();
if (csvRecord != null) {
processNullValues(csvRecord, this.nullValues);
if (this.useHeadersInDisplayName && this.headers == null) {
this.headers = getHeaders(this.csvParser);
}
}
}
catch (Throwable throwable) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@
*/
String lineSeparator() default "\n";

/**
* Configures whether the first CSV record should be treated as header names
* for columns.
*
* <p>When set to {@code true}, the header names will be used in the
* generated display name for each {@code @ParameterizedTest} method
* invocation.
*
* <p>Defaults to {@code false}.
*
* @since 5.8.3
*/
@API(status = EXPERIMENTAL, since = "5.8.3")
boolean useHeadersInDisplayName() default false;

/**
* The column delimiter character to use when reading the CSV files.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,9 @@ static CsvParser createParserFor(CsvSource annotation) {

static CsvParser createParserFor(CsvFileSource annotation) {
String delimiter = selectDelimiter(annotation, annotation.delimiter(), annotation.delimiterString());
boolean headerExtractionEnabled = false;
return createParser(delimiter, annotation.lineSeparator(), DOUBLE_QUOTE, annotation.emptyValue(),
annotation.maxCharsPerColumn(), COMMENT_PROCESSING_FOR_CSV_FILE_SOURCE, headerExtractionEnabled,
annotation.ignoreLeadingAndTrailingWhitespace());
annotation.maxCharsPerColumn(), COMMENT_PROCESSING_FOR_CSV_FILE_SOURCE,
annotation.useHeadersInDisplayName(), annotation.ignoreLeadingAndTrailingWhitespace());
}

private static String selectDelimiter(Annotation annotation, char delimiter, String delimiterString) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,19 @@
@API(status = EXPERIMENTAL, since = "5.8.1")
String textBlock() default "";

/**
* Configures whether the first CSV record should be treated as header names
* for columns.
*
* <p>When set to {@code true}, the header names will be used in the
* generated display name for each {@code @ParameterizedTest} method
* invocation.
*
* <p>Defaults to {@code false}.
*
* @since 5.8.3
*/
@API(status = EXPERIMENTAL, since = "5.8.3")
boolean useHeadersInDisplayName() default false;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,46 +130,39 @@ void executesLinesFromTextBlock(String fruit, int rank) {

@ParameterizedTest(name = "[{index}] {arguments}")
@CsvSource(delimiter = '|', quoteCharacter = '"', useHeadersInDisplayName = true, textBlock = """
#-----------------------------
#---------------------------------------
FRUIT | RANK
#-----------------------------
#---------------------------------------
apple | 1
#-----------------------------
#---------------------------------------
banana | 2
#-----------------------------
cherry | 3.1415926535\
8979323846\
2643383279\
5028841971\
6939937510\
5820974944\
5923078164\
0628620899\
8628034825\
3421170679
#-----------------------------
"lemon lime" | 99
#-----------------------------
strawberry | 700_000
#-----------------------------
#---------------------------------------
cherry | 3.14159265358979323846
""")
void executesLinesFromTextBlockUsingPseudoTableFormat(String fruit, double rank, TestInfo testInfo) {
System.err.println(testInfo.getDisplayName());
void executesLinesFromTextBlockUsingTableFormatAndHeaders(String fruit, double rank, TestInfo testInfo) {
assertFruitTable(fruit, rank, testInfo);
}

@ParameterizedTest(name = "[{index}] {arguments}")
@CsvFileSource(resources = "two-column-with-headers.csv", delimiter = '|', useHeadersInDisplayName = true)
void executesLinesFromClasspathResourceUsingTableFormatAndHeaders(String fruit, double rank, TestInfo testInfo) {
assertFruitTable(fruit, rank, testInfo);
}

private void assertFruitTable(String fruit, double rank, TestInfo testInfo) {
String displayName = testInfo.getDisplayName();
switch (fruit) {
case "apple":
assertThat(rank).isEqualTo(1);
assertThat(displayName).isEqualTo("[1] FRUIT = apple, RANK = 1");
break;
case "banana":
assertThat(rank).isEqualTo(2);
assertThat(displayName).isEqualTo("[2] FRUIT = banana, RANK = 2");
break;
case "cherry":
assertThat(rank).isCloseTo(Math.PI, within(0.0));
break;
case "lemon lime":
assertThat(rank).isEqualTo(99);
break;
case "strawberry":
assertThat(rank).isEqualTo(700_000);
assertThat(rank).isCloseTo(Math.PI, within(0.00000000001));
assertThat(displayName).isEqualTo("[3] FRUIT = cherry, RANK = 3.14159265358979323846");
break;
default:
fail("Unexpected fruit : " + fruit);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ void throwsExceptionForInvalidCsv() {

assertThatExceptionOfType(JUnitException.class)//
.isThrownBy(() -> provideArguments(annotation).toArray())//
.withMessage("Line at index 3 contains invalid CSV: \"\"");
.withMessage("Record at index 3 contains invalid CSV: \"\"");
}

@Test
Expand Down Expand Up @@ -267,10 +267,8 @@ void convertsEmptyValuesToNullInLinesAfterFirstLine() {
void throwsExceptionIfSourceExceedsMaxCharsPerColumnConfig() {
var annotation = csvSource().lines("413").maxCharsPerColumn(2).build();

var arguments = provideArguments(annotation);

assertThatExceptionOfType(CsvParsingException.class)//
.isThrownBy(arguments::toArray)//
.isThrownBy(() -> provideArguments(annotation))//
.withMessageStartingWith("Failed to parse CSV input configured via Mock for CsvSource")//
.withRootCauseInstanceOf(ArrayIndexOutOfBoundsException.class);
}
Expand All @@ -288,10 +286,8 @@ void providesArgumentWithDefaultMaxCharsPerColumnConfig() {
void throwsExceptionWhenSourceExceedsDefaultMaxCharsPerColumnConfig() {
var annotation = csvSource().lines("0".repeat(4097)).delimiter(';').build();

var arguments = provideArguments(annotation);

assertThatExceptionOfType(CsvParsingException.class)//
.isThrownBy(arguments::toArray)//
.isThrownBy(() -> provideArguments(annotation))//
.withMessageStartingWith("Failed to parse CSV input configured via Mock for CsvSource")//
.withRootCauseInstanceOf(ArrayIndexOutOfBoundsException.class);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ CsvSource build() {
var annotation = mock(CsvSource.class);

// Common
when(annotation.useHeadersInDisplayName()).thenReturn(false);
when(annotation.delimiter()).thenReturn(super.delimiter);
when(annotation.delimiterString()).thenReturn(super.delimiterString);
when(annotation.emptyValue()).thenReturn(super.emptyValue);
Expand Down Expand Up @@ -171,6 +172,7 @@ CsvFileSource build() {
var annotation = mock(CsvFileSource.class);

// Common
when(annotation.useHeadersInDisplayName()).thenReturn(false);
when(annotation.delimiter()).thenReturn(super.delimiter);
when(annotation.delimiterString()).thenReturn(super.delimiterString);
when(annotation.emptyValue()).thenReturn(super.emptyValue);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#---------------------------------
FRUIT | RANK
#---------------------------------
apple | 1
#---------------------------------
banana | 2
#---------------------------------
cherry | 3.14159265358979323846
#---------------------------------

0 comments on commit 857d841

Please sign in to comment.