Testes Unitários em uma API REST

Utilizando Spring Boot, jUnit5, AssertJ e Mockito.

Testes Unitários em uma API REST

📝Introdução

Testes unitários são testes da menor fração de uma aplicação, no contexto de APIs REST desenvolvidas com Java e Spring Boot trata-se dos testes isolados de métodos de determinada classe. A implementação de testes unitários garante o funcionamento correto de cada uma das funcionalidades implementadas na API de forma rastreável, permitindo a estabilidade do código no caso de modificações.

Além disso, a realização de testes unitários ajuda a identificar erros de forma precoce, o que torna o processo de correção mais rápido e eficiente, possibilitanto a refatoração do código de forma ágil e preditiva. O objetivo deste artigo é apresentar as principais funcionalidades das bibliotecas jUnit, AssertJ e Mockito que são necessárias para automatização de testes e na validação dos resultados em APIs REST desenvolvidas com Spring Boot.


⚙️Ambiente e depêndencias

Durante este artigo vamos considerar a implementação de testes em um projeto que utiliza Spring Boot, que por padrão contém a dependência spring-boot-starter-test, essa dependência inclui as bibliotecas atualizadas do Mockito, AssertJ e do jUnit 5. Além disso, vamos considerar a utilização de um banco de dados H2 para realização dos testes.

Maven:

  • Dependência de testes do Spring Boot:
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
  • Depêndencia do banco de dados H2:
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

Gradle:

  • Dependência de testes do Spring Boot:
testImplementation("org.springframework.boot:spring-boot-starter-test")
  • Depêndencia do banco de dados H2:
runtimeOnly("com.h2database:h2")

Propriedades

Tanto para o Maven quanto para o Gradle, é necessário configurar o arquivo application.properties com as seguintes configurações para o banco de dados H2:

#H2
spring.h2.console.path=/h2-console
spring.h2.console.enabled=true

#SpringData
spring.datasource.url=jdbc:h2:mem:teste
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

📈Testes em Repositórios

Testes unitários em repositórios garantem que as operações básicas de CRUD estejam funcionando sem problemas, além disso, testes unitários em repositórios permitem que as alterações na camada de persistência sejam validadas rapidamente sem afetar o funcionamento do sistema inteiro.

Configurando a classe de testes:

A primeira etapa é a realização da configuração da classe de testes que segue por boas práticas a mesma nomeação do repositório que está sendo testado seguido da palavra "Test". Além disso, é necessário inserir a anotação @DataJpaTest , que faz com que a classe de teste seja executada apenas nas camadas de persistência, não sendo necessário a inicialização da aplicação como um todo para excução do teste e garantindo maior rapidez na execução dos testes. A anotação @DisplayName permite a customização do nome do teste. Por fim, é necessário incluir como atributo da classe o repositório que está sendo testado, e com a anotação @Autowired para a injeção de dependência automática do repositório.

@DataJpaTest
@DisplayName("Teste para o repositório de pessoas.")
class PessoaRepositoryTest {

    @Autowired
    private PessoaRepository repository;
}

O primeiro teste:

Os testes ficarão escridos dentro da classe de testes que foi criada na forma de métodos, acima dos métods será incluida a anotação @Test para indicar ao spring boot que trata-se de um teste, também pode-se utilizar a anotação @DisplayName para customizar o nome do teste após sua execução. Além disso, apenas como prática de organização vou adotar os comentários "cenário", "ação" e "verificação" destro dos testes para que possamos definir as etapas do teste com melhor organização.

@Test
    @DisplayName("Persistencia com sucesso.")
    void save_Persistencia_ComSucesso() {

        //cenário
        aqui vamos incluir cconfigurações de interesse para que o teste seja feito
        // ação
        aqui ficará o método que está sendo testado

        // verificação
        aqui ficará as assertivas que testaremos para garantir que a ação teve sucesso em sua execução, para isso utilizaremos a biblioteca AssertJ

    }

Para esse teste de persistência, no cenário iremos instânciar uma pessoa chamada João e vamos persisti-la no nosso banco de dados, depois vamos pegar o retorno do método .save o qual nos retorna a entidade que foi persistida no banco de dados e vamos fazer algumas assertivas utilizando o AssertJ. As assertivas que vamos utilizar serão:

  • assertThat(pessoaSalvada).isNotNull(); , para garantir que a entidade persistida não é null;

  • assertThat(pessoaSalvada.getId()).isNotNull(); , para garantir que foi gerado um ID para a entidade após ela ser salva no banco de dados;

  • assertThat(pessoaSalvada.getNome()).isEqualTo(pessoa.getNome()); , para garantir que a entidade persistida tem o mesmo nome da entidade que foi instanciada;

