From 34217e4964dac3c4eddd406d7b40aa63dce6bef5 Mon Sep 17 00:00:00 2001 From: Sam Brannen Date: Sun, 19 Sep 2021 19:29:32 +0300 Subject: [PATCH] Introduce support for text blocks in @CsvSource @CsvSource allows users to provide CSV content as an array of strings, where each string represents a line in a CSV file. With the introduction of support for text blocks as a first-class language feature in recent JDKs (preview feature in Java SE 15), we can improve the user experience with @CsvSource by allowing the user to provide a text block instead of an array of strings. This commit introduces a new textBlock attribute in @CsvSource that allows users to take advantage of the text block support in their programming language. Given the following parameterized test using a text block... @ParameterizedTest @CsvSource(textBlock = """ apple, 1 banana, 2 'lemon, lime', 0xF1 strawberry, 700_000 """) void csvSourceWithTextBlock(String fruit, int rank) { System.out.println(fruit + " : " + rank); } ... the output is: apple : 1 banana : 2 lemon, lime : 241 strawberry : 700000 Closes #2721 --- .../release-notes/release-notes-5.8.1.adoc | 4 ++ .../asciidoc/user-guide/writing-tests.adoc | 37 ++++++++--- .../params/provider/CsvArgumentsProvider.java | 16 ++++- .../jupiter/params/provider/CsvSource.java | 66 +++++++++++++++++-- .../ParameterizedTestIntegrationTests.java | 26 ++++++++ .../provider/CsvArgumentsProviderTests.java | 44 ++++++++++++- .../provider/MockCsvAnnotationBuilder.java | 7 ++ 7 files changed, 186 insertions(+), 14 deletions(-) diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.8.1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.8.1.adoc index 5dac02309d22..b19929b5ebb2 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.8.1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.8.1.adoc @@ -44,6 +44,10 @@ GitHub. ==== New Features and Improvements * `JAVA_18` has been added to the `JRE` enum for use with JRE-based execution conditions. +* CSV content in `@CsvSource` can now be supplied as a _text block_ instead of an array of + strings. See the + <<../user-guide/index.adoc#writing-tests-parameterized-tests-sources-CsvSource, User + Guide>> for details and an example. * The `ExecutionMode` for the current test or container is now accessible via the `ExtensionContext`. diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 5f7d1472c356..d5a54c0e3178 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -1330,14 +1330,34 @@ include::{testDir}/example/ExternalMethodSourceDemo.java[tags=external_MethodSou [[writing-tests-parameterized-tests-sources-CsvSource]] ===== @CsvSource -`@CsvSource` allows you to express argument lists as comma-separated values (i.e., -`String` literals). +`@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 line and results in one invocation of the parameterized test. [source,java,indent=0] ---- include::{testDir}/example/ParameterizedTestDemo.java[tags=CsvSource_example] ---- +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 +line within a text block represents a CSV line and results in one invocation of the +parameterized test. Using a text block, the previous example can be implemented as follows. + +[source,java,indent=0] +---- +@ParameterizedTest +@CsvSource(textBlock = """ + apple, 1 + banana, 2 + 'lemon, lime', 0xF1 + strawberry, 700_000 +""") +void testWithCsvSource(String fruit, int rank) { + // ... +} +---- + 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 `String` delimiter instead of a single character. However, both delimiter attributes @@ -1354,8 +1374,8 @@ reference is a primitive type. NOTE: An _unquoted_ empty value will always be converted to a `null` reference regardless of any custom values configured via the `nullValues` attribute. -Unless it starts with a quote character, leading and trailing whitespaces of a -CSV column are trimmed by default. This behavior can be changed by setting the +Unless it starts with a quote character, leading and trailing whitespace in a CSV column +is trimmed by default. This behavior can be changed by setting the `ignoreLeadingAndTrailingWhitespace` attribute to `true`. [cols="50,50"] @@ -1373,8 +1393,9 @@ CSV column are trimmed by default. This behavior can be changed by setting the [[writing-tests-parameterized-tests-sources-CsvFileSource]] ===== @CsvFileSource -`@CsvFileSource` lets you use CSV files from the classpath or the local file system. Each -line from a CSV file results in one invocation of the parameterized test. +`@CsvFileSource` lets you use comma-separated value (CSV) files from the classpath or the +local file system. Each line from a CSV file results in one invocation of the +parameterized test. 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 @@ -1407,8 +1428,8 @@ reference is a primitive type. NOTE: An _unquoted_ empty value will always be converted to a `null` reference regardless of any custom values configured via the `nullValues` attribute. -Unless it starts with a quote character, leading and trailing whitespaces of a -CSV column are trimmed by default. This behavior can be changed by setting the +Unless it starts with a quote character, leading and trailing whitespace in a CSV column +is trimmed by default. This behavior can be changed by setting the `ignoreLeadingAndTrailingWhitespace` attribute to `true`. [[writing-tests-parameterized-tests-sources-ArgumentsSource]] 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 863a68eace1f..16b5d4206bcd 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 @@ -17,6 +17,7 @@ import java.util.Arrays; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; import java.util.stream.Stream; import com.univocity.parsers.csv.CsvParser; @@ -32,6 +33,8 @@ */ class CsvArgumentsProvider implements ArgumentsProvider, AnnotationConsumer { + private static final Pattern NEW_LINE_REGEX = Pattern.compile("\\n"); + private static final String LINE_SEPARATOR = "\n"; private CsvSource annotation; @@ -47,9 +50,20 @@ public void accept(CsvSource annotation) { @Override public Stream provideArguments(ExtensionContext context) { + Preconditions.condition(this.annotation.value().length > 0 ^ !this.annotation.textBlock().isEmpty(), + () -> "@CsvSource must be declared with either `value` or `textBlock` but not both"); + + String[] lines; + if (!this.annotation.textBlock().isEmpty()) { + lines = NEW_LINE_REGEX.split(this.annotation.textBlock(), 0); + } + else { + lines = this.annotation.value(); + } + AtomicLong index = new AtomicLong(0); // @formatter:off - return Arrays.stream(this.annotation.value()) + return Arrays.stream(lines) .map(line -> parseLine(index.getAndIncrement(), line)) .map(Arguments::of); // @formatter:on 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 eb7c6f18be66..1cfe4cdda1c8 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 @@ -22,9 +22,9 @@ import org.apiguardian.api.API; /** - * {@code @CsvSource} is an {@link ArgumentsSource} which reads - * comma-separated values (CSV) from one or more supplied - * {@linkplain #value CSV lines}. + * {@code @CsvSource} is an {@link ArgumentsSource} which reads comma-separated + * values (CSV) from one or more CSV lines supplied via the {@link #value} + * attribute or {@link #textBlock} attribute. * *

