Novidades do JUnit 5 - parte 2

Em setembro/2017, após pouco mais de um ano de versões milestones e testes, foi lançado o JUnit 5, a nova versão do principal framework para testes de código na plataforma Java. Escrevi um post sobre as principais novas funcionalidades e recursos. Váááárias coisas legais, mas o que fazemos com os testes que já existem no nosso projeto?

Adorei o JUnit 5! Mas...e os meus testes já escritos nas versões anteriores do JUnit?

Ok, vamos assumir que você já tem testes escritos com o JUnit 3/4 na sua base de código (potencialmente, dezenas, centenas ou milhares). Precisamos alterá-los para começar a usar o JUnit 5? A resposta, ainda bem, é não. Podemos fazer as duas versões conviverem no mesmo projeto (como as novas classes do JUnit Jupiter estão sob o pacote base org.junit.jupiter, não há nenhum conflito com as versões anteriores), enquanto modificamos os testes já existentes e implementamos os novos no JUnit 5.

Como exemplo, no meu projeto há dois testes (quebrados propositalmente para fins de exemplo):

import org.junit.Assert; // <- pacotes do junit 4
import org.junit.Test;

public class JUnit4Test {

    @Test // <- junit 4
    public void test() {
        Assert.fail("fail...on junit 4"); // <- junit 4
    }
}
import org.junit.jupiter.api.Assertions; // <- pacotes do junit 5
import org.junit.jupiter.api.Test;

public class JUnit5Test {

    @Test // <- junit 5
    public void sample() {
        Assertions.fail("fail...on junit 5!"); <- junit 5
    }

}

Build

Vamos começar configurando o JUnit 5 para rodar no build da aplicação (nos exemplos abaixo, utilizo o Maven). O resultado do mvn test no meu projeto será:

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running com.elo7.sample.junit.JUnit4Test
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.208 sec <<< FAILURE!
test(com.elo7.sample.junit.JUnit4Test)  Time elapsed: 0.02 sec  <<< FAILURE!
java.lang.AssertionError: fail...on junit 4
    at org.junit.Assert.fail(Assert.java:88)

Results :

Failed tests:   test(com.elo7.sample.junit.JUnit4Test): fail...on junit 4

Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

O teste implementado com o JUnit 5 não foi executado. Precisamos configurar o Surefire (o plugin padrão para execução de testes no Maven), no pom.xml, para utilizar um provider customizado para rodar os testes sobre o JUnit Platform:

<build>
    <plugins>
        <plugin>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>2.19</version>
            <dependencies>
                <!-- JUnit Platform provider -->
                <dependency>
                    <groupId>org.junit.platform</groupId>
                    <artifactId>junit-platform-surefire-provider</artifactId>
                    <version>1.0.1</version>
                </dependency>
                <!-- Também precisamos de um TestEngine -->
                <dependency>
                    <groupId>org.junit.jupiter</groupId>
                    <artifactId>junit-jupiter-engine</artifactId>
                    <version>5.0.1</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

E agora...

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running com.elo7.sample.junit.JUnit5Test
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.302 sec <<< FAILURE! - in com.elo7.sample.junit.JUnit5Test
sample()  Time elapsed: 0.102 sec  <<< FAILURE!
org.opentest4j.AssertionFailedError: fail...on junit 5!
    at com.elo7.sample.junit.JUnit5Test.sample(JUnit5Test.java:10)

Results :

Failed tests:
  JUnit5Test.sample:10 fail...on junit 5!

Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

