Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support CSV headers in display names in parameterized tests #2767

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import java.io.StringReader;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
Expand All @@ -23,6 +24,7 @@

import com.univocity.parsers.csv.CsvParser;

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

if (textBlockDeclared) {
return parseTextBlock(this.annotation.textBlock()).stream().map(Arguments::of);
}

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 textBlockDeclared ? parseTextBlock() : parseValueArray();
}

private List<String[]> parseTextBlock(String textBlock) {
private Stream<Arguments> parseTextBlock() {
String textBlock = this.annotation.textBlock();
boolean useHeadersInDisplayName = this.annotation.useHeadersInDisplayName();
List<Arguments> argumentsList = new ArrayList<>();

try {
AtomicInteger index = new AtomicInteger(0);
List<String[]> csvRecords = this.csvParser.parseAll(new StringReader(textBlock));
String[] headers = useHeadersInDisplayName ? getHeaders(this.csvParser) : null;

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\"\"\"");
processNullValues(csvRecord, this.nullValues);
() -> "Record at index " + index + " contains invalid CSV: \"\"\"\n" + textBlock + "\n\"\"\"");
argumentsList.add(processCsvRecord(csvRecord, this.nullValues, useHeadersInDisplayName, headers));
}
return csvRecords;
}
catch (Throwable throwable) {
throw handleCsvException(throwable, this.annotation);
}

return argumentsList.stream();
}

private String[] parseLine(String line, int index) {
private Stream<Arguments> parseValueArray() {
boolean useHeadersInDisplayName = this.annotation.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 : this.annotation.value()) {
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 + "\"");
argumentsList.add(processCsvRecord(csvRecord, this.nullValues, useHeadersInDisplayName, headers));
}
}
catch (Throwable throwable) {
throw handleCsvException(throwable, this.annotation);
}

return argumentsList.stream();
}

static void processNullValues(String[] csvRecord, Set<String> nullValues) {
if (!nullValues.isEmpty()) {
for (int i = 0; i < csvRecord.length; i++) {
if (nullValues.contains(csvRecord[i])) {
csvRecord[i] = null;
}
// 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);
}

/**
* Processes custom null values, supports wrapping of column values in
* {@link Named} if necessary (for CSV header support), and returns the
* CSV record wrapped in an {@link Arguments} instance.
*/
static Arguments processCsvRecord(Object[] csvRecord, Set<String> nullValues, boolean useHeadersInDisplayName,
String[] headers) {

Object[] arguments = new Object[csvRecord.length];

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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
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.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.CsvArgumentsProvider.processCsvRecord;
import static org.junit.jupiter.params.provider.CsvParserFactory.createParserFor;
import static org.junit.platform.commons.util.CollectionUtils.toSet;

Expand Down Expand Up @@ -119,41 +119,49 @@ 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 Arguments nextArguments;
private String[] headers;

CsvParserIterator(CsvParser csvParser, CsvFileSource annotation) {
this.csvParser = csvParser;
this.annotation = annotation;
this.useHeadersInDisplayName = annotation.useHeadersInDisplayName();
this.nullValues = toSet(annotation.nullValues());
advance();
}

@Override
public boolean hasNext() {
return this.nextCsvRecord != null;
return this.nextArguments != null;
}

@Override
public Arguments next() {
Arguments result = arguments(this.nextCsvRecord);
Arguments result = this.nextArguments;
advance();
return result;
}

private void advance() {
String[] csvRecord = null;
try {
csvRecord = this.csvParser.parseNext();
String[] csvRecord = this.csvParser.parseNext();
if (csvRecord != null) {
processNullValues(csvRecord, this.nullValues);
// Lazily retrieve headers if necessary.
if (this.useHeadersInDisplayName && this.headers == null) {
this.headers = getHeaders(this.csvParser);
}
this.nextArguments = processCsvRecord(csvRecord, this.nullValues, this.useHeadersInDisplayName,
this.headers);
}
else {
this.nextArguments = null;
}
}
catch (Throwable throwable) {
handleCsvException(throwable, this.annotation);
}

this.nextCsvRecord = csvRecord;
}

}
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 @@ -32,14 +32,15 @@ static CsvParser createParserFor(CsvSource annotation) {
String delimiter = selectDelimiter(annotation, annotation.delimiter(), annotation.delimiterString());
boolean commentProcessingEnabled = !annotation.textBlock().isEmpty();
return createParser(delimiter, LINE_SEPARATOR, annotation.quoteCharacter(), annotation.emptyValue(),
annotation.maxCharsPerColumn(), commentProcessingEnabled, annotation.ignoreLeadingAndTrailingWhitespace());
annotation.maxCharsPerColumn(), commentProcessingEnabled, annotation.useHeadersInDisplayName(),
annotation.ignoreLeadingAndTrailingWhitespace());
}

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

private static String selectDelimiter(Annotation annotation, char delimiter, String delimiterString) {
Expand All @@ -56,16 +57,18 @@ private static String selectDelimiter(Annotation annotation, char delimiter, Str
}

private static CsvParser createParser(String delimiter, String lineSeparator, char quote, String emptyValue,
int maxCharsPerColumn, boolean commentProcessingEnabled, boolean ignoreLeadingAndTrailingWhitespace) {
int maxCharsPerColumn, boolean commentProcessingEnabled, boolean headerExtractionEnabled,
boolean ignoreLeadingAndTrailingWhitespace) {
return new CsvParser(createParserSettings(delimiter, lineSeparator, quote, emptyValue, maxCharsPerColumn,
commentProcessingEnabled, ignoreLeadingAndTrailingWhitespace));
commentProcessingEnabled, headerExtractionEnabled, ignoreLeadingAndTrailingWhitespace));
}

