Como criar testes unitários eficazes
Contexto
A criação de testes de unitários automáticos é uma das tarefas mais comuns hoje em dia no desenvolvimento, mas para criar um teste de unidade eficaz você deve cobrir alguns aspectos específicos do teste. Neste artigo, vou mostrar alguns desses aspectos.
First things first
Vamos definir um cenário comum de necessidades de negócio, um Serviço de Pedidos simples e muito comum, onde nosso serviço receberá um objeto com o nome do cliente, os itens do pedido e o endereço de entrega.
O serviço de encomendas deverá validar se o nome, artigos e endereço de entrega não estão vazios e validar se o valor de cada artigo for superior a zero.
Se toda a validação estiver ok, temos que persistir essas informações no banco de dados.
Abaixo, mostro a classe de serviço e a classe do validador:
Service class:
package com.example.test.service;
import com.example.test.dto.Order;
import com.example.test.model.OrderRepresentation;
import com.example.test.repository.OrderRepresentationRepository;
import com.example.test.validator.OrderValidator;
import lombok.AllArgsConstructor;
import java.util.UUID;
@AllArgsConstructor
public class OrderService {
private final OrderRepresentationRepository repository;
private final OrderValidator validator;
public OrderRepresentation save(Order order) {
validator.validateNewOrder(order);
OrderRepresentation orderRepresentation = order.toOrderRepresentation(UUID.randomUUID().toString());
return repository.save(orderRepresentation);
}
}
Validator class:
package com.example.test.validator;
import com.example.test.dto.Item;
import com.example.test.dto.Order;
import com.example.test.exception.ValidationException;
public class OrderValidator {
public void validateNewOrder(Order order) {
if (order.getClientName() == null || order.getClientName().isEmpty()) {
throw new ValidationException("Client name is empty.");
}
if (order.getDeliveryAddress() == null || order.getDeliveryAddress().isEmpty()) {
throw new ValidationException("Delivery address is empty.");
}
for (Item item : order.getItems()) {
if (item.getAmount() == null || item.getAmount() <= 0) {
throw new ValidationException("Invalid amount on item ".concat(item.getName()));
}
}
}
}
Se você tentar executar uma cobertura de código em nosso código, obteremos um valor óbvio de 0%.
A anatomia de um teste
Um bom teste deve seguir algumas premissas para ser um teste bem construído, as premissas são:
- cada teste testa apenas um cenário: parece óbvio, mas cada teste deve abranger apenas um cenário, caso seja necessário abranger um novo cenário, um novo teste precisa ser criado
- cada teste é separado em três partes: configuração, execução e asserções
- se você estiver usando testes unitários, você deve isolar cada camada de código e testar cada uma também isolada
- use o conceito de
mocking
para emular as dependências
Com esses aspectos em mente, vamos criar nossa primeira classe de teste:
package com.example.test;
import com.example.test.dto.Item;
import com.example.test.dto.Order;
import com.example.test.model.OrderRepresentation;
import com.example.test.repository.OrderRepresentationRepository;
import com.example.test.service.OrderService;
import com.example.test.validator.OrderValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
public class OrderServiceTest {
private OrderService orderService;
private OrderValidator orderValidator;
private OrderRepresentationRepository orderRepresentationRepository;
@BeforeEach
public void setUp() {
this.orderValidator = mock(OrderValidator.class);
this.orderRepresentationRepository = mock(OrderRepresentationRepository.class);
doNothing().when(orderValidator).validateNewOrder(any());
given(orderRepresentationRepository.save(any())).willAnswer(invocationOnMock -> invocationOnMock.getArgument(0));
this.orderService = new OrderService(orderRepresentationRepository, orderValidator);
}
@DisplayName("Save order with success")
@Test
public void testSaveOrderWithSuccess() {
Order order = createSimpleOrder();
OrderRepresentation orderRepresentation = this.orderService.save(order);
assertTrue(orderRepresentation.getId() != null);
assertEquals("test", orderRepresentation.getClientName());
assertEquals("test_address", orderRepresentation.getDeliveryAddress());
assertEquals("item1", orderRepresentation.getItems().get(0).getName());
assertEquals(Integer.valueOf(1), orderRepresentation.getItems().get(0).getAmount());
assertEquals(BigDecimal.TEN, orderRepresentation.getItems().get(0).getValue());
}
private Order createSimpleOrder() {
return Order.builder()
.clientName("test")
.deliveryAddress("test_address")
.items(List.of(Item.builder()
.name("item1")
.amount(1)
.value(BigDecimal.TEN)
.build()))
.build();
}
}
Se executarmos esta classe de teste, veremos uma execução de teste bem-sucedida e se executarmos a ferramenta de cobertura de código, veremos 100% de cobertura na classe OrderService.
A questão importante
Agora, quero que você pare e tente responder a seguinte pergunta:
Esse teste é suficiente para cobrir o cenário de sucesso da criação de um pedido?
Você não precisa considerar outros cenários, apenas concentre-se neste e tente responder à pergunta.
Enquanto você tenta encontrar uma resposta, farei algumas alterações na classe OrderService:
package com.example.test.service;
import com.example.test.dto.Order;
import com.example.test.model.OrderRepresentation;
import com.example.test.repository.OrderRepresentationRepository;
import com.example.test.validator.OrderValidator;
import lombok.AllArgsConstructor;
import java.util.UUID;
@AllArgsConstructor
public class OrderService {
private final OrderRepresentationRepository repository;
private final OrderValidator validator;
public OrderRepresentation save(Order order) {
//validator.validateNewOrder(order);
OrderRepresentation orderRepresentation = order.toOrderRepresentation(UUID.randomUUID().toString());
return orderRepresentation;// repository.save(orderRepresentation);
}
}
Olhando para minhas alterações, o que você acha, nosso teste vai falhar ou não?
A resposta é NÃO.
O teste com 100% de cobertura de código NÃO falhará com essas duas novas alterações (linhas comentários 19;23).
Voltando à primeira pergunta, você realmente acha que o teste criado é suficiente? Você provavelmente também mudou sua resposta para NÃO. Agora você provavelmente está pensando: "Ok, o primeiro teste não é suficiente para cobrir o recurso, mas como eu poderia cobrir isso?".
Indo além da cobertura do código
Como você viu, 100% de cobertura de código não é uma boa métrica. No meu último trabalho, tínhamos uma expressão interna para isso, costumamos chamar assim: "O desenvolvedor só pintou o Sonar de verde… de novo…".
O teste falha porque nosso teste foca apenas no valor retornado e não valida o COMPORTAMENTO do método save
. Qual é o comportamento do método salvar? Já descrevemos, lembra?
Vamos definir um cenário comum de necessidades de negócio, um Serviço de Pedidos simples e muito comum, onde nosso serviço receberá um objeto com o nome do cliente, os itens do pedido e o endereço de entrega.
O serviço de encomendas deverá validar se o nome, artigos e endereço de entrega não estão vazios e validar se a quantidade de cada artigo for superior a zero.
Se toda a validação estiver ok, temos que persistir essas informações no banco de dados.
Então, precisamos simplesmente adicionar ao nosso teste a validação desses comportamentos. Com o Mockito, a ferramenta que usamos para MOCK nas camadas de dependência é fácil. Observe a nova implementação da classe de teste:
package com.example.test;
import com.example.test.dto.Item;
import com.example.test.dto.Order;
import com.example.test.model.OrderRepresentation;
import com.example.test.repository.OrderRepresentationRepository;
import com.example.test.service.OrderService;
import com.example.test.validator.OrderValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
public class OrderServiceTest {
private OrderService orderService;
private OrderValidator orderValidator;
private OrderRepresentationRepository orderRepresentationRepository;
@BeforeEach
public void setUp() {
this.orderValidator = mock(OrderValidator.class);
this.orderRepresentationRepository = mock(OrderRepresentationRepository.class);
doNothing().when(orderValidator).validateNewOrder(any());
given(orderRepresentationRepository.save(any())).willAnswer(invocationOnMock -> invocationOnMock.getArgument(0));
this.orderService = new OrderService(orderRepresentationRepository, orderValidator);
}
@DisplayName("Save order with success")
@Test
public void testSaveOrderWithSuccess() {
Order order = createSimpleOrder();
OrderRepresentation orderRepresentation = this.orderService.save(order);
assertTrue(orderRepresentation.getId() != null);
assertEquals("test", orderRepresentation.getClientName());
assertEquals("test_address", orderRepresentation.getDeliveryAddress());
assertEquals("item1", orderRepresentation.getItems().get(0).getName());
assertEquals(Integer.valueOf(1), orderRepresentation.getItems().get(0).getAmount());
assertEquals(BigDecimal.TEN, orderRepresentation.getItems().get(0).getValue());
ArgumentCaptor<Order> argOrder = ArgumentCaptor.forClass(Order.class);
ArgumentCaptor<OrderRepresentation> argOrderRep = ArgumentCaptor.forClass(OrderRepresentation.class);
verify(this.orderRepresentationRepository, times(1)).save(argOrderRep.capture());
verify(this.orderValidator, times(1)).validateNewOrder(argOrder.capture());
verifyNoMoreInteractions(this.orderRepresentationRepository);
verifyNoMoreInteractions(this.orderValidator);
assertEquals("test", argOrder.getValue().getClientName());
assertEquals("test_address", argOrder.getValue().getDeliveryAddress());
assertEquals("item1", argOrder.getValue().getItems().get(0).getName());
assertEquals(Integer.valueOf(1), argOrder.getValue().getItems().get(0).getAmount());
assertEquals(BigDecimal.TEN, argOrder.getValue().getItems().get(0).getValue());
assertTrue(argOrderRep.getValue().getId() != null);
assertEquals("test", argOrderRep.getValue().getClientName());
assertEquals("test_address", argOrderRep.getValue().getDeliveryAddress());
assertEquals("item1", argOrderRep.getValue().getItems().get(0).getName());
assertEquals(Integer.valueOf(1), argOrderRep.getValue().getItems().get(0).getAmount());
assertEquals(BigDecimal.TEN, argOrderRep.getValue().getItems().get(0).getValue());
}
private Order createSimpleOrder() {
return Order.builder()
.clientName("test")
.deliveryAddress("test_address")
.items(List.of(Item.builder()
.name("item1")
.amount(1)
.value(BigDecimal.TEN)
.build()))
.build();
}
}
Se você executar esta nova versão da nossa classe de teste, verá uma falha no nosso teste. Isso ocorre porque adicionamos algumas linhas (59–77), que verificam se a classe OrderValidator e a classe OrderRepresenationRepository foram chamadas. Além disso, essas linhas verificam se os argumentos passados ao validador e ao repositório são os esperados.
Como você observou, é importante verificar se os métodos esperados são chamados dentro de nosso teste, pois quando fazemos isso, estamos criando uma forte ligação entre nosso teste e nosso código criado.
Desta forma, se uma nova funcionalidade for implementada e alterar o método, adicionando novas chamadas ao validador ou repositório, o teste começará a falhar.
Teste de unidade salvando o dia
Por exemplo, se criamos um novo método em nosso repositório:
package com.example.test.repository;
import com.example.test.model.OrderRepresentation;
public interface OrderRepresentationRepository {
OrderRepresentation save(OrderRepresentation orderRepresentation);
OrderRepresentation findByClientName(String clientName);
}
E chamamos esse novo método findByClientName
no serviço:
package com.example.test.service;
import com.example.test.dto.Order;
import com.example.test.exception.ValidationException;
import com.example.test.model.OrderRepresentation;
import com.example.test.repository.OrderRepresentationRepository;
import com.example.test.validator.OrderValidator;
import lombok.AllArgsConstructor;
import java.util.UUID;
@AllArgsConstructor
public class OrderService {
private final OrderRepresentationRepository repository;
private final OrderValidator validator;
public OrderRepresentation save(Order order) {
validator.validateNewOrder(order);
OrderRepresentation orderRepresentation = order.toOrderRepresentation(UUID.randomUUID().toString());
OrderRepresentation alreadyHasOrder = repository.findByClientName(order.getClientName());
if (alreadyHasOrder != null) {
throw new ValidationException("Client already has a order.");
}
return repository.save(orderRepresentation);
}
}
Ao executarmos nosso teste unitário, obteremos o seguinte resultado:
org.mockito.exceptions.verification.NoInteractionsWanted:
No interactions wanted here:
-> at com.example.test.OrderServiceTest.testSaveOrderWithSuccess(OrderServiceTest.java:63)
But found this interaction on mock 'orderRepresentationRepository':
-> at com.example.test.service.OrderService.save(OrderService.java:24)
***
O teste unitário cobriu o cenário onde um novo recurso foi implementado e quebrou o COMPORTAMENTO esperado.
E agora?
Agora, com um bom primeiro cenário criado, poderíamos começar a criar novos cenários para cobrir cenários malsucedidos de criação de um Pedido. Em seguida, crie uma nova classe de testes de unidade para cobrir OrderValidator e outras classes, se necessário.
Obrigado =)