diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.8.2.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.8.2.adoc index 6e97eae8b16..f8e4f7d4b92 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.8.2.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.8.2.adoc @@ -6,6 +6,7 @@ *Scope:* * Text blocks in `@CsvSource` are treated like CSV files +* CSV headers in display names for `@CsvSource` and `@CsvFileSource` * Custom quote character support in `@CsvSource` and `@CsvFileSource` For a complete list of all _closed_ issues and pull requests for this release, consult the @@ -29,8 +30,13 @@ No changes. quoted strings. See the <<../user-guide/index.adoc#writing-tests-parameterized-tests-sources-CsvSource, User Guide>> for details and examples. +* CSV headers can now be used in display names in parameterized tests. See + <<../user-guide/index.adoc#writing-tests-parameterized-tests-sources-CsvSource, + `@CsvSource`>> and + <<../user-guide/index.adoc#writing-tests-parameterized-tests-sources-CsvFileSource, + `@CsvFileSource`>> in the User Guide for details and examples. * The quote character for _quoted strings_ in `@CsvSource` and `@CsvFileSource` is now - configurable via new `quoteCharacter` attributes in each annotation. + configurable via a new `quoteCharacter` attribute in each annotation. [[release-notes-5.8.2-junit-vintage]] diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index efa460277ba..5699ce07b09 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -1332,7 +1332,9 @@ include::{testDir}/example/ExternalMethodSourceDemo.java[tags=external_MethodSou `@CsvSource` allows you to express argument lists as comma-separated values (i.e., CSV `String` literals). Each string provided via the `value` attribute in `@CsvSource` -represents a CSV record and results in one invocation of the parameterized test. +represents a CSV record and results in one invocation of the parameterized test. The first +record may optionally be used to supply CSV headers (see the Javadoc for the +`useHeadersInDisplayName` attribute for details and an example). [source,java,indent=0] ---- @@ -1375,12 +1377,16 @@ by default. This behavior can be changed by setting the If the programming language you are using supports _text blocks_ -- for example, Java SE 15 or higher -- you can alternatively use the `textBlock` attribute of `@CsvSource`. Each record within a text block represents a CSV record and results in one invocation of the -parameterized test. Using a text block, the previous example can be implemented as follows. +parameterized test. The first record may optionally be used to supply CSV headers by +setting the `useHeadersInDisplayName` attribute to `true` as in the example below. + +Using a text block, the previous example can be implemented as follows. [source,java,indent=0] ---- -@ParameterizedTest -@CsvSource(textBlock = """ +@ParameterizedTest(name = "[{index}] {arguments}") +@CsvSource(useHeadersInDisplayName = true, textBlock = """ + FRUIT, RANK apple, 1 banana, 2 'lemon, lime', 0xF1 @@ -1391,6 +1397,15 @@ void testWithCsvSource(String fruit, int rank) { } ---- +The generated display names for the previous example include the CSV header names. + +---- +[1] FRUIT = apple, RANK = 1 +[2] FRUIT = banana, RANK = 2 +[3] FRUIT = lemon, lime, RANK = 0xF1 +[4] FRUIT = strawberry, RANK = 700_000 +---- + In contrast to CSV records supplied via the `value` attribute, a text block can contain comments. Any line beginning with a `+++#+++` symbol will be treated as a comment and ignored. Note, however, that the `+++#+++` symbol must be the first character on the line @@ -1435,7 +1450,11 @@ your text block. `@CsvFileSource` lets you use comma-separated value (CSV) files from the classpath or the local file system. Each record from a CSV file results in one invocation of the -parameterized test. +parameterized test. The first record may optionally be used to supply CSV headers. You can +instruct JUnit to ignore the headers via the `numLinesToSkip` attribute. If you would like +for the headers to be used in the display names, you can set the `useHeadersInDisplayName` +attribute to `true`. The examples below demonstrate the use of `numLinesToSkip` and +`useHeadersInDisplayName`. The default delimiter is a comma (`,`), but you can use another character by setting the `delimiter` attribute. Alternatively, the `delimiterString` attribute allows you to use a @@ -1457,6 +1476,26 @@ include::{testDir}/example/ParameterizedTestDemo.java[tags=CsvFileSource_example include::{testResourcesDir}/two-column.csv[] ---- +The following listing shows the generated display names for the first two parameterized +test methods above. + +---- +[1] country=Sweden, reference=1 +[2] country=Poland, reference=2 +[3] country=United States of America, reference=3 +[4] country=France, reference=700_000 +---- + +The following listing shows the generated display names for the last parameterized test +method above that uses CSV header names. + +---- +[1] COUNTRY = Sweden, REFERENCE = 1 +[2] COUNTRY = Poland, REFERENCE = 2 +[3] COUNTRY = United States of America, REFERENCE = 3 +[4] COUNTRY = France, REFERENCE = 700_000 +---- + In contrast to the default syntax used in `@CsvSource`, `@CsvFileSource` uses a double quote (`+++"+++`) as the quote character by default, but this can be changed via the `quoteCharacter` attribute. See the `"United States of America"` value in the example diff --git a/documentation/src/test/java/example/ParameterizedTestDemo.java b/documentation/src/test/java/example/ParameterizedTestDemo.java index bcd0f0a1266..4717f6133b1 100644 --- a/documentation/src/test/java/example/ParameterizedTestDemo.java +++ b/documentation/src/test/java/example/ParameterizedTestDemo.java @@ -236,6 +236,13 @@ void testWithCsvFileSourceFromFile(String country, int reference) { assertNotNull(country); assertNotEquals(0, reference); } + + @ParameterizedTest(name = "[{index}] {arguments}") + @CsvFileSource(resources = "/two-column.csv", useHeadersInDisplayName = true) + void testWithCsvFileSourceAndHeaders(String country, int reference) { + assertNotNull(country); + assertNotEquals(0, reference); + } // end::CsvFileSource_example[] // tag::ArgumentsSource_example[] diff --git a/documentation/src/test/resources/two-column.csv b/documentation/src/test/resources/two-column.csv index 011550be370..7ebb4c545f1 100644 --- a/documentation/src/test/resources/two-column.csv +++ b/documentation/src/test/resources/two-column.csv @@ -1,4 +1,4 @@ -Country, Reference +COUNTRY, REFERENCE Sweden, 1 Poland, 2 "United States of America", 3 diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java index a218d1f02b1..fda9ed4002d 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java @@ -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; @@ -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; @@ -53,56 +55,96 @@ public Stream 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 parseTextBlock(String textBlock) { + private Stream parseTextBlock() { + String textBlock = this.annotation.textBlock(); + boolean useHeadersInDisplayName = this.annotation.useHeadersInDisplayName(); + List argumentsList = new ArrayList<>(); + try { - AtomicInteger index = new AtomicInteger(0); List 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 parseValueArray() { + boolean useHeadersInDisplayName = this.annotation.useHeadersInDisplayName(); + List 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 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 nullValues, boolean useHeadersInDisplayName, + String[] headers) { + + // Nothing to process? + if (nullValues.isEmpty() && !useHeadersInDisplayName) { + return Arguments.of(csvRecord); + } + + Preconditions.condition(!useHeadersInDisplayName || (csvRecord.length <= headers.length), + () -> String.format( + "The number of columns (%d) exceeds the number of supplied headers (%d) in CSV record: %s", + csvRecord.length, headers.length, Arrays.toString(csvRecord))); + + 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); } /** diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java index 66b8f237907..6e7c8d931fa 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java @@ -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; @@ -119,41 +119,49 @@ private static class CsvParserIterator implements Iterator { private final CsvParser csvParser; private final CsvFileSource annotation; + private final boolean useHeadersInDisplayName; private final Set 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; } } diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java index a2c9f400b28..e639612cf7d 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java @@ -27,7 +27,9 @@ * or {@link #files}. * *