private static CsvParserSettings createParserSettings(String delimiter, String lineSeparator, char quote,
String emptyValue, int maxCharsPerColumn, boolean commentProcessingEnabled,
String emptyValue, int maxCharsPerColumn, boolean commentProcessingEnabled, boolean headerExtractionEnabled,
boolean ignoreLeadingAndTrailingWhitespace) {

CsvParserSettings settings = new CsvParserSettings();
settings.setHeaderExtractionEnabled(headerExtractionEnabled);
settings.getFormat().setDelimiter(delimiter);
settings.getFormat().setLineSeparator(lineSeparator);
settings.getFormat().setQuote(quote);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@
@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;

/**
* The quote character to use for <em>quoted strings</em>.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,38 +119,56 @@ void executesLinesFromTextBlock(String fruit, int rank) {
}
}

@ParameterizedTest
@CsvSource(delimiter = '|', quoteCharacter = '"', 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
#-----------------------------
@ParameterizedTest(name = "[{index}] {arguments}")
@CsvSource(delimiter = '|', useHeadersInDisplayName = true, nullValues = "NIL", textBlock = """
#---------------------------------
FRUIT | RANK
#---------------------------------
apple | 1
#---------------------------------
banana | 2
#---------------------------------
cherry | 3.14159265358979323846
#---------------------------------
| 0
#---------------------------------
NIL | 0
#---------------------------------
""")
void executesLinesFromTextBlockUsingPseudoTableFormat(String fruit, double rank) {
void executesLinesFromTextBlockUsingTableFormatAndHeadersAndNullValues(String fruit, double rank,
TestInfo testInfo) {
assertFruitTable(fruit, rank, testInfo);
}

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

private void assertFruitTable(String fruit, double rank, TestInfo testInfo) {
String displayName = testInfo.getDisplayName();

if (fruit == null) {
assertThat(rank).isEqualTo(0);
assertThat(displayName).matches("\\[(4|5)\\] FRUIT = null, RANK = 0");
return;
}

switch (fruit) {
case "apple" -> assertThat(rank).isEqualTo(1);
case "banana" -> assertThat(rank).isEqualTo(2);
case "cherry" -> assertThat(rank).isCloseTo(Math.PI, within(0.0));
case "lemon lime" -> assertThat(rank).isEqualTo(99);
case "strawberry" -> assertThat(rank).isEqualTo(700_000);
case "apple" -> {
assertThat(rank).isEqualTo(1);
assertThat(displayName).isEqualTo("[1] FRUIT = apple, RANK = 1");
}
case "banana" -> {
assertThat(rank).isEqualTo(2);
assertThat(displayName).isEqualTo("[2] FRUIT = banana, RANK = 2");
}
case "cherry" -> {
assertThat(rank).isCloseTo(Math.PI, within(0.0));
assertThat(displayName).isEqualTo("[3] FRUIT = cherry, RANK = 3.14159265358979323846");
}
default -> fail("Unexpected fruit : " + fruit);
}
}
Expand Down
Loading