Guia para testes parametrizados do JUnit 5

Guia para testes parametrizados do JUnit 5

1. Visão geral

JUnit 5, a próxima geração do JUnit, facilita a escrita de testes de desenvolvedor com recursos novos e brilhantes.

Um desses recursos éparameterized tests.. Este recurso nos permiteexecute a single test method multiple times with different parameters.

Neste tutorial, vamos explorar testes parametrizados em profundidade, então vamos começar!

2. Dependências

Para usar os testes parametrizados JUnit 5, precisamos importar o artefatojunit-jupiter-params da plataforma JUnit. Isso significa que ao usar o Maven, adicionaremos o seguinte ao nossopom.xml:


    org.junit.jupiter
    junit-jupiter-params
    5.4.2
    test

Além disso, ao usar o Gradle, vamos especificá-lo de maneira um pouco diferente:

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

3. Primeira impressão

Digamos que temos uma função de utilidade existente e gostaríamos de ter certeza sobre seu comportamento:

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

Os testes parametrizados são como outros testes, exceto que adicionamos a anotação@ParameterizedTest:

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

O executor de teste JUnit 5 executa o teste acima - e, conseqüentemente, o métodoisOdd - seis vezes. E a cada vez, ele atribui um valor diferente da matriz@ValueSource ao parâmetro do métodonumber.

Portanto, este exemplo mostra duas coisas que precisamos para um teste parametrizado:

  • a source of arguments, uma matrizint, neste caso

  • a way to access them, neste caso, o parâmetronumber

Também há mais uma coisa que não é evidente neste exemplo, portanto, fique atento.

4.  Fontes de argumento

Como já sabemos, um teste parametrizado executa o mesmo teste várias vezes com argumentos diferentes.

E, com sorte, podemos fazer mais do que apenas números - então, vamos explorar!

4.1. Valores simples

With the @ValueSource annotation, we can pass an array of literal values to the test method.

Por exemplo, suponha que vamos testar nosso métodoisBlank simples:

public class Strings {
    public static boolean isBlank(String input) {
        return input == null || input.trim().isEmpty();
    }
}

Esperamos que este método retornetrue paranull para strings em branco. Portanto, podemos escrever um teste parametrizado como o seguinte para afirmar esse comportamento:

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

Como podemos ver, o JUnit executará esse teste duas vezes e, a cada vez, atribui um argumento da matriz ao parâmetro method.

Uma das limitações das fontes de valor é que elas suportam apenas os seguintes tipos:

  • short (com o atributoshorts)

  • byte (com o atributobytes)

  • int (com o atributoints)

  • long (com o atributolongs)

  • float (com o atributofloats)

  • double (com o atributodoubles)

  • char (com o atributochars)

  • java.lang.String (com o atributostrings)

  • java.lang.Class (com o atributoclasses)

Além disso,we can only pass one argument to the test method each time.

E antes de prosseguir, alguém percebeu que não passamosnull como argumento? Essa é outra limitação:We can’t pass null through a @ValueSource, even for String and Class!

4.2. Valores nulos e vazios

A partir da JUnit 5.4, podemos passar um único valornull  para um método de teste parametrizado usando@NullSource:

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

Uma vez que os tipos de dados primitivos não podem aceitarnull values, não podemos usar o@NullSource  para argumentos primitivos.

Da mesma forma, podemos passar valores vazios usando a anotação@EmptySource :

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

@EmptySource passes a single empty argument to the annotated method.

Para argumentosString, o valor passado seria tão simples quanto umString vazio. Moreover, this parameter source can provide empty values for Collection types and arrays.

A fim de passar os valores denull de areia vazia, podemos usar a nota sanitária@NullAndEmptySource composta:

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

Tal como acontece com@EmptySource, a anotação composta funciona paraStrings _, Collections, _ e matrizes.

Para passar mais algumas variações de strings vazias para o teste parametrizado,we can combine @ValueSource, @NullSource, and @EmptySource together:

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

4.3. Enum

In order to run a test with different values from an enumeration, we can use the @EnumSource annotation.

Por exemplo, podemos afirmar que todos os números de meses estão entre 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);
}

Ou podemos filtrar alguns meses usando o atributonames .

Que tal afirmar que abril, setembro, junho e novembro duram 30 dias:

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

Por padrão, onames manterá apenas os valores enum correspondentes. Podemos reverter isso definindo o atributomode paraEXCLUDE:

@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));
}

Além de strings literais, podemos passar uma expressão regular para o atributonames:

@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));
}

Bem semelhante a@ValueSource,@EnumSource só é aplicável quando vamos passar apenas um argumento por execução de teste.

