Parametrização de Testes com JUnit 5.7: Uma Análise Detalhada do @EnumSource

Testes parametrizados permitem que desenvolvedores testem seu código de maneira eficiente com uma gama de valores de entrada. No domínio do teste JUnit, usuários experientes há muito tempo lidam com a complexidade de implementar esses testes. Mas com o lançamento do JUnit 5.7, uma nova era de testes parametrizados surge, oferecendo suporte de primeira linha e capacidades aprimoradas para desenvolvedores. Vamos explorar as emocionantes possibilities que o JUnit 5.7 traz para a mesa de testes parametrizados!

Amostras de Parametrização da Documentação do JUnit 5.7

Vamos ver alguns exemplos da documentação:

Java

 

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
    assertTrue(StringUtils.isPalindrome(candidate));
}

@ParameterizedTest
@CsvSource({
    "apple,         1",
    "banana,        2",
    "'lemon, lime', 0xF1",
    "strawberry,    700_000"
})
void testWithCsvSource(String fruit, int rank) {
    assertNotNull(fruit);
    assertNotEquals(0, rank);
}

@ParameterizedTest
@MethodSource("stringIntAndListProvider")
void testWithMultiArgMethodSource(String str, int num, List<String> list) {
    assertEquals(5, str.length());
    assertTrue(num >=1 && num <=2);
    assertEquals(2, list.size());
}

static Stream<Arguments> stringIntAndListProvider() {
    return Stream.of(
        arguments("apple", 1, Arrays.asList("a", "b")),
        arguments("lemon", 2, Arrays.asList("x", "y"))
    );
}

A anotação @ParameterizedTest deve ser acompanhada por uma das várias anotações de origem fornecidas, descrevendo de onde pegar os parâmetros. A origem dos parâmetros é frequentemente referida como o “provedor de dados”.

I will not dive into their detailed description here: the JUnit user guide does it better than I could, but allow me to share several observations:

  • O @ValueSource é limitado a fornecer apenas um valor de parâmetro. Em outras palavras, o método de teste não pode ter mais de um argumento, e os tipos que podem ser usados são restritos também.
  • A passagem de múltiplos argumentos é parcialmente abordada pelo @CsvSource, que analisa cada string em um registro que depois é passado como argumentos campo a campo. Isso pode ficar difícil de ler com strings longas e/ou muitos argumentos. Os tipos que podem ser usados também são restritos — mais sobre isso mais tarde.
  • Todas as fontes que declararam os valores reais em anotações estão restritas a valores que são constantes em tempo de compilação (limitação de anotações do Java, não JUnit).
  • @MethodSource e @ArgumentsSource fornecem um fluxo/coletânea de n-tuplas (não tipadas) que são então passadas como argumentos de método. Vários tipos reais são suportados para representar a sequência de n-tuplas, mas nenhum deles garante que caberão na lista de argumentos do método. Este tipo de fonte requer métodos adicionais ou classes, mas não impõe restrições sobre onde e como obter os dados de teste.

Como você pode ver, os tipos de fontes disponíveis variam desde os simples (fáceis de usar, mas limitados em funcionalidade) até os finalmente flexíveis que requerem mais código para funcionar.

  • Nota lateral — Isso geralmente é um sinal de um bom design: um pouco de código é necessário para funcionalidade essencial, e adicionar complexidade extra é justificado quando usado para permitir um caso de uso mais exigente.

O que não parece se encaixar neste continuum hipotético de simples para flexível, é o @EnumSource. Vamos olhar para este exemplo não trivial de quatro conjuntos de parâmetros com 2 valores cada.

  • Nota — Embora o @EnumSource passe o valor do enum como um único parâmetro do método de teste, conceitualmente, o teste é parametrizado pelos campos do enum, o que não impõe restrição sobre o número de parâmetros.
Java

 

    enum Direction {
        UP(0, '^'),
        RIGHT(90, '>'),
        DOWN(180, 'v'),
        LEFT(270, '<');

        private final int degrees;
        private final char ch;

        Direction(int degrees, char ch) {
            this.degrees = degrees;
            this.ch = ch;
        }
    }

    @ParameterizedTest
    @EnumSource
    void direction(Direction dir) {
        assertEquals(0, dir.degrees % 90);
        assertFalse(Character.isWhitespace(dir.ch));
        
        int orientation = player.getOrientation();
        player.turn(dir);
        assertEquals((orientation + dir.degrees) % 360, player.getOrientation());
    }

Pense nisso: a lista hardcoded de valores restringe sua flexibilidade severamente (sem dados externos ou gerados), enquanto a quantidade de código adicional necessária para declarar o enum torna esta uma alternativa bastante verbosa em comparação, por exemplo, com @CsvSource.

Porém essa é apenas uma primeira impressão. Vamos ver como isso pode ficar elegante ao explorar a verdadeira potência dos enums do Java.

  • Nota lateral: Este artigo não aborda a verificação de enums que fazem parte do seu código de produção. Esses, obviamente, precisaram ser declarados independentemente de como você escolhe verificá-los. Em vez disso, ele se concentra em quando e como expressar seus dados de teste na forma de enums.

