Guida ai test parametrizzati di JUnit 5

1. Panoramica

JUnit 5, la prossima generazione di JUnit, facilita la scrittura di test per sviluppatori con funzionalità nuove e brillanti.

Fra queste ci sono p test arameterized. Questa funzione ci consente di eseguire più volte un singolo metodo di prova con parametri diversi.

In questo tutorial, esploreremo in modo approfondito i test parametrizzati, quindi iniziamo!

2. Dipendenze

Per poter utilizzare i test parametrizzati JUnit 5, è necessario importare l' artefatto junit-jupiter-params dalla piattaforma JUnit. Ciò significa che quando si utilizza Maven, aggiungeremo quanto segue al nostro pom.xml :

 org.junit.jupiter junit-jupiter-params 5.7.0 test 

Inoltre, quando si utilizza Gradle, lo specificheremo in modo leggermente diverso:

testCompile("org.junit.jupiter:junit-jupiter-params:5.7.0")

3. Prima impressione

Supponiamo di avere una funzione di utilità esistente e vorremmo essere sicuri del suo comportamento:

public class Numbers { public static boolean isOdd(int number) { return number % 2 != 0; } }

I test parametrizzati sono come gli altri test tranne per il fatto che aggiungiamo l' annotazione @ParameterizedTest :

@ParameterizedTest @ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // six numbers void isOdd_ShouldReturnTrueForOddNumbers(int number) { assertTrue(Numbers.isOdd(number)); }

Il test runner di JUnit 5 esegue questo test precedente e, di conseguenza, il metodo isOdd , sei volte. E ogni volta, assegna un valore diverso dall'array @ValueSource al parametro del metodo numerico .

Quindi, questo esempio ci mostra due cose di cui abbiamo bisogno per un test parametrizzato:

  • una fonte di argomenti , un array int , in questo caso
  • un modo per accedervi , in questo caso, il parametro number

C'è anche un'altra cosa non evidente con questo esempio, quindi rimanete sintonizzati.

4. Fonti degli argomenti

Come ormai dovremmo sapere, un test parametrizzato esegue lo stesso test più volte con argomenti diversi.

E, si spera, possiamo fare di più che semplici numeri, quindi esploriamo!

4.1. Valori semplici

Con l' annotazione @ValueSource , possiamo passare un array di valori letterali al metodo di test .

Ad esempio, supponiamo di testare il nostro semplice metodo isBlank :

public class Strings { public static boolean isBlank(String input)  return input == null  }

Ci aspettiamo che questo metodo restituisca true per null per le stringhe vuote. Quindi, possiamo scrivere un test parametrizzato come il seguente per affermare questo comportamento:

@ParameterizedTest @ValueSource(strings = {"", " "}) void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input) { assertTrue(Strings.isBlank(input)); } 

Come possiamo vedere, JUnit eseguirà questo test due volte e ogni volta assegna un argomento dall'array al parametro del metodo.