4.4. Literais em CSV

Suponha que vamos nos certificar de que o métodotoUpperCase() deString gere o valor esperado em maiúsculas. @ValueSource não será suficiente.

Para escrever um teste parametrizado para esses cenários, precisamos:

  • Passeinput value andexpected value para o método de teste

  • Calcule oactual result with those input values

  • Afirme o valor real com o valor esperado

Portanto, precisamos de fontes de argumentos capazes de transmitir vários argumentos. O@CsvSource é uma dessas fontes:

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

O@CsvSource aceita uma matriz de valores separados por vírgulas e cada entrada da matriz corresponde a uma linha em um arquivo CSV.

Essa fonte usa uma entrada de matriz de cada vez, divide-a por vírgula e passa cada matriz para o método de teste anotado como parâmetros separados. Por padrão, a vírgula é o separador de coluna, mas podemos personalizá-la usando o atributodelimiter:

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

Agora é um valor separado porcolon, ainda um CSV!

4.5. Arquivos CSV

Em vez de passar os valores CSV dentro do código, podemos nos referir a um arquivo CSV real.

Por exemplo, poderíamos usar um arquivo CSV como:

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

Podemos carregar o arquivo CSV eignore the header column com@CsvFileSource:

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

O sattributeresources representa os recursos do arquivo CSV no caminho de classe para leitura. E podemos passar vários arquivos para ele.

OnumLinesToSkip attribute representa o número de linhas a serem ignoradas ao ler os arquivos CSV. By default, @CsvFileSource does not skip any lines, but this feature is usually useful for skipping the header lines, como fizemos aqui.

Assim como o@CsvSource simples, o delimitador é personalizável com o atributodelimiter .

Além do separador de colunas:

  • O separador de linha pode ser personalizado usando o atributolineSeparator - uma nova linha é o valor padrão

  • A codificação do arquivo é personalizável usando o atributoencoding - UTF-8 é o valor padrão

4.6. Método

As fontes de argumento que cobrimos até agora são um tanto simples e compartilham uma limitação: é difícil ou impossível passar objetos complexos usando-os!

Uma abordagem paraproviding more complex arguments is to use a method as an argument source.

Vamos testar o métodoisBlank com um@MethodSource:

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

O nome que fornecemos para@MethodSource precisa corresponder a um método existente.

Então, vamos escreverprovideStringsForIsBlank,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)
    );
}

Aqui estamos literalmente retornando um fluxo de argumentos, mas não é um requisito estrito. Por exemplo,we can return any other collection-like interfaces like *List.* 

Se vamos fornecer apenas um argumento por invocação de teste, então não é necessário usar a sabstraçãoArguments :

@ParameterizedTest
@MethodSource // hmm, no method name ...
void isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument(String input) {
    assertTrue(Strings.isBlank(input));
}

private static Stream isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument() {
    return Stream.of(null, "", "  ");
}

Quando não fornecemos um nome para@MethodSource, JUnit irá procurar por um método de origem com o mesmo nome do método de teste.

Às vezes, é útil compartilhar argumentos entre diferentes classes de teste. Nesses casos, podemos nos referir a um método de origem fora da classe atual por seu nome completo:

class StringsUnitTest {

    @ParameterizedTest
    @MethodSource("com.example.parameterized.StringParams#blankStrings")
    void isBlank_ShouldReturnTrueForNullOrBlankStringsExternalSource(String input) {
        assertTrue(Strings.isBlank(input));
    }
}

public class StringParams {

    static Stream blankStrings() {
        return Stream.of(null, "", "  ");
    }
}

Usando o formatoFQN#methodName, podemos nos referir a um método estático externo.

4.7. Fornecedor de argumentos personalizados

Outra abordagem avançada para passar em argumentos de teste é usar uma implementação personalizada de uma interface chamadaArgumentsProvider:

class BlankStringsArgumentsProvider implements ArgumentsProvider {

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

Em seguida, podemos anotar nosso teste com a anotação@ArgumentsSource para usar este provedor personalizado:

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

Vamos tornar o provedor personalizado uma API mais agradável de usar com uma anotação personalizada!

4.8. Anotação personalizada

Que tal carregar os argumentos de teste de uma variável estática? Algo como:

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));
}

Na verdade,JUnit 5 does not provide this! No entanto, podemos lançar nossa própria solução.

Primeiro, podemos criar uma anotação:

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(VariableArgumentsProvider.class)
public @interface VariableSource {

    /**
     * The name of the static variable
     */
    String value();
}