Configuramos a execução dos testes para utilizar o JUnit5, mas agora nosso problema se inverteu; o teste escrito com JUnit 4 não rodou :(. Nosso próximo passo é rodar TODOS os testes usando o JUnit Platform.

Precisamos adicionar uma nova dependência abaixo à nossa configuração do Surefire; um artefato com o estiloso nome de junit-vintage-engine.

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.19</version>
    <dependencies>
        ...
        <dependency>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
            <version>4.12.1</version>
        </dependency>
    </dependencies>
</plugin>

O TestEngine disponibilizado pelo junit-vintage é capaz de rodar os testes escritos com JUnit 3/4 sobre a plataforma do JUnit 5!.

Com a modifação acima, a saída do mvn test é:

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running com.elo7.sample.junit.JUnit4Test
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.204 sec <<< FAILURE! - in com.elo7.sample.junit.JUnit4Test
test  Time elapsed: 0.022 sec  <<< FAILURE!
java.lang.AssertionError: fail...on junit 4
    at com.elo7.sample.junit.JUnit4Test.test(JUnit4Test.java:10)

Running com.elo7.sample.junit.JUnit5Test
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.098 sec <<< FAILURE! - in com.elo7.sample.junit.JUnit5Test
sample()  Time elapsed: 0.07 sec  <<< FAILURE!
org.opentest4j.AssertionFailedError: fail...on junit 5!
    at com.elo7.sample.junit.JUnit5Test.sample(JUnit5Test.java:10)

Results :

Failed tests:
  JUnit4Test.test:10 fail...on junit 4
  JUnit5Test.sample:10 fail...on junit 5!

Tests run: 2, Failures: 2, Errors: 0, Skipped: 0

Ambos os testes, JUnit 4 e 5, rodaram :).

Na documentação, há mais exemplos de customizações do Surefire para o JUnit 5.

Caso você utilize o Gradle, basta seguir a documentação para configurar o plugin do JUnit 5.

IDE

Caso utilize uma IDE que ainda não suporta nativamente o JUnit 5, podemos adicionar mais duas dependências que permitem executarmos testes do JUnit Platform sobre a estrutura do Junit 4 (!):

<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-runner</artifactId>
    <version>1.0.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.0.1</version>
    <scope>test</scope>
</dependency>

O artefato junit-platform-runner irá fornecer o runner JUnitPlatform, que podemos usar com a anotação RunWith do JUnit 4 (o junit-jupiter-engine deve ser adicionado para fornecer algum TestEngine):

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.platform.runner.JUnitPlatform;
import org.junit.runner.RunWith;

@RunWith(JUnitPlatform.class) // <- @RunWith do junit 4
public class JUnit5Test {

    @Test // <- junit 5
    public void sample() {
        Assertions.fail("fail...on junit 5!"); // <- junit 5
    }
}

O teste acima funcionará normalmente em qualquer IDE que suporte o JUnit 4. No build, também não é necessária nenhuma customização adicional.

Migrando o código

Considerando tudo o que vimos até aqui, podemos dizer que a mudança para o JUnit 5 é bem mais do que apenas "adicionar o jar" da nova versão. Para fins de migração de código, um resumo das diferenças mais significativas (algumas já detalhadas neste mesmo post) que você deve considerar:

  • Anotações no pacote org.junit.jupiter.api

  • Assert substituída pela classe Assertions

  • Assume substituída pela classe Assumptions

  • @Before e @After substituídas por @BeforeEach e @AfterEach

  • @BeforeClass e @AfterClass substituídas por @BeforeAll e @AfterAll

  • @Ignore substituída por @Disabled

  • @Category substituída por @Tag

  • @RunWith substituída por @ExtendeWith (o que????? explicado mais abaixo!)

  • @Rule e @ClassRule substituídas por...@ExtendeWith também (como????? explicado mais abaixo!)

Um comentário específico sobre as Rules: como dito acima, as anotações @Rule e @ClassRule não existem no JUnit 5. Essas duas classes eram utilizadas por vários frameworks como ponto de extensão, uma vez que as Rules podem ser usadas para externalizar configurações/parametrizações de ambiente necessárias para o teste, como inicializar mocks (caso do Mockito) ou subir um servidor HTTP (caso do MockServer). Dada a importância das Rules no ecossistema do JUnit e para os desenvolvedores, foi implementado um suporte específico para três Rules em particular: ExternalResource, Verifier e ExpectedException. A documentação explica com mais detalhes a motivação para suportar especificamente essas três classes (também é necessário o uso de algumas anotações adicionais: ExpectedExceptionSupport, ExternalResourceSupport e VerifierSupport).

Extendendo o JUnit 5

No JUnit 4, os principais pontos de extensão do framework são as Rules (que permitem alterar/introduzir comportamentos externos no teste) e os Test Runners (um objeto responsável por toda a execução do teste, permitindo customizá-lo à vontade). Esses dois modelos não existem no JUnit 5, que introduz um novo conceito de extensão baseado na interface Extension (essa interface não possui métodos; é apenas uma interface de marcação).