Uno dei limiti delle sorgenti di valore è che supportano solo i seguenti tipi:

  • short (con l' attributo shorts )
  • byte (con l' attributo bytes )
  • int (con l' attributo ints )
  • long (con l' attributo longs )
  • float (con l' attributo floats )
  • double (con l' attributo double )
  • char (con l' attributo chars )
  • java.lang.String (con l' attributo strings )
  • java.lang.Class (con l' attributo classes )

Inoltre, possiamo passare ogni volta un solo argomento al metodo di test .

E prima di andare oltre, qualcuno ha notato che non abbiamo passato nulla come argomento? Questa è un'altra limitazione: non possiamo passare null tramite @ValueSource, anche per String e Class !

4.2. Valori nulli e vuoti

A partire da JUnit 5.4, possiamo passare un singolo valore nullo a un metodo di test parametrizzato utilizzando @NullSource:

@ParameterizedTest @NullSource void isBlank_ShouldReturnTrueForNullInputs(String input) { assertTrue(Strings.isBlank(input)); }

Poiché i tipi di dati primitivi non possono accettare valori nulli , non possiamo usare @NullSource per argomenti primitivi.

In modo molto simile, possiamo passare valori vuoti usando l' annotazione @EmptySource :

@ParameterizedTest @EmptySource void isBlank_ShouldReturnTrueForEmptyStrings(String input) { assertTrue(Strings.isBlank(input)); }

@EmptySource passa un singolo argomento vuoto al metodo annotato .

Per gli argomenti String , il valore passato sarebbe semplice come una String vuota . Inoltre, questa sorgente di parametri può fornire valori vuoti per i tipi di raccolta e gli array .

Per passare sia valori nulli che vuoti, possiamo utilizzare l' annotazione @NullAndEmptySource composta :

@ParameterizedTest @NullAndEmptySource void isBlank_ShouldReturnTrueForNullAndEmptyStrings(String input) { assertTrue(Strings.isBlank(input)); }

Come con il @EmptySource , l'annotazione composta lavora per String s , Collezione s , e gli array .

Per passare qualche altra variazione di stringa vuota al test parametrizzato, possiamo combinare @ValueSource, @NullSource e @EmptySource insieme:

@ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "\t", "\n"}) void isBlank_ShouldReturnTrueForAllTypesOfBlankStrings(String input) { assertTrue(Strings.isBlank(input)); }

4.3. Enum

Per eseguire un test con valori diversi da un'enumerazione, possiamo utilizzare l' annotazione @EnumSource .

Ad esempio, possiamo affermare che tutti i numeri dei mesi sono compresi tra 1 e 12:

@ParameterizedTest @EnumSource(Month.class) // passing all 12 months void getValueForAMonth_IsAlwaysBetweenOneAndTwelve(Month month) { int monthNumber = month.getValue(); assertTrue(monthNumber >= 1 && monthNumber <= 12); }

In alternativa, possiamo filtrare alcuni mesi utilizzando l' attributo names .

Che ne dici di affermare il fatto che aprile, settembre, giugno e novembre durano 30 giorni:

@ParameterizedTest @EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) void someMonths_Are30DaysLong(Month month) { final boolean isALeapYear = false; assertEquals(30, month.length(isALeapYear)); }

Per impostazione predefinita, i nomi manterranno solo i valori enum corrispondenti. Possiamo capovolgere questa situazione impostando l' attributo mode su EXCLUDE :

@ParameterizedTest @EnumSource( value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER", "FEBRUARY"}, mode = EnumSource.Mode.EXCLUDE) void exceptFourMonths_OthersAre31DaysLong(Month month) { final boolean isALeapYear = false; assertEquals(31, month.length(isALeapYear)); }

Oltre alle stringhe letterali, possiamo passare un'espressione regolare all'attributo names :

@ParameterizedTest @EnumSource(value = Month.class, names = ".+BER", mode = EnumSource.Mode.MATCH_ANY) void fourMonths_AreEndingWithBer(Month month) { EnumSet months = EnumSet.of(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER, Month.DECEMBER); assertTrue(months.contains(month)); }

Abbastanza simile a @ValueSource , @EnumSource è applicabile solo quando passeremo un solo argomento per esecuzione del test.

4.4. Letterali CSV

Supponiamo di assicurarci che il metodo toUpperCase () da String generi il valore maiuscolo previsto. @ValueSource non sarà sufficiente.

Per scrivere un test parametrizzato per tali scenari, dobbiamo:

  • Passa un valore di input e un valore atteso al metodo di test
  • Calcola il risultato effettivo con quei valori di input
  • Assert the actual value with the expected value

So, we need argument sources capable of passing multiple arguments. The @CsvSource is one of those sources:

@ParameterizedTest @CsvSource({"test,TEST", "tEst,TEST", "Java,JAVA"}) void toUpperCase_ShouldGenerateTheExpectedUppercaseValue(String input, String expected) { String actualValue = input.toUpperCase(); assertEquals(expected, actualValue); }

The @CsvSource accepts an array of comma-separated values and each array entry corresponds to a line in a CSV file.

This source takes one array entry each time, splits it by comma and passes each array to the annotated test method as separate parameters. By default, the comma is the column separator but we can customize it using the delimiter attribute:

@ParameterizedTest @CsvSource(value = {"test:test", "tEst:test", "Java:java"}, delimiter = ':') void toLowerCase_ShouldGenerateTheExpectedLowercaseValue(String input, String expected) { String actualValue = input.toLowerCase(); assertEquals(expected, actualValue); }

Now it's a colon-separated value, still a CSV!

4.5. CSV Files

Instead of passing the CSV values inside the code, we can refer to an actual CSV file.

For example, we could use a CSV file like:

input,expected test,TEST tEst,TEST Java,JAVA

We can load the CSV file and ignore the header column with @CsvFileSource:

@ParameterizedTest @CsvFileSource(resources = "/data.csv", numLinesToSkip = 1) void toUpperCase_ShouldGenerateTheExpectedUppercaseValueCSVFile( String input, String expected) { String actualValue = input.toUpperCase(); assertEquals(expected, actualValue); }

The resources attribute represents the CSV file resources on the classpath to read. And, we can pass multiple files to it.

The numLinesToSkip attribute represents the number of lines to skip when reading the CSV files. By default, @CsvFileSource does not skip any lines, but this feature is usually useful for skipping the header lines, like we did here.

Just like the simple @CsvSource, the delimiter is customizable with the delimiter attribute.

In addition to the column separator:

  • The line separator can be customized using the lineSeparator attribute – a newline is the default value
  • The file encoding is customizable using the encoding attribute – UTF-8 is the default value

4.6. Method

The argument sources we've covered so far are somewhat simple and share one limitation: It's hard or impossible to pass complex objects using them!

One approach to providing more complex arguments is to use a method as an argument source.

Let's test the isBlank method with a @MethodSource:

@ParameterizedTest @MethodSource("provideStringsForIsBlank") void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input, boolean expected) { assertEquals(expected, Strings.isBlank(input)); }

The name we supply to @MethodSource needs to match an existing method.

So let's next write provideStringsForIsBlank, a static method that returns a Stream of Arguments:

private static Stream provideStringsForIsBlank() { return Stream.of( Arguments.of(null, true), Arguments.of("", true), Arguments.of(" ", true), Arguments.of("not blank", false) ); }

Here we're literally returning a stream of arguments, but it's not a strict requirement. For example, we can return any other collection-like interfaces like List.

If we're going to provide just one argument per test invocation, then it's not necessary to use the Arguments abstraction:

@ParameterizedTest @MethodSource // hmm, no method name ... void isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument(String input) { assertTrue(Strings.isBlank(input)); } private static Stream isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument() { return Stream.of(null, "", " "); }

When we don't provide a name for the @MethodSource, JUnit will search for a source method with the same name as the test method.

Sometimes it's useful to share arguments between different test classes. In these cases, we can refer to a source method outside of the current class by its fully-qualified name:

class StringsUnitTest { @ParameterizedTest @MethodSource("com.baeldung.parameterized.StringParams#blankStrings") void isBlank_ShouldReturnTrueForNullOrBlankStringsExternalSource(String input) { assertTrue(Strings.isBlank(input)); } } public class StringParams { static Stream blankStrings() { return Stream.of(null, "", " "); } }

Using the FQN#methodName format we can refer to an external static method.

4.7. Custom Argument Provider

Another advanced approach to pass test arguments is to use a custom implementation of an interface called ArgumentsProvider:

class BlankStringsArgumentsProvider implements ArgumentsProvider { @Override public Stream provideArguments(ExtensionContext context) { return Stream.of( Arguments.of((String) null), Arguments.of(""), Arguments.of(" ") ); } }

Then we can annotate our test with the @ArgumentsSource annotation to use this custom provider:

@ParameterizedTest @ArgumentsSource(BlankStringsArgumentsProvider.class) void isBlank_ShouldReturnTrueForNullOrBlankStringsArgProvider(String input) { assertTrue(Strings.isBlank(input)); }

Let's make the custom provider a more pleasant API to use with a custom annotation!

4.8. Custom Annotation

How about loading the test arguments from a static variable? Something like:

static Stream arguments = Stream.of( Arguments.of(null, true), // null strings should be considered blank Arguments.of("", true), Arguments.of(" ", true), Arguments.of("not blank", false) ); @ParameterizedTest @VariableSource("arguments") void isBlank_ShouldReturnTrueForNullOrBlankStringsVariableSource( String input, boolean expected) { assertEquals(expected, Strings.isBlank(input)); }

Actually, JUnit 5 does not provide this! However, we can roll our own solution.

First off, we can create an annotation:

@Documented @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @ArgumentsSource(VariableArgumentsProvider.class) public @interface VariableSource { /** * The name of the static variable */ String value(); }

Then we need to somehow consume the annotation details and provide test arguments. JUnit 5 provides two abstractions to achieve those two things:

  • AnnotationConsumer to consume the annotation details
  • ArgumentsProvider to provide test arguments

So, we next need to make the VariableArgumentsProvider class read from the specified static variable and return its value as test arguments:

class VariableArgumentsProvider implements ArgumentsProvider, AnnotationConsumer { private String variableName; @Override public Stream provideArguments(ExtensionContext context) { return context.getTestClass() .map(this::getField) .map(this::getValue) .orElseThrow(() -> new IllegalArgumentException("Failed to load test arguments")); } @Override public void accept(VariableSource variableSource) { variableName = variableSource.value(); } private Field getField(Class clazz) { try { return clazz.getDeclaredField(variableName); } catch (Exception e) { return null; } } @SuppressWarnings("unchecked") private Stream getValue(Field field) { Object value = null; try { value = field.get(null); } catch (Exception ignored) {} return value == null ? null : (Stream) value; } }

And it works like a charm!

5. Argument Conversion

5.1. Implicit Conversion

Let's re-write one of those @EnumTests with a @CsvSource:

@ParameterizedTest @CsvSource({"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) // Pssing strings void someMonths_Are30DaysLongCsv(Month month) { final boolean isALeapYear = false; assertEquals(30, month.length(isALeapYear)); }

This shouldn't work, right? But, somehow it does!

So, JUnit 5 converts the String arguments to the specified enum type. To support use cases like this, JUnit Jupiter provides a number of built-in implicit type converters.

The conversion process depends on the declared type of each method parameter. The implicit conversion can convert the String instances to types like:

  • UUID
  • Locale
  • LocalDate, LocalTime, LocalDateTime, Year, Month, etc.
  • File and Path
  • URL and URI
  • Enum subclasses

5.2. Explicit Conversion

Sometimes we need to provide a custom and explicit converter for arguments.

Suppose we want to convert strings with the yyyy/mm/ddformat to LocalDate instances. First off, we need to implement the ArgumentConverter interface:

class SlashyDateConverter implements ArgumentConverter { @Override public Object convert(Object source, ParameterContext context) throws ArgumentConversionException { if (!(source instanceof String)) { throw new IllegalArgumentException( "The argument should be a string: " + source); } try { String[] parts = ((String) source).split("/"); int year = Integer.parseInt(parts[0]); int month = Integer.parseInt(parts[1]); int day = Integer.parseInt(parts[2]); return LocalDate.of(year, month, day); } catch (Exception e) { throw new IllegalArgumentException("Failed to convert", e); } } }

Then we should refer to the converter via the @ConvertWith annotation:

@ParameterizedTest @CsvSource({"2018/12/25,2018", "2019/02/11,2019"}) void getYear_ShouldWorkAsExpected( @ConvertWith(SlashyDateConverter.class) LocalDate date, int expected) { assertEquals(expected, date.getYear()); }

6. Argument Accessor

By default, each argument provided to a parameterized test corresponds to a single method parameter. Consequently, when passing a handful of arguments via an argument source, the test method signature gets very large and messy.

One approach to address this issue is to encapsulate all passed arguments into an instance of ArgumentsAccessor and retrieve arguments by index and type.

For example, let's consider our Person class:

class Person { String firstName; String middleName; String lastName; // constructor public String fullName() { if (middleName == null || middleName.trim().isEmpty()) { return String.format("%s %s", firstName, lastName); } return String.format("%s %s %s", firstName, middleName, lastName); } }

Then, in order to test the fullName() method, we'll pass four arguments: firstName, middleName, lastName, and the expected fullName. We can use the ArgumentsAccessor to retrieve the test arguments instead of declaring them as method parameters:

@ParameterizedTest @CsvSource({"Isaac,,Newton,Isaac Newton", "Charles,Robert,Darwin,Charles Robert Darwin"}) void fullName_ShouldGenerateTheExpectedFullName(ArgumentsAccessor argumentsAccessor) { String firstName = argumentsAccessor.getString(0); String middleName = (String) argumentsAccessor.get(1); String lastName = argumentsAccessor.get(2, String.class); String expectedFullName = argumentsAccessor.getString(3); Person person = new Person(firstName, middleName, lastName); assertEquals(expectedFullName, person.fullName()); }

Here, we're encapsulating all passed arguments into an ArgumentsAccessor instance and then, in the test method body, retrieving each passed argument with its index. In addition to just being an accessor, type conversion is supported through get* methods:

  • getString(index) retrieves an element at a specific index and converts it to Stringthe same is true for primitive types
  • get(index) simply retrieves an element at a specific index as an Object
  • get(index, type) retrieves an element at a specific index and converts it to the given type

7. Argument Aggregator

Using the ArgumentsAccessor abstraction directly may make the test code less readable or reusable. In order to address these issues, we can write a custom and reusable aggregator.

To do that, we implement the ArgumentsAggregator interface:

class PersonAggregator implements ArgumentsAggregator { @Override public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context) throws ArgumentsAggregationException { return new Person( accessor.getString(1), accessor.getString(2), accessor.getString(3)); } }

And then we reference it via the @AggregateWith annotation:

@ParameterizedTest @CsvSource({"Isaac Newton,Isaac,,Newton", "Charles Robert Darwin,Charles,Robert,Darwin"}) void fullName_ShouldGenerateTheExpectedFullName( String expectedFullName, @AggregateWith(PersonAggregator.class) Person person) { assertEquals(expectedFullName, person.fullName()); }

The PersonAggregator takes the last three arguments and instantiates a Person class out of them.

8. Customizing Display Names

By default, the display name for a parameterized test contains an invocation index along with a String representation of all passed arguments, something like:

├─ someMonths_Are30DaysLongCsv(Month) │ │ ├─ [1] APRIL │ │ ├─ [2] JUNE │ │ ├─ [3] SEPTEMBER │ │ └─ [4] NOVEMBER

However, we can customize this display via the name attribute of the @ParameterizedTest annotation:

@ParameterizedTest(name = "{index} {0} is 30 days long") @EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) void someMonths_Are30DaysLong(Month month) { final boolean isALeapYear = false; assertEquals(30, month.length(isALeapYear)); }

April is 30 days long surely is a more readable display name:

├─ someMonths_Are30DaysLong(Month) │ │ ├─ 1 APRIL is 30 days long │ │ ├─ 2 JUNE is 30 days long │ │ ├─ 3 SEPTEMBER is 30 days long │ │ └─ 4 NOVEMBER is 30 days long

The following placeholders are available when customizing the display name:

  • {index} will be replaced with the invocation index – simply put, the invocation index for the first execution is 1, for the second is 2, and so on
  • {arguments} is a placeholder for the complete, comma-separated list of arguments
  • {0}, {1}, ... are placeholders for individual arguments

9. Conclusion

In this article, we've explored the nuts and bolts of parameterized tests in JUnit 5.

We learned that parameterized tests are different from normal tests in two aspects: they're annotated with the @ParameterizedTest, and they need a source for their declared arguments.

Also, by now, we should now that JUnit provides some facilities to convert the arguments to custom target types or to customize the test names.

Come al solito, i codici di esempio sono disponibili nel nostro progetto GitHub, quindi assicurati di controllarlo!