A verdade é que uma entrega de software com qualidade, é formada por uma base de código que tenha bom design, e que permita o crescimento e evolução do software com simplicidade, qualidade e segurança durante seu ciclo de vida. Junto há uma bateria de testes que agregue segurança na evolução e manutenção do software. E que também ofereça a oportunidade de antecipar o encontro de bugs e anomalias.
E no contexto de arquiteturas distribuídas ou altamente distribuídas, como no caso de Microsserviços, onde majoritariamente os desafios estão concentrados em garantir que os diferentes sistemas comuniquem-se bem entre-si. Estas dificuldades também se estendem para os testes, e caso optemos por utilizar apenas testes de unidade, deixaremos de captar falhas referentes a processos básicos do nosso sistema, como serialização, desserialização, mapeamento dos clientes HTTP e até mesmos que nossas operações juntos a bancos de dados, brokers MQ sejam feitas como eu já disse em:
3 motivos do porquê testes unitários não são suficientes para Microservices com Spring Boot
Jordi Henrique Silva ・ Dec 14 '22
Pensando nisto, separei 3 dicas para te ajudar, a escrever testes que aumentem a segurança e corretude dos sistemas em que trabalha.
1. Entenda o contexto e conheças as dependências da sua solução
O primeiro passo para escrever um codigo de teste, é ter bom entendimento sobre o código de produção, principalmente quando nos apoiamos em frameworks de IoC/DI como Spring, onde utilizamos outros beans em composição com nossa lógica de negócio. Então aqui aparecem ferramentas de cache, bancos de dados relacionais e não relacionais, clientes HTTP, gRPC e outros.
Entender como as dependências interagem entre-si lhe permite identificar o que é necessário para o seu cenário de teste. Desde quais dependências precisaram ser simuladas e também quais são as entradas necessárias para o teste.
2. Utilize ferramentas de testes modernas como TestContainers e WireMock para simular suas dependências
Essa dica é como a cereja do bolo, traz refinamento a sua suíte de testes, pois, com uso destas ferramentas, você pode simular de maneira fidedigna os serviços que serão integrados em produção. Ao aproximar a execução dos testes ao cenário de produção, conseguiremos validar detalhes como, conexão com a ferramenta, configuração dos beans via properties ou programaticamente. Ao utilizar ferramentas como TestContainers podemos fazer com que nosso testes executem seus cenários que exigem o Redis como Cache Distribuído, através de containers gerenciados pelo JUnit. Ou até mesmo serviços da AWS como S3, SQS, SNS e DynamoDB.
Já quando você opta por utilizar Wiremock, você ganhará confiança em suas integrações REST, pois, Wiremock permite que você simule a resposta de Servidores HTTP, através de seus contratos. Desta forma, aquele cliente que você mapeou a chamada a uma API, será executado, e ainda na execução dos testes você terá validações se o contrato é respeitado, ou se o seu código de produção exige que modificações sejam feitas.
3. Escreva seus testes nas etapas de Cenário, Ação e Validação ou AAA Pattern.
A ideia do aqui é basicamente separar seu teste em 3 estágios, cenário, ação e validação, ou como a literatura diz em Unit Testing Principles, Practices, and Patterns, Arrange, Act e Assert.
O primeiro estágio é onde nos preocupamos em provisionar o Cenário necessário para execução da nossa solução. Este estágio é onde criamos os objetos de domínio que serão utilizados como massa para o teste, caso nossa solução utilize dependências externas, devemos provisioná-las ou Mockar as mesmas, de modo a definir seus comportamentos.
Não existe segredo para o estágio de Ação, nele é onde invocamos a execução do nosso código de produção que desejamos testar, e armazenamos a resposta para ser utilizada na próxima etapa. E por fim, iremos cuidar da etapa de Validação do teste, escrevendo um conjunto de sentenças que tragam garantia que o comportamento executado pelo código de produção, entrega o esperado, utilizaremos os asserts para validar se os resultados correspondem ao planejado.
Vamos ver na prática?
Para entender um pouco melhor vamos imaginar a seguinte situação, dado um fluxo de um sistema que realiza venda de produtos, é necessário que você implemente um serviço que calcule o frete para uma determinada venda. Neste calculo será necessário que você se integre a API da transportadora para calcular uma simulação de frete. Para executar a simulação, será necessário que obtenha o peso total do pedido, e o código postal, do destino, para seja calculado um valor e estimativa de data de entrega para o frete.
POST www.suatransportadora.com/v1/frete
{
"origem": "32569-455",
"destino": "39856-151",
"valor": 45.5,
"peso": 784,
"comprimento": 25,
"altura": 10,
"largura": 4
}
A chamada HTTP acima, corresponde ao contrato que deve ser utilizado para se integrar a REST API de fretes da transportadora. E abaixo você encontrará uma implementação do serviço de fretes em Spring Boot.
@Service
public class CalculaFreteService{
@Autowired
private ProdutoRepository repository;
@Autowired
private TransportadoraClient freteClient;
@Value("${default.cep.origem}")
private String cepOrigem;
@Transactional(readOnly = true)
public Frete calcular(SolicitacaoCompra solicitacaoCompra) {
List<Produto> produtos = repository.findAllById(solicitacaoCompra.produtos());
var altura = produtos.stream().max(alturaComparator()).get().getAltura();
var largura = produtos.stream().max(larguraComparator()).get().getLargura();
var comprimento = produtos.stream().max(comprimentoComparator()).get().getComprimento();
var valor = produtos.stream().map(Produto::getPreco).reduce(BigDecimal.ZERO, BigDecimal::add);
var peso = produtos.stream().map(Produto::getPeso).reduce(BigDecimal.ZERO, BigDecimal::add);
var freteRequest = new FreteSimulationRequest(
altura,
largura,
comprimento,
peso,
cepOrigem,
solicitacaoCompra.cepDestino(),
valor
);
try {
var simulacao = freteClient.simulate(freteRequest);
return new Frete(simulacao);
} catch (FeignException.FeignClientException.BadRequest ex) {
throw new InvalidIntegrationException("Entradas nao suportadas");
}
}
}
O codigo apresentado acima, pode ser resumido em, o sistema recebe o cep de destino e os identificadores dos produtos que serão entregues. Em seguinda os produtos são buscados no banco de dados, e as caracteristicas de largura, altura e comprimento do recipiente que armazenará os produtos, junto ao peso, e o valor do produto são obtidos, para serem utilizados como entrada da integração da API de fretes. Após obter estes dados, uma chamda HTTP é feita a API da Transportadora para calcular o custo do frete. Caso a requisição seja executada com sucesso, os dados do frete são retornados, caso contrário uma resposta de erro será retornada.
Aplicando o conhecimento
Agora que conhecemos nosso serviço, e também aprendemos sobre como extrair melhor valor de ferramentas modernas de testes, podemos somar com conhecimento de Spring Test e JUnit para construir teste que aproximem a execução do serviço em produção.
Então antes da escrita do primeiro teste, é necessário identificar e provisionar as dependências exigidas para execução no serviço, conforme discutimos isso em Entenda o contexto e conheças as depêndencias da sua solução. Conhecer os pontos de integração do sistema, permite que você habilite em seu sistema, a capacidade de fornecer as mesmas. Então em primeiro lugar habilitaremos novas dependências ao sistema, como o starter do Spring Cloud Contract WireMock em nosso projeto, atravrés de seu starter. Também é necessário adicionar as depêndecias os starters do Spring TestContainers, JUnit Test Containers. Como nosso sistema se integrar a um PostgreSQL, também será necessário adicionar o TestContainers Postrgres.
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureWireMock(port = 0)//habilita que uma porta aletoria seja utilizada no WireMockServer
@Testcontainers
class CalculaFreteServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> container = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:latest")
);
@Autowired
private CalculaFreteService service;
@Autowired
private ProdutoRepository produtoRepository;
@Autowired
private ObjectMapper mapper;
@BeforeEach
void setUp() {
produtoRepository.deleteAll();
}
@Test
void deveCalcularOFrete() throws JsonProcessingException {
//cenario
Produto produto = new Produto(
"Joystick Xbox One", "Joystick wireless", BigDecimal.TEN,
new Caracteristica(
new BigDecimal("17.7"),
new BigDecimal("17.8"),
new BigDecimal("7.2"),
new BigDecimal("300")
)
);
produtoRepository.save(produto);
stubFor(
post(urlPathMatching("/v1/frete"))
.willReturn(
aResponse()
.withStatus(HttpStatus.CREATED.value())
.withHeader("content-type", MediaType.APPLICATION_JSON_VALUE)
.withBody(toJson(getSimulacaoDoFrete()))
)
);
//acao
Frete frete = service.calcular(new SolicitacaoCompra(List.of(produto.getId()), "32604189"));
//validacao
assertEquals(
new BigDecimal("42.35"), frete.getValor()
);
assertNotNull(frete.getEstimativaDeEntrega());
assertEquals(
"32605125", frete.getOrigem()
);
assertEquals(
"32604189", frete.getDestino()
);
}
}
O primeiro passo deste teste é habilitar o uso do ApplicationContext na execução do seu teste, através da anotação @SpringBootTest
, dado a isso, o processo de startup do sistema será iniciado, instanciando todos os beans necessários para a execução do sistema. Esta anotação faz com que durante a execução do teste uma instância do Servidor seja criada, então caso alguma configuração de beans feita programaticamente ou através de properties esteja incorreta, você será capaz de perceber já que para o ApplicationContext seja iniciado, todas BeansFactorys devem ser criadas com sucesso, caso contrario o startup será abortado.
Em seguida habilitamos que as configurações referentes ao profile de test seja utilizada, e também habilitamos a capacidade do Test de Usar WireMock Servers através das respectivas anotações: @ActiveProfiles("test")
e @AutoConfigureWireMock(port = 0)
. E para encerrar as configurações habilitamos a classe de teste a capacidade de utilizar containers docker através de TestContainers, com a anotação @TestContainers
.
Após finalizar as configurações, iremos provisionar as dependências necessárias ao nosso teste, iniciaremos provisionando um container do PostgreSQL. Utilizamos a anotação @Container
para habilitar que o TestContainers utilize seu orquestrador para instância o containers do PostgreSQL. Também adicionaremos a anotação @ServiceConnection
para crie o bean de conexão com JDBC e integre o container ao contexto durante a execução do teste. E por fim, não menos importante, fornecemos instâncias do CalculaFreteService, ObjectMapper e o ProdutoRepository.
Ainda da escrita do teste, precisamos garantir que os testes rodem de maneira independente, isto significa, que o contexto de execução de um teste, nunca deverá interferir sobre a execução de outro método de teste, então isto torna necessário que antes que cada teste execute o estado do banco seja o mesmo, justificando a utilização do método deleteAll()
do ProdutoRepository
. E então estamos prontos para implementar os nossos testes.
O caso de teste implementado visa cobrir o caminho feliz ou caminho sem erros do código, então sua responsabilidade é garantir que uma simulação de frete seja calculada. Neste momento é onde nos conectamos com as dicas I e II. Iniciamos utilizando a etapa de cenário para fornecer as ambiente necessário para execução do teste, então adicionados ao banco de dados, o registro de um produto. Em seguida provisionados um MockServer com WireMock que irá atender o contrato de resposta da REST API de Frete da Transportadora. Na etapa de ação, iremos executar uma chamada ao método calcular
do CalculaFreteService
e armazenar a resposta, para o produto e código postal de destino informado. E para finalizar iremos validar, se o valor, cep de origem e destino são iguais ao esperado, e se existe uma estimativa de entrega. Você pode ter mais detalhes do código neste repositório.
Conclusão
Trabalhar em arquiteturas altamente distribuídas é um grande desafio por diferentes motivos, como garantir que a rede estará disponível, se existe banda, ou baixa latência, e também que os sistemas presentes na arquitetura se comuniquem de forma correta.
Dado a isso, a escrita de testes para estes serviços não pode ser baseadas apenas em requisitos funcionais do sistema, mas também devem garantir que os requisitos não funcionais, como operações em rede, operações em disco, serialização e etc, sejam, executados conforme esperado. E isto exige uma boa habilidade de escrita de testes. Visando isso aprendemos que escrever testes que se aproxime a execução do software em produção, proporciona a capacidade de executar validações que só seriam feitas no, startup da aplicação ou na execução do código em produção, antecipando o encontro de bugs e anomalias.
Na primeira e segunda dica aprendemos que conhecer as dependências do código que será testado, facilita o encontro de ferramentas que auxiliem a prover as dependências necessárias para execução do sistema, durante a execução do teste. Já na terceira dica aprendemos que dividir o teste em etapas pode, lhe ajudar a planejar melhor seu teste.
Quando combinamos as dicas, criamos uma estratégia poderosa e moderna de testes, proporcionando que o software seja mantido e evoluído com segurança e qualidade software.
Top comments (0)