Quando Usar

Existem situações em que os enums performam melhor do que as alternativas:

Múltiplos Parâmetros por Teste

Quando tudo o que você precisa é de um único parâmetro, você provavelmente não quer complicar as coisas além do @ValueSource. Mas assim que você precisa de múltiplos parâmetros — digamos, entradas e resultados esperados — você tem que recorrer ao @CsvSource, @MethodSource/@ArgumentsSource ou @EnumSource.

De certa forma, o enum permite que você “esconda” qualquer número de campos de dados.

Então, quando você precisar adicionar mais parâmetros ao método de teste no futuro, simplesmente adiciona mais campos nos seus enums existentes, deixando os sinais do método de teste intactos. Isso se torna inestimável quando você reutiliza seu provedor de dados em múltiplos testes.

Para outras fontes, é necessário empregar ArgumentsAccessors ou ArgumentsAggregators pela flexibilidade que os enums têm de fábrica.

Segurança de Tipos

Para desenvolvedores Java, isso deve ser um grande ponto.

Parâmetros lidos de CSV (arquivos ou literais), @MethodSource ou @ArgumentsSource, eles não fornecem garantia em tempo de compilação de que a contagem de parâmetros e seus tipos vão corresponder à assinatura.

Obviamente, o JUnit vai reclamar em tempo de execução, mas esqueça qualquer assistência de código do seu IDE.

Same as before, this adds up when you reuse the same parameters for multiple tests. Using a type-safe approach would be a huge win when extending the parameter set in the future.

Tipos Personalizados

Isso é principalmente uma vantagem sobre fontes baseadas em texto, como as que leem dados de CSV — os valores codificados no texto precisam ser convertidos para tipos Java.

Se você tem uma classe personalizada para instanciar a partir do registro CSV, pode fazer isso usando ArgumentsAggregator. No entanto, sua declaração de dados ainda não é segura em termos de tipo — qualquer correspondência incorreta entre a assinatura do método e os dados declarados aparecerá em tempo de execução ao “agregar” argumentos. Sem mencionar que declarar a classe agregadora adiciona mais código de suporte necessário para que sua parametrização funcione. E sempre favorecemos @CsvSource sobre @EnumSource para evitar o código extra.

Documentável

Ao contrário dos outros métodos, a fonte enum possui símbolos Java para ambos os conjuntos de parâmetros (instancias de enum) e todos os parâmetros que eles contêm (campos de enum). Eles fornecem um local direto onde anexar documentação na sua forma mais natural — o JavaDoc.

Não é que a documentação não possa ser colocada em outro lugar, mas, por definição, será colocada mais longe do que ela documenta, e portanto será mais difícil de encontrar e mais fácil de se tornar desatualizada.

Mas Há Mais!

Agora: Enums. São. Classes.

Parece que muitos desenvolvedores iniciantes ainda não perceberam o quão poderosos os enums do Java realmente são.

Em outras linguagens de programação, eles são realmente apenas constantes glorificadas. Mas no Java, eles são convenientes pequenas implementações de um padrão de design Flyweight com (muita da) vantagens de classes completas.

Por que isso é uma coisa boa?

Comportamento Relacionado a Test Fixture

Como com qualquer outra classe, enums podem ter métodos adicionados a eles.

Isso se torna útil se parâmetros de teste de enum são reutilizados entre testes — mesmos dados, apenas testados de maneira um pouco diferente. Para trabalhar eficazmente com os parâmetros sem cópia e cola significativa, algum código auxiliar precisa ser compartilhado entre esses testes.

Não é algo que uma classe auxiliar e alguns métodos estáticos não resolveriam.

  • Nota lateral: Note que esse design sofre de C inveja de características. Métodos de teste — ou pior, métodos de classe auxiliar — teriam que puxar os dados dos objetos enum para realizar ações sobre esses dados.

Enquanto isso é a (única) maneira em programação procedural, no mundo orientado a objetos, podemos fazer melhor.

Declarando os métodos “auxiliares” diretamente na declaração do enum, moveríamos o código para onde os dados estão. Ou, para usar o jargão de POO, os métodos auxiliares se tornariam o “comportamento” dos fixtures de teste implementados como enums. Isso não só tornaria o código mais idiomático (chamando métodos sensíveis em instâncias em vez de métodos estáticos passando dados), mas também facilitaria a reutilização de parâmetros enum entre casos de teste.

Herança

Enums podem implementar interfaces com métodos (padrão). Quando usado com senso, isso pode ser explorado para compartilhar comportamento entre vários provedores de dados — vários enums.

Um exemplo que facilmente vem à mente são enums separados para testes positivos e negativos. Se eles representam um tipo semelhante de fixture de teste, é provável que tenham algum comportamento para compartilhar.

A Fala é Barata

Vamos ilustrar isso em uma suíte de testes de um conversor hipotético de arquivos de código-fonte, não muito diferente do que realiza a conversão de Python 2 para 3.