The column delimiter (defaults to comma) can be customized with either * {@link #delimiter()} or {@link #delimiterString()}. @@ -51,8 +51,65 @@ * the specified {@link #delimiter} or {@link #delimiterString}. Any line * beginning with a {@code #} symbol will be interpreted as a comment and will * be ignored. + * + *

Defaults to an empty array. You therefore must supply CSV content + * via this attribute or the {@link #textBlock} attribute. + * + *

If text block syntax is supported by your programming language, + * you may find it more convenient to declare your CSV content via the + * {@link #textBlock} attribute. + * + *

Example

+ *
+	 * {@literal @}ParameterizedTest
+	 * {@literal @}CsvSource({
+	 *     "apple,         1",
+	 *     "banana,        2",
+	 *     "'lemon, lime', 0xF1",
+	 *     "strawberry,    700_000",
+	 * })
+	 * void test(String fruit, int rank) {
+	 *     // ...
+	 * }
+ * + * @see #textBlock */ - String[] value(); + String[] value() default {}; + + /** + * The CSV lines to use as the source of arguments, supplied as a single + * text block; must not be empty. + * + *

Each line in the text block corresponds to a line in a CSV file and will + * be split using the specified {@link #delimiter} or {@link #delimiterString}. + * Any line beginning with a {@code #} symbol will be interpreted as a comment + * and will be ignored. + * + *

Defaults to an empty string. You therefore must supply CSV content + * via this attribute or the {@link #value} attribute. + * + *

Text block syntax is supported by various languages on the JVM + * including Java SE 15 or higher. If text blocks are not supported, you + * should declare your CSV content via the {@link #value} attribute. + * + *

Example

+ *
+	 * {@literal @}ParameterizedTest
+	 * {@literal @}CsvSource(textBlock = """
+	 *     apple,         1
+	 *     banana,        2
+	 *     'lemon, lime', 0xF1
+	 *     strawberry,    700_000
+	 * """)
+	 * void test(String fruit, int rank) {
+	 *     // ...
+	 * }
+ * + * @since 5.8.1 + * @see #value + */ + @API(status = EXPERIMENTAL, since = "5.8.1") + String textBlock() default ""; /** * The column delimiter character to use when reading the {@linkplain #value lines}. @@ -128,4 +185,5 @@ */ @API(status = EXPERIMENTAL, since = "5.8") boolean ignoreLeadingAndTrailingWhitespace() default true; + } 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 737877422c46..364f0992b1f1 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 @@ -90,6 +90,32 @@ */ class ParameterizedTestIntegrationTests { + @ParameterizedTest + @CsvSource(textBlock = """ + apple, 1 + banana, 2 + 'lemon, lime', 0xF1 + strawberry, 700_000 + """) + void executesLinesFromTextBlock(String fruit, int rank) { + switch (fruit) { + case "apple": + assertThat(rank).isEqualTo(1); + break; + 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); + } + } + @Test void executesWithSingleArgumentsProviderWithMultipleInvocations() { var results = execute("testWithTwoSingleStringArgumentsProvider", String.class); 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 d0a876a17cb9..4ba60b8ffb28 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 @@ -26,7 +26,7 @@ class CsvArgumentsProviderTests { @Test - void throwsExceptionOnInvalidCsv() { + void throwsExceptionForInvalidCsv() { var annotation = csvSource("foo", "bar", ""); assertThatExceptionOfType(JUnitException.class)// @@ -34,6 +34,27 @@ void throwsExceptionOnInvalidCsv() { .withMessage("Line at index 2 contains invalid CSV: \"\""); } + @Test + void throwsExceptionIfNeitherValueNorTextBlockIsDeclared() { + var annotation = csvSource().build(); + + assertThatExceptionOfType(PreconditionViolationException.class)// + .isThrownBy(() -> provideArguments(annotation))// + .withMessage("@CsvSource must be declared with either `value` or `textBlock` but not both"); + } + + @Test + void throwsExceptionIfValueAndTextBlockAreDeclared() { + var annotation = csvSource().lines("foo").textBlock(""" + bar + baz + """).build(); + + assertThatExceptionOfType(PreconditionViolationException.class)// + .isThrownBy(() -> provideArguments(annotation))// + .withMessage("@CsvSource must be declared with either `value` or `textBlock` but not both"); + } + @Test void providesSingleArgument() { var annotation = csvSource("foo"); @@ -43,6 +64,15 @@ void providesSingleArgument() { assertThat(arguments).containsExactly(array("foo")); } + @Test + void providesSingleArgumentFromTextBlock() { + var annotation = csvSource().textBlock("foo").build(); + + var arguments = provideArguments(annotation); + + assertThat(arguments).containsExactly(array("foo")); + } + @Test void providesMultipleArguments() { var annotation = csvSource("foo", "bar"); @@ -52,6 +82,18 @@ void providesMultipleArguments() { assertThat(arguments).containsExactly(array("foo"), array("bar")); } + @Test + void providesMultipleArgumentsFromTextBlock() { + var annotation = csvSource().textBlock(""" + foo + bar + """).build(); + + var arguments = provideArguments(annotation); + + assertThat(arguments).containsExactly(array("foo"), array("bar")); + } + @Test void splitsAndTrimsArguments() { var annotation = csvSource(" foo , bar "); 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 097caa0a9cdc..5c5605fcd5e6 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 @@ -83,6 +83,7 @@ B ignoreLeadingAndTrailingWhitespace(boolean ignoreLeadingAndTrailingWhitespace) static class MockCsvSourceBuilder extends MockCsvAnnotationBuilder { private String[] lines = new String[0]; + private String textBlock = ""; @Override protected MockCsvSourceBuilder getSelf() { @@ -94,6 +95,11 @@ MockCsvSourceBuilder lines(String... lines) { return this; } + MockCsvSourceBuilder textBlock(String textBlock) { + this.textBlock = textBlock; + return this; + } + @Override CsvSource build() { var annotation = mock(CsvSource.class); @@ -108,6 +114,7 @@ CsvSource build() { // @CsvSource when(annotation.value()).thenReturn(this.lines); + when(annotation.textBlock()).thenReturn(this.textBlock); return annotation; }