Essas assertivas nos garantirão que a pessoa persistida foi a mesma pessoa que foi instanciada e que o processo de persisção ocorreu sem nenhum problema.

@Test
    @DisplayName("Persistencia com sucesso.")
    void save_Persistencia_ComSucesso() {
    // cenário
    Pessoa pessoa = new Pessoa();
    pessoa.setNome("José");

    // ação

    Pessoa pessoaSalvada = repository.save(pessoa);

    // verificação
   assertThat(pessoaSalvada).isNotNull();

   assertThat(pessoaSalvada.getId()).isNotNull();

   assertThat(pessoaSalvada.getNome()).isEqualTo(pessoa.getNome());

    }

Esse foi um exemplo de teste de persistência em um repositório, também é possível efetuar testes para outras funções CRUD, como por exemplo a busca de uma pessoa no banco de dados:

@Test
    @DisplayName("Buscar pessoa por ID com sucesso.")
    void findById_BuscarPorId_ComSucesso() {
        // cenário
        Pessoa pessoaSalvada = repository.save(factory.criarPessoa());

        // ação
        Pessoa pessoaBuscada = repository.findById(pessoaSalvada.getId()).get();


        // verificação
        assertThat(pessoaBuscada).isNotNull();

        assertThat(pessoaBuscada.getId()).isEqualTo(pessoaSalvada.getId());
    }

AssertJ e captura de exceções

Os testes unitários devem cobrir não apenas as possibilidades de sucesso, como também as de falha, e para isso a biblioteca AssertJ nos possibilita a utilização de asserções especificas para quando um método joga uma exceção na pilha de execução, em um cenário como este nós podemos utilizar a seguinte asserção para fazer a verificação:

  • Throwable e = Assertions.catchThrowable(() -> MÉTODO QUE LANÇARÁ A EXCEÇÃO); , escreva o método dentro desta sintaxe para que a exceção que seja lançada fique guardava na variável e do tipo Throwable ;

  • assertThat(e).isInstanceOf(CLASSE DA EXCEÇÃO QUE VOCÊ ESPERA QUE SEJA LANÇADA); , essa assertiva verificará de exceção jogada na pilha é do mesmo tipo que foi indicada pelo método isInstanceOf();;

  • Além disso, pode-se encadear o método .hasMessageContaining(); para verificar a mensagem que a exceção contém.

Por exemplo, o teste abaixo verifica a busca de uma pessoa que não existe dentro do banco de dados, como a entidade especificada não existe no banco de dados será lançada uma exceção do tipo NoSuchElementException.class contendo a mensagem "No value present".

@Test
    @DisplayName("Buscar pessoa por ID que não existe.")
    void findById_BuscarPorId_NaoExiste() {
        // ação
        Throwable e = Assertions.catchThrowable(() -> repository.findById(0l).get());

        // verificação
        assertThat(e).isInstanceOf(NoSuchElementException.class).hasMessageContaining("No value present");
    }

Rodando os testes

Após a execução dos testes unitários da classe de repositório, pode-se veficiar no painel do jUnit os testes que foram efetuados, o tempo de execução e também os nomes customizados que foram escritos por nós, utilizando a anotação @DisplayName.

Ficou interessado em buscar mais informações sobre como fazer outros tipos de assertivas mais elaboradas? Visite a documentação oficial do AssertJ e tenha acesso a todas features que a biblioteca disponibiliza. Não esqueça de visitar essa API com vários exemplos de testes unitários.


📈Testes em Serviços

As classes de serviços são responsáveis por gerenciar as regras de negócio geralmente capturando uma entrada de dados, fazendo algum tipo de processamento nestes dados e solicitando que determinado repositório efetue a persistência do objeto modificado. Testes em serviços são essenciais para garantir que a regra de negócio esteja sendo devidamente executada pela API.

Mockito

Como já relatado, a camada de serviços opera frequentemente junto com repositórios para efetuar diversas operações CRUD, a depender do que a funcionalidade de serviço demanda. No entanto, nosso objetivo em efetuar testes unitários em serviços não é o de testar nossa camada de persistência, para isso existem os testes em repositórios.