Para ter confiança real no que uma ferramenta tão abrangente faz, um acaba com um conjunto extenso de arquivos de entrada manifestando vários aspectos da linguagem, e arquivos correspondentes para comparar o resultado da conversão. Exceto isso, é necessário verificar quais avisos/erros são apresentados ao usuário para entradas problemáticas.

Isso é uma combinação natural para testes parametrizados devido ao grande número de amostras para verificar, mas não se encaixa em nenhum dos simples fontes de parâmetros do JUnit, pois os dados são um pouco complexos.

Veja abaixo:

Java

 

    enum Conversion {
        CLEAN("imports-correct.2.py", "imports-correct.3.py", Set.of()),
        WARNINGS("problematic.2.py", "problematic.3.py", Set.of(
                "Using module 'xyz' that is deprecated"
        )),
        SYNTAX_ERROR("syntax-error.py", new RuntimeException("Syntax error on line 17"));
        // Muitos, muitos outros ...

        @Nonnull
        final String inFile;
        @CheckForNull
        final String expectedOutput;
        @CheckForNull
        final Exception expectedException;
        @Nonnull
        final Set expectedWarnings;

        Conversion(@Nonnull String inFile, @Nonnull String expectedOutput, @NotNull Set expectedWarnings) {
            this(inFile, expectedOutput, null, expectedWarnings);
        }

        Conversion(@Nonnull String inFile, @Nonnull Exception expectedException) {
            this(inFile, null, expectedException, Set.of());
        }

        Conversion(@Nonnull String inFile, String expectedOutput, Exception expectedException, @Nonnull Set expectedWarnings) {
            this.inFile = inFile;
            this.expectedOutput = expectedOutput;
            this.expectedException = expectedException;
            this.expectedWarnings = expectedWarnings;
        }

        public File getV2File() { ... }

        public File getV3File() { ... }
    }

    @ParameterizedTest
    @EnumSource
    void upgrade(Conversion con) {

        try {
            File actual = convert(con.getV2File());
            if (con.expectedException != null) {
                fail("No exception thrown when one was expected", con.expectedException);
            }
            assertEquals(con.expectedWarnings, getLoggedWarnings());
            new FileAssert(actual).isEqualTo(con.getV3File());
        } catch (Exception ex) {
            assertTypeAndMessageEquals(con.expectedException, ex);
        }
    }

O uso de enums não nos restringe em quanto complexos podem ser os dados. Como você pode ver, podemos definir vários construtores convenientes nos enums, então declarar novos conjuntos de parâmetros é agradável e limpo. Isso evita o uso de longas listas de argumentos que frequentemente terminam cheias de muitos valores ” vazios ” (nulos, strings vazias ou coleções) que deixam a gente se perguntando o que o argumento #7 — você sabe, um dos nulos — realmente representa.

Perceba como os enums permitem o uso de tipos complexos (Set, RuntimeException) sem restrições ou conversões mágicas. Passar esses dados também é completamente seguro por tipo.

Agora, sei o que você está pensando. Isso é terrivelmente prolixo. Bem, até certo ponto. Realisticamente, você vai ter muito mais amostras de dados para verificar, então a quantidade de código boilerplate será menos significativa em comparação.

Também veja como os testes relacionados podem ser escritos utilizando os mesmos enums e seus métodos auxiliares:

Java

 

    @ParameterizedTest
    @EnumSource
    // Atualizando arquivos já atualizados sempre passa, não faz alterações, não emite avisos.
    void upgradeFromV3toV3AlwaysPasses(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV3File());
    }

    @ParameterizedTest
    @EnumSource
    // Revertendo arquivos criados pelo procedimento de atualização é esperado que sempre passe sem avisos.
    void downgrade(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV2File());
    }

Alguns Mais Conversas Após Tudo

Conceitualmente, @EnumSourceencoraja você a criar uma descrição complexa e legível por máquina de cenários de teste individuais, desbotando a linha entre provedores de dados e fixtures de teste.

Outra coisa ótima sobre ter cada conjunto de dados expresso como um símbolo Java (elemento de enum) é que eles podem ser usados individualmente; completamente fora de provedores de dados/testes parametrizados. Desde que tenham um nome razoável e sejam autocontidos (em termos de dados e comportamento), eles contribuem para testes bonitos e legíveis.

Java

 

@Test
void warnWhenNoEventsReported() throws Exception {
    FixtureXmls.Invalid events = FixtureXmls.Invalid.NO_EVENTS_REPORTED;
    
    // read() é um método auxiliar compartilhado por todos os FixtureXmls
    try (InputStream is = events.read()) {
        EventList el = consume(is);
        assertEquals(Set.of(...), el.getWarnings());
    }
}

Agora, @EnumSource não vai ser uma das suas fontes de argumento mais usadas, e isso é uma coisa boa, pois usá-la em excesso não faria bem. Mas nas circunstâncias certas, é útil saber como usar tudo o que ela tem a oferecer.

Source:
https://dzone.com/articles/junit5-parameterized-tests-with-enumsource