The CSV records parsed from these resources and files will be provided as - * arguments to the annotated {@code @ParameterizedTest} method. + * arguments to the annotated {@code @ParameterizedTest} method. Note that the + * first record may optionally be used to supply CSV headers (see + * {@link #useHeadersInDisplayName}). * *

Any line beginning with a {@code #} symbol will be interpreted as a comment * and will be ignored. @@ -95,6 +97,34 @@ */ String lineSeparator() default "\n"; + /** + * Configures whether the first CSV record should be treated as header names + * for columns. + * + *

When set to {@code true}, the header names will be used in the + * generated display name for each {@code @ParameterizedTest} method + * invocation. When using this feature, you must ensure that the display name + * pattern for {@code @ParameterizedTest} includes + * {@value org.junit.jupiter.params.ParameterizedTest#ARGUMENTS_PLACEHOLDER} instead of + * {@value org.junit.jupiter.params.ParameterizedTest#ARGUMENTS_WITH_NAMES_PLACEHOLDER} + * as demonstrated in the example below. + * + *

Defaults to {@code false}. + * + * + *

Example

+ *
+	 * {@literal @}ParameterizedTest(name = "[{index}] {arguments}")
+	 * {@literal @}CsvFileSource(resources = "fruits.csv", useHeadersInDisplayName = true)
+	 * void test(String fruit, int rank) {
+	 *     // ...
+	 * }
+ * + * @since 5.8.2 + */ + @API(status = EXPERIMENTAL, since = "5.8.2") + boolean useHeadersInDisplayName() default false; + /** * The quote character to use for quoted strings. * diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvParserFactory.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvParserFactory.java index 7c7a4fd2f9a..7e765277644 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvParserFactory.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvParserFactory.java @@ -31,14 +31,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(), annotation.quoteCharacter(), 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) { @@ -55,16 +56,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); diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvSource.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvSource.java index 40c3ec3d09b..d3a244cac61 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvSource.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvSource.java @@ -77,7 +77,9 @@ * via this attribute or the {@link #textBlock} attribute. * *

Each value corresponds to a record in a CSV file and will be split using - * the specified {@link #delimiter} or {@link #delimiterString}. + * the specified {@link #delimiter} or {@link #delimiterString}. Note that + * the first value may optionally be used to supply CSV headers (see + * {@link #useHeadersInDisplayName}). * *

If text block syntax is supported by your programming language, * you may find it more convenient to declare your CSV content via the @@ -113,6 +115,8 @@ * *

Each record in the text block corresponds to a record in a CSV file and will * be split using the specified {@link #delimiter} or {@link #delimiterString}. + * Note that the first record may optionally be used to supply CSV headers (see + * {@link #useHeadersInDisplayName}). * *

In contrast to CSV records supplied via {@link #value}, a text block * can contain comments. Any line beginning with a hash tag ({@code #}) will @@ -151,6 +155,39 @@ @API(status = EXPERIMENTAL, since = "5.8.1") String textBlock() default ""; + /** + * Configures whether the first CSV record should be treated as header names + * for columns. + * + *

When set to {@code true}, the header names will be used in the + * generated display name for each {@code @ParameterizedTest} method + * invocation. When using this feature, you must ensure that the display name + * pattern for {@code @ParameterizedTest} includes + * {@value org.junit.jupiter.params.ParameterizedTest#ARGUMENTS_PLACEHOLDER} instead of + * {@value org.junit.jupiter.params.ParameterizedTest#ARGUMENTS_WITH_NAMES_PLACEHOLDER} + * as demonstrated in the example below. + * + *

Defaults to {@code false}. + * + *

Example

+ *
+	 * {@literal @}ParameterizedTest(name = "[{index}] {arguments}")
+	 * {@literal @}CsvSource(useHeadersInDisplayName = true, textBlock = """
+	 *     FRUIT,         RANK
+	 *     apple,         1
+	 *     banana,        2
+	 *     'lemon, lime', 0xF1
+	 *     strawberry,    700_000
+	 *     """)
+	 * void test(String fruit, int rank) {
+	 *     // ...
+	 * }
+ * + * @since 5.8.2 + */ + @API(status = EXPERIMENTAL, since = "5.8.2") + boolean useHeadersInDisplayName() default false; + /** * The quote character to use for quoted strings. * diff --git a/junit-jupiter-params/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java b/junit-jupiter-params/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java index 9bd67544c79..adf7b218fb3 100644 --- a/junit-jupiter-params/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java +++ b/junit-jupiter-params/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java @@ -11,6 +11,7 @@ package org.junit.jupiter.params; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -127,36 +128,57 @@ void executesLinesFromTextBlock(String fruit, int rank) { } } - @ParameterizedTest - @CsvSource(delimiter = '|', quoteCharacter = '"', textBlock = """ - #----------------------------- - # FRUIT | RANK - #----------------------------- - apple | 1 - #----------------------------- - banana | 2 - #----------------------------- - "lemon lime" | 0xF1 - #----------------------------- - 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, int 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": + case "apple" -> { assertThat(rank).isEqualTo(1); - break; - case "banana": + assertThat(displayName).isEqualTo("[1] FRUIT = apple, RANK = 1"); + } + case "banana" -> { assertThat(rank).isEqualTo(2); - break; - case "lemon lime": - assertThat(rank).isEqualTo(241); - break; - case "strawberry": - assertThat(rank).isEqualTo(700_000); - break; - default: - fail("Unexpected fruit : " + fruit); + 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); } } diff --git a/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/CsvArgumentsProviderTests.java b/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/CsvArgumentsProviderTests.java index 32fd519baac..1bd44c4a4ae 100644 --- a/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/CsvArgumentsProviderTests.java +++ b/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/CsvArgumentsProviderTests.java @@ -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 @@ -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); } @@ -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); } @@ -336,6 +332,41 @@ void honorsCommentCharacterWhenUsingTextBlockAttribute() { assertThat(arguments).containsExactly(array("bar", "#baz"), array("#bar", "baz")); } + @Test + void supportsCsvHeadersWhenUsingTextBlockAttribute() { + var annotation = csvSource().useHeadersInDisplayName(true).textBlock(""" + FRUIT, RANK + apple, 1 + banana, 2 + """).build(); + + var arguments = provideArguments(annotation); + Stream argumentsAsStrings = arguments.map(array -> { + String[] strings = new String[array.length]; + for (int i = 0; i < array.length; i++) { + strings[i] = String.valueOf(array[i]); + } + return strings; + }); + + assertThat(argumentsAsStrings).containsExactly(array("FRUIT = apple", "RANK = 1"), + array("FRUIT = banana", "RANK = 2")); + } + + @Test + void throwsExceptionIfColumnCountExceedsHeaderCount() { + var annotation = csvSource().useHeadersInDisplayName(true).textBlock(""" + FRUIT, RANK + apple, 1 + banana, 2, BOOM! + """).build(); + + assertThatExceptionOfType(PreconditionViolationException.class)// + .isThrownBy(() -> provideArguments(annotation))// + .withMessage( + "The number of columns (3) exceeds the number of supplied headers (2) in CSV record: [banana, 2, BOOM!]"); + } + private Stream provideArguments(CsvSource annotation) { var provider = new CsvArgumentsProvider(); provider.accept(annotation); diff --git a/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/CsvFileArgumentsProviderTests.java b/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/CsvFileArgumentsProviderTests.java index a820ab5a7ca..0176c4517ee 100644 --- a/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/CsvFileArgumentsProviderTests.java +++ b/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/CsvFileArgumentsProviderTests.java @@ -275,6 +275,21 @@ void readsFromMultipleClasspathResourcesWithHeaders() { array("baz"), array("qux"), array("")); } + @Test + void supportsCsvHeadersInDisplayNames() { + var annotation = csvFileSource()// + .encoding("ISO-8859-1")// + .resources("/single-column.csv")// + .useHeadersInDisplayName(true)// + .build(); + + var arguments = provideArguments(new CsvFileArgumentsProvider(), annotation); + Stream argumentsAsStrings = arguments.map(array -> new String[] { String.valueOf(array[0]) }); + + assertThat(argumentsAsStrings).containsExactly(array("foo = bar"), array("foo = baz"), array("foo = qux"), + array("foo = ")); + } + @Test void throwsExceptionForMissingClasspathResource() { var annotation = csvFileSource()// diff --git a/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/MockCsvAnnotationBuilder.java b/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/MockCsvAnnotationBuilder.java index aa8a1d1e8fc..b19f66475a6 100644 --- a/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/MockCsvAnnotationBuilder.java +++ b/junit-jupiter-params/src/test/java/org/junit/jupiter/params/provider/MockCsvAnnotationBuilder.java @@ -34,6 +34,7 @@ static MockCsvFileSourceBuilder csvFileSource() { // ------------------------------------------------------------------------- + private boolean useHeadersInDisplayName = false; private char quoteCharacter = '\0'; protected char delimiter = '\0'; protected String delimiterString = ""; @@ -47,6 +48,11 @@ private MockCsvAnnotationBuilder() { protected abstract B getSelf(); + B useHeadersInDisplayName(boolean useHeadersInDisplayName) { + this.useHeadersInDisplayName = useHeadersInDisplayName; + return getSelf(); + } + B quoteCharacter(char quoteCharacter) { this.quoteCharacter = quoteCharacter; return getSelf(); @@ -115,6 +121,7 @@ CsvSource build() { var annotation = mock(CsvSource.class); // Common + when(annotation.useHeadersInDisplayName()).thenReturn(super.useHeadersInDisplayName); when(annotation.quoteCharacter()).thenReturn(super.quoteCharacter); when(annotation.delimiter()).thenReturn(super.delimiter); when(annotation.delimiterString()).thenReturn(super.delimiterString); @@ -179,6 +186,7 @@ CsvFileSource build() { var annotation = mock(CsvFileSource.class); // Common + when(annotation.useHeadersInDisplayName()).thenReturn(super.useHeadersInDisplayName); when(annotation.quoteCharacter()).thenReturn(super.quoteCharacter); when(annotation.delimiter()).thenReturn(super.delimiter); when(annotation.delimiterString()).thenReturn(super.delimiterString); diff --git a/junit-jupiter-params/src/test/resources/org/junit/jupiter/params/two-column-with-headers.csv b/junit-jupiter-params/src/test/resources/org/junit/jupiter/params/two-column-with-headers.csv new file mode 100644 index 00000000000..9a9fd17d14e --- /dev/null +++ b/junit-jupiter-params/src/test/resources/org/junit/jupiter/params/two-column-with-headers.csv @@ -0,0 +1,13 @@ +#--------------------------------- + FRUIT | RANK +#--------------------------------- + apple | 1 +#--------------------------------- + banana | 2 +#--------------------------------- + cherry | 3.14159265358979323846 +#--------------------------------- + | 0 +#--------------------------------- + NIL | 0 +#---------------------------------