O novo modelo de extensão do JUnit é mais flexível do que as opções das versões anteriores. Podemos implementar extensões para customizações mais específicas; diferentes customizações são representadas por diferentes interfaces, de modo que podemos implementar apenas o detalhe que queremos extender. Por exemplo, resolução de argumentos de métodos (ParameterResolver), callbacks de pré/pós execução de testes (AfterAllCallback, AfterEachCallback, AfterTestExecutionCallback, BeforeAllCallback, BeforeEachCallback, BeforeTestExecutionCallback), captura de erros (TestExecutionExceptionHandler), pós-processamento da instância da classe de teste (TestInstancePostProcessor) ou executar um teste condicionalmente (ExecutionCondition).

Usando extensões

Para utilizar uma extensão no teste, devemos usar a anotação ExtendWith.

import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(YourExtension.class)
public class JUnit5Test {

}

Essa anotação é repetível, de modo que podemos utilizar várias extensões diferentes no mesmo teste.

import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(YourExtension.class)
@ExtendWith(OtherExtension.class)
public class JUnit5Test {

}

Implementando extensões

Para entendendermos o uso das interfaces comentadas mais acima, vou adaptar e comentar aqui um exemplo que consta na documentação para criarmos uma extensão para o Mockito (se você ainda não conhece o Mockito, recomendo a leitura destes dois posts do nosso blog). Essa extensão irá permitir a injeção de mocks como argumentos dos testes:

import org.junit.jupiter.api.extension.TestInstancePostProcessor;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;

import java.lang.reflect.Parameter;

// uma extensão deve sempre implementar Extension; é o caso da classe abaixo pois implementa TestInstancePostProcessor e ParameterResolver (que extendem Extension)
public class MockitoExtension implements TestInstancePostProcessor, ParameterResolver {

    // método da interface TestInstancePostProcessor; será invocado após a criação da instância da classe de teste
    @Override
    public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
        //inicializa os campos da classe marcados com anotações do Mockito
        MockitoAnnotations.initMocks(testInstance);
    }

    // método da interface ParameterResolver; será invocado SE o método de teste tiver algum argumento
    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        // nossa extensão só se aplica para argumentos anotados com @Mock
        return parameterContext.getParameter().isAnnotationPresent(Mock.class);
    }

    // método da interface ParameterResolver; será invocado SE o método de teste tiver algum argumento e SE passar na verificação do método supportsParameter
    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        Parameter parameter = parameterContext.getParameter();

        Class<?> mockType = parameter.getType();

        // Store e Namespace são dois objetos para persistência de estado da extensão
        Store mocks = extensionContext.getStore(Namespace.create(MockitoExtension.class, mockType));

        // cria um novo mock caso ainda não exista
        return mocks.getOrComputeIfAbsent(mockType.getCanonicalName(), key -> Mockito.mock(mockType));
    }
}

E no nosso teste:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class) //usando nossa extensão
public class JUnit5Test {

    @BeforeEach
    public void setup(@Mock Person mockPerson) { //nossa extensão irá injetar o parâmetro anotado com @Mock
        when(mockPerson.name()).thenReturn("Tiago");
    }

    @Test
    public void sample(@Mock Person person) { //nossa extensão irá retornar o mesmo mock criado no setup
        assertEquals("Tiago", person.name());
    }

    private interface Person {

        String name();
    }
}

A documentação explica em detalhes, com vários exemplos e casos de uso, o novo modelo de extensão do JUnit 5.

Conclusão

Nesse segundo post, tentei abordar os principais pontos de atenção para começarmos a utilizar o JUnit 5 em um projeto já existente, incluindo configuração do build, IDE e migração do código. É possível fazer a transição de forma gradual e natural, pois é perfeitamente possível incluir o JUnit 5 em um projeto que já utilize alguma versão anterior do framework.

Apesar dos dois posts loooongos, é possível que algo tenha ficado de fora; se quiser se aprofundar mais, recomendo a excelente documentação e o repositório com exemplos de código.

Obrigado e espero que tenha gostado! Em caso de dúvidas ou qualquer outra observação, sinta-se à vontade para usar a caixa de comentários!