Então, precisamos de alguma formaconsume the annotation detalhes eprovide test arguments. O JUnit 5 fornece duas abstrações para alcançar essas duas coisas:

  • AnnotationConsumer para consumir os detalhes da anotação

  • ArgumentsProvider to fornece argumentos de teste

Portanto, precisamos fazer o sclassVariableArgumentsProvider lido da variável estática especificada e retornar seu valor como argumentos de teste:

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;
    }
}

E funciona como um encanto!

5. Conversão de argumento

5.1. Conversão implícita

Vamos reescrever um desses@EnumTests com um @CsvSource:

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

Isso não deve funcionar, certo? Mas, de alguma forma, sim!

Portanto, o JUnit 5 converte os sargumentosString para o tipo de enum especificado. Para suportar casos de uso como esse, o JUnit Jupiter fornece vários conversores de tipo implícito internos.

O processo de conversão depende do tipo declarado de cada parâmetro do método. A conversão implícita pode converter as instânciasString em tipos como:

  • UUID

  • Localidade

  • LocalDate, LocalTime, LocalDateTime, Year, Month, etc.

  • File areiaPath

  • URL areiaURI

  • Enum subclasses

5.2. Conversão explícita

Às vezes, precisamos fornecer um conversor explícito e personalizado para argumentos.

Suponha que queremos converter strings com o formatoyyyy/mm/dd  em instânciasLocalDate. Primeiro, precisamos implementar a interfaceArgumentConverter:

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);
        }
    }
}

Então devemos nos referir ao conversor por meio da anotação@ConvertWith :

@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

Por padrão, cada argumento fornecido para um teste parametrizado corresponde a um único parâmetro de método. Consequentemente, ao passar um punhado de argumentos por meio de uma fonte de argumentos, a assinatura do método de teste fica muito grande e confusa.

Uma abordagem para resolver esse problema é encapsular todos os argumentos passados ​​em uma instância deArgumentsAccessor e recuperar os argumentos por índice e tipo.

Por exemplo, vamos considerar nossa classePerson:

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);
    }
}

Então, para testar o métodofullName(), passaremos quatro argumentos:firstName, middleName, lastName,eexpected fullName.  Podemos usar oArgumentsAccessor para recuperar os argumentos de teste em vez de declará-los como parâmetros do método:

@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());
}

Aqui, estamos encapsulando todos os argumentos passados ​​em uma posiçãoArgumentsAccessor in e, em seguida, no corpo do método de teste, recuperando cada argumento passado com seu índice. Além de ser apenas um acessador, a conversão de tipo é compatível com os métodosget*:

  • getString(index) recupera um elemento em um índice específico e o converte emString -to mesmo é verdadeiro para tipos primitivos

  • get(index) simply recupera um elemento em um índice específico como umObject

  • get(index, type) r recupera um elemento em um índice específico e o converte para otype fornecido

7. Argument Aggregator

Usar a sabstraçãoArgumentsAccessor diretamente pode tornar o código de teste menos legível ou reutilizável. Para resolver esses problemas, podemos escrever um agregador personalizado e reutilizável.

Para fazer isso, implementamos a sinterfaceArgumentsAggregator :

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));
    }
}

E então a referimos por meio da nota sanitária@AggregateWith :

@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());
}

OPersonAggregator põe em risco os últimos três argumentos e instancia uma classePerson deles.

8.  Personalização de nomes de exibição

Por padrão, o nome de exibição de um teste parametrizado contém um índice de invocação junto com uma representaçãoString  de todos os argumentos passados, algo como:

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

No entanto, podemos personalizar essa exibição por meio do atributoname da anotação@ParameterizedTest:

@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 certamente é um nome de exibição mais legível:

├─ 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

Os seguintes espaços reservados estão disponíveis ao personalizar o nome para exibição:

  • {index} erá substituído pelo índice de invocação - simplificando, o índice de invocação para a primeira execução é 1, para a segunda é 2 e assim por diante

  • {arguments}  é um espaço reservado para a lista completa de argumentos separados por vírgulas

  • {0}, {1}, ... são marcadores de posição para argumentos individuais

9. Conclusão

Neste artigo, exploramos as porcas e parafusos dos testes parametrizados no JUnit 5.

Aprendemos que os testes parametrizados são diferentes dos testes normais em dois aspectos: eles são anotados com@ParameterizedTest e precisam de uma fonte para seus argumentos declarados.

Além disso, agora, devemos agora que o JUnit forneça alguns recursos para converter os argumentos em tipos de destino customizados ou para personalizar os nomes de teste.

Como de costume, os códigos de amostra estão disponíveis em nosso projetoGitHub, então certifique-se de conferir!