Nesse sentido que se apresenta a ferramenta Mockito, que permite a criação "mocks", objetos simulados que imitam o comportamento dos objeto reais. Usando esses mocks, é possível verificar se os serviços estão invocando os métodos de persistência corretamente. O teste de serviços utilizando mocks garante que a camada de serviços esteja executando a regra de negócios corretamente.

Testes efetuados com mocks são mais rápidos do que testes que envolvem a camada de persistência e facilitam a manutenção do código, já que caso ocorra alguma mudança em nossa camada de persistência, o teste em serviços não será afetado.

Ficou abstrato? Vamos para a prática que vai ser mais fácil de entender! Vamos configurar uma classe de serviços:

@ExtendWith(SpringExtension.class)
@DisplayName("Testes para o serviço de pessoas")
class PessoaServiceTest {

    @InjectMocks
    private PessoaService pessoaService;
    @Mock
    private PessoaRepository pessoaRepository;
@BeforeEach
    public void setup() {
        MockitoAnnotations.openMocks(this);
    }
}
  • Ao anotar uma classe de teste com @ExtendWith(SpringExtension.class), você está informando ao JUnit que deseja usar o Spring durante o processo de teste;

  • A classe PessoaService é anotada com @InjectMocks para indicar que é uma classe de serviço a ser testada;

  • A classe PessoaRepository é anotada com @Mock, indicando que será criado um objeto simulado para o repositório;

  • A anotação @BeforeEach faz com que antes de cada teste o conteúdo do método setup() seja inicializado;

  • No método setup(), a classe é configurada para iniciar o MockitoAnnotations, o que permite o uso dos mocks.

Vamos fazer o teste do método cadastrar() dentro de PessoasServices:

@Service
public class PessoaService {
//método a ser testado:
public Pessoa cadastrar(CadastroPessoa dados) {
        Pessoa pessoa = new Pessoa(dados);
        pessoaRepository.save(pessoa);
        return pessoa;
    }

}

Repare que este método instancia uma pessoa e depois persiste ela em um banco de dados, como não queremos efetuar de fato a persistencia, vamos simular o que o comando pessoaRepository.save(pessoa); executa utilizando o mockito:

@Test
    @DisplayName("Cadastrar pessoa com sucesso.")
    void cadastrar_CadastroPessoa_ComSucesso() {
        // cenário
        Pessoa jose= new Pessoa();
        pessoa.setNome("José");
      when(pessoaRepository.save(jose)).thenReturn(jose);

        // ação
        Pessoa pessoa = pessoaService.cadastrar(jose);

        //verificação
        assertThat(pessoa).isNotNull();    

    assertThat(pessoa.getNome()).isEqualTo(jose.getNome());

    }
  • O trecho when(pessoaRepository.save(jose)).thenReturn(jose); instrui o mockito a quando o comando pessoaRepository.save(pessoa); for executado, o mockito retornar o objeto jose como resposta, e não de fato ao repositório executar o comando de persitencia, trata-se de uma simulação que evita a interferência de outras variáveis e permite um teste mais controlado e isolado.

Pode-se também utilizar o mockito para simular o throw de alguma exceção na pilha de execução, veremos isso logo em sequência.


📈Testes em Controllers

Os controllers são responsáveis pelo recebimentos das requisições HTTPs, as chamadas dos serviços corretos e o retorno de respostas de acordo com as boas práticas da utilização do protocolo HTTP, assim é de grande interesse que se façam testes para garantir que tanto o recebimento dos dados pelos métodos do controller quanto as respostas retornadas às requisições estão adequadas ao que se espera de cada endpoint da API.

Para os controllers, vamos também utilizar o Mockito, no entanto, dessa vez vamos "mockar" os services. Vamos configurar a classe de teste de PessoaController:


@ExtendWith(SpringExtension.class)
@DisplayName("Teste para o controller de pessoas.")
class PessoaControllerTest {

    @InjectMocks
    private PessoaController controller;
    @Mock
    private static PessoaService pessoaService;

    @BeforeEach
    public void setup() {
        MockitoAnnotations.openMocks(this);
    }
}

Vamos fazer um teste para o método consultarById() dentro de PessoaController.

@GetMapping("/consultar/{id}")
    public ResponseEntity<DetalhaPessoa> consultarById(@PathVariable Long id) {
        return ResponseEntity.ok(pessoaService.consultarById(id));
    }

Seguindo o que já vimos sobre Mockito, AssertJ e jUnit 5, nosso teste ficará assim:

@Test
    @DisplayName("Consultar pessoa por ID.")
    void consultarById_ConsultarPessoaPorID_ComSucesso() {
        // cenário
        Long idP1 = p1.getPessoa().getId();
        when(pessoaService.consultarById(idP1)).thenReturn(p1.getDetalhaPessoa());

        // ação
        ResponseEntity<DetalhaPessoa> http = controller.consultarById(idP1);

        // verificação
        assertThat(http.getStatusCode()).isEqualTo(HttpStatus.OK);

        assertThat(p1.getDetalhaPessoa()).isEqualTo( http.getBody());
    }
  • assertThat(http.getStatusCode()).isEqualTo(HttpStatus.OK); verifica se o código de resposta HTTP retornado é o código adequado no caso de sucesso (código 200, OK);

  • assertThat(p1.getDetalhaPessoa()).isEqualTo( http.getBody()); verifica se o corpo da responsa HTTP contém o detalhamento da entidade que foir cadastrada e persistida.

Mockar métodos que lançam exceções

Assim como podemos simular o retorno de objetos utilizando o mockito, podemos também simular o lançamento de exceções por métodos utilizando o seguinte método do mockito:

  • when(MÉTODO CHAMADO).thenThrow(INSTANCIA DE EXCEPTION); Quando um método é chamado o mockito lança uma exceção na pilha de execução.

Vamos aplicar essa feature no teste de atualizar() dentro de PessoaService que está mockado dentro de PessoaController.

public DetalhaPessoa atualizar(Long id, AtualizarPessoa dados) {
        Pessoa pessoa = pessoaRepository.findById(id)
                .orElseThrow(() -> PESSOA_NAO_ENCONTRADA_EXCEPTION);
        dados.nome().ifPresent(pessoa::setNome);
        dados.dataNascimento().ifPresent(d -> pessoa.setDataNascimento(LocalDate.parse(d, formato)));
        return new DetalhaPessoa(pessoa);
    }

Repare que o método atualizar() lança uma exceção PESSOA_NAO_ENCONTRADA_EXCEPTION quando pessoaRepository.findById(id) não encontra uma pessoa com um ID especificado, vamos simular o lançamento dessa exceção no teste do método atualizarPessoa() dentro de PessoaController.

@Test
    @DisplayName("Atualizar pessoa não cadastrada.")
    void atualizarPessoa_AtualizarPessoa_NaoCadastrada() {
        // cenário
        Long idP1 = p1.getPessoa().getId();
        AtualizarPessoa novosdados = p2.getDadosParaAtualizar();
        when(pessoaService.atualizar(idP1, novosdados)).thenThrow(new PessoaNaoEncontradaException("Pessoa com o ID especificado não encontrada"));

        // ação
        Throwable e = Assertions.catchThrowable(() -> controller.atualizarPessoa(idP1, novosdados));

        // verificação
assertThat(e).isInstanceOf(PessoaNaoEncontradaException.class).hasMessageContaining("Pessoa com o ID especificado não encontrada");

    }
  • when(pessoaService.atualizar(idP1, novosdados)).thenThrow(new PessoaNaoEncontradaException("Pessoa com o ID especificado não encontrada")); simula o lançamento de exceção do método pessoaService.atualizar() descrito anteriormente.

Gostou dessa funcionalidade? Não se esqueça de ler a documentação oficial do Mockito para poder se atualizar sobre as outras features que a biblioteca disponibiliza. Não esqueça de visitar essa API com vários exemplos de testes unitários.


📌Conclusão

Testes unitários com a biblioteca jUnit5 permitem validar o comportamento de pequenas partes do sistema de forma automatizada. O Mockito permite a simulação do comportamento de determinadas partes do sistema tornando possível testar o comportamento de várias partes do nosso sistema de forma isolada. A biblioteca AssertJ nos garante maior simplicidade e legibilidade nas asserções feitas em cada teste.

📚Referências

Documentação do Spring Boot Testing.

Documentação do jUnit 5.

Documentação do AssertJ.

Documentação Mockito.

API com exemplos de testes unitários.