Skip to content

Commit

Permalink
Introduce support for text blocks in @CsvSource
Browse files Browse the repository at this point in the history
@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
  • Loading branch information
sbrannen committed Sep 19, 2021
1 parent 155a25f commit 34217e4
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
37 changes: 29 additions & 8 deletions documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
Expand All @@ -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
Expand Down Expand Up @@ -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]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +33,8 @@
*/
class CsvArgumentsProvider implements ArgumentsProvider, AnnotationConsumer<CsvSource> {

private static final Pattern NEW_LINE_REGEX = Pattern.compile("\\n");

private static final String LINE_SEPARATOR = "\n";

private CsvSource annotation;
Expand All @@ -47,9 +50,20 @@ public void accept(CsvSource annotation) {

@Override
public Stream<? extends Arguments> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>The column delimiter (defaults to comma) can be customized with either
* {@link #delimiter()} or {@link #delimiterString()}.
Expand All @@ -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.
*
* <p>Defaults to an empty array. You therefore must supply CSV content
* via this attribute or the {@link #textBlock} attribute.
*
* <p>If <em>text block</em> syntax is supported by your programming language,
* you may find it more convenient to declare your CSV content via the
* {@link #textBlock} attribute.
*
* <h4>Example</h4>
* <pre class="code">
* {@literal @}ParameterizedTest
* {@literal @}CsvSource({
* "apple, 1",
* "banana, 2",
* "'lemon, lime', 0xF1",
* "strawberry, 700_000",
* })
* void test(String fruit, int rank) {
* // ...
* }</pre>
*
* @see #textBlock
*/
String[] value();
String[] value() default {};

/**
* The CSV lines to use as the source of arguments, supplied as a single
* <em>text block</em>; must not be empty.
*
* <p>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.
*
* <p>Defaults to an empty string. You therefore must supply CSV content
* via this attribute or the {@link #value} attribute.
*
* <p>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.
*
* <h4>Example</h4>
* <pre class="code">
* {@literal @}ParameterizedTest
* {@literal @}CsvSource(textBlock = """
* apple, 1
* banana, 2
* 'lemon, lime', 0xF1
* strawberry, 700_000
* """)
* void test(String fruit, int rank) {
* // ...
* }</pre>
*
* @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}.
Expand Down Expand Up @@ -128,4 +185,5 @@
*/
@API(status = EXPERIMENTAL, since = "5.8")
boolean ignoreLeadingAndTrailingWhitespace() default true;

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,35 @@
class CsvArgumentsProviderTests {

@Test
void throwsExceptionOnInvalidCsv() {
void throwsExceptionForInvalidCsv() {
var annotation = csvSource("foo", "bar", "");

assertThatExceptionOfType(JUnitException.class)//
.isThrownBy(() -> provideArguments(annotation).toArray())//
.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");
Expand All @@ -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");
Expand All @@ -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 ");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ B ignoreLeadingAndTrailingWhitespace(boolean ignoreLeadingAndTrailingWhitespace)
static class MockCsvSourceBuilder extends MockCsvAnnotationBuilder<CsvSource, MockCsvSourceBuilder> {

private String[] lines = new String[0];
private String textBlock = "";

@Override
protected MockCsvSourceBuilder getSelf() {
Expand All @@ -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);
Expand All @@ -108,6 +114,7 @@ CsvSource build() {

// @CsvSource
when(annotation.value()).thenReturn(this.lines);
when(annotation.textBlock()).thenReturn(this.textBlock);

return annotation;
}
Expand Down

0 comments on commit 34217e4

Please sign in to comment.