No desenvolvimento de software moderno, testes eficazes desempenham um papel fundamental para garantir a confiabilidade e a estabilidade dos aplicativos.
Este artigo oferece recomendações práticas para escrever testes de integração, demonstrando como focar nas especificações das interações com serviços externos, tornando os testes mais legíveis e fáceis de manter. A abordagem não só aumenta a eficiência dos testes, mas também promove uma melhor compreensão dos processos de integração dentro da aplicação. Através das lentes de exemplos específicos, diversas estratégias e ferramentas - como DSL wrappers, JsonAssert e Pact - serão exploradas, oferecendo ao leitor um guia completo para melhorar a qualidade e a visibilidade dos testes de integração.
O artigo apresenta exemplos de testes de integração realizados utilizando o Spock Framework no Groovy para testar interações HTTP em aplicações Spring. Ao mesmo tempo, as principais técnicas e abordagens sugeridas podem ser efetivamente aplicadas a vários tipos de interações além do HTTP.
O artigo Writing Effective Integration Tests in Spring: Organized Testing Strategies for HTTP Request Mocking descreve uma abordagem para escrever testes com uma separação clara em estágios distintos, cada um desempenhando sua função específica. Vamos descrever um exemplo de teste de acordo com essas recomendações, mas zombando não de uma, mas de duas solicitações. A etapa Act (Execução) será omitida por questões de brevidade (um exemplo de teste completo pode ser encontrado no repositório do projeto ).
O código apresentado é condicionalmente dividido em partes: “Código de Suporte” (colorido em cinza) e “Especificação de Interações Externas” (colorido em azul). O Código de Suporte inclui mecanismos e utilitários para testes, incluindo interceptação de solicitações e emulação de respostas. A Especificação de Interações Externas descreve dados específicos sobre serviços externos com os quais o sistema deve interagir durante o teste, incluindo solicitações e respostas esperadas. O Código de Suporte estabelece a base para o teste, enquanto a Especificação está diretamente relacionada à lógica de negócios e às principais funções do sistema que estamos tentando testar.
A Especificação ocupa uma parte menor do código, mas representa um valor significativo para a compreensão do teste, enquanto o Código de Suporte, ocupando uma parte maior, apresenta menos valor e é repetitivo para cada declaração simulada. O código deve ser usado com MockRestServiceServer. Referindo-se ao exemplo do WireMock , pode-se ver o mesmo padrão: a especificação é quase idêntica e o código de suporte varia.
O objetivo deste artigo é oferecer recomendações práticas para escrever testes de forma que o foco esteja na especificação e o Código de Apoio fique em segundo plano.
Para nosso cenário de teste, proponho um hipotético bot do Telegram que encaminha solicitações para a API OpenAI e envia respostas de volta aos usuários.
Os contratos de interação com os serviços são descritos de forma simplificada para evidenciar a lógica principal da operação. Abaixo está um diagrama de sequência que demonstra a arquitetura do aplicativo. Entendo que o design possa levantar questões do ponto de vista da arquitetura de sistemas, mas aborde isso com compreensão – o objetivo principal aqui é demonstrar uma abordagem para aumentar a visibilidade nos testes.
Este artigo discute as seguintes recomendações práticas para escrever testes:
O uso de um wrapper DSL permite ocultar o código simulado clichê e fornece uma interface simples para trabalhar com a especificação. É importante enfatizar que o que é proposto não é uma DSL específica, mas uma abordagem geral que ela implementa. Um exemplo de teste corrigido usando DSL é apresentado abaixo ( texto de teste completo ).
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess("{...}")) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
Onde o método restExpectation.openai.completions
, por exemplo, é descrito a seguir:
public interface OpenaiMock { /** * This method configures the mock request to the following URL: {@code https://api.openai.com/v1/chat/completions} */ RequestCaptor completions(DefaultResponseCreator responseCreator); }
Ter um comentário sobre o método permite, ao passar o mouse sobre o nome do método no editor de código, obter ajuda, inclusive ver a URL que será ridicularizada.
Na implementação proposta, a declaração da resposta do mock é feita através de instâncias ResponseCreator
, permitindo customizações, como:
public static ResponseCreator withResourceAccessException() { return (request) -> { throw new ResourceAccessException("Error"); }; }
Um exemplo de teste para cenários malsucedidos especificando um conjunto de respostas é mostrado abaixo:
import static org.springframework.http.HttpStatus.FORBIDDEN setup: def openaiRequestCaptor = restExpectation.openai.completions(openaiResponse) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 0 where: openaiResponse | _ withResourceAccessException() | _ withStatus(FORBIDDEN) | _
Para WireMock, tudo é igual, exceto que a formação da resposta é um pouco diferente ( código de teste , código de classe de fábrica de resposta ).
Ao implementar uma DSL, é possível anotar parâmetros de método com @Language("JSON")
para habilitar o suporte a recursos de linguagem para trechos de código específicos no IntelliJ IDEA. Com JSON, por exemplo, o editor tratará o parâmetro string como código JSON, habilitando recursos como destaque de sintaxe, preenchimento automático, verificação de erros, navegação e pesquisa de estrutura. Aqui está um exemplo do uso da anotação:
public static DefaultResponseCreator withSuccess(@Language("JSON") String body) { return MockRestResponseCreators.withSuccess(body, APPLICATION_JSON); }
Veja como fica no editor:
A biblioteca JSONAssert foi projetada para simplificar o teste de estruturas JSON. Ele permite que os desenvolvedores comparem facilmente strings JSON esperadas e reais com um alto grau de flexibilidade, suportando vários modos de comparação.
Isso permite passar de uma descrição de verificação como esta
openaiRequestCaptor.body.model == "gpt-3.5-turbo" openaiRequestCaptor.body.messages.size() == 1 openaiRequestCaptor.body.messages[0].role == "user" openaiRequestCaptor.body.messages[0].content == "Hello!"
para algo assim
assertEquals("""{ "model": "gpt-3.5-turbo", "messages": [{ "role": "user", "content": "Hello!" }] }""", openaiRequestCaptor.bodyString, false)
Na minha opinião, a principal vantagem da segunda abordagem é que ela garante a consistência da representação de dados em vários contextos – em documentação, logs e testes. Isso simplifica significativamente o processo de teste, proporcionando flexibilidade na comparação e precisão no diagnóstico de erros. Assim, não só economizamos tempo na escrita e manutenção de testes, mas também melhoramos sua legibilidade e informatividade.
Ao trabalhar no Spring Boot, a partir de pelo menos a versão 2, nenhuma dependência adicional é necessária para trabalhar com a biblioteca, pois org.springframework.boot:spring-boot-starter-test
já inclui uma dependência em org.skyscreamer:jsonassert
.
Uma observação que podemos fazer é que as strings JSON ocupam uma parte significativa do teste. Eles deveriam ser escondidos? Sim e não. É importante entender o que traz mais benefícios. Ocultá-los torna os testes mais compactos e simplifica a compreensão da essência do teste à primeira vista. Por outro lado, para uma análise minuciosa, parte da informação crucial sobre a especificação da interação externa será ocultada, exigindo saltos extras entre arquivos. A decisão depende da comodidade: faça o que for mais confortável para você.
Se você optar por armazenar strings JSON em arquivos, uma opção simples é manter as respostas e solicitações separadamente em arquivos JSON. Abaixo está um código de teste ( versão completa ) demonstrando uma opção de implementação:
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess(fromFile("json/openai/response.json"))) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
O método fromFile
simplesmente lê uma string de um arquivo no diretório src/test/resources
e não carrega nenhuma ideia revolucionária, mas ainda está disponível no repositório do projeto para referência.
Para a parte variável da string, sugere-se usar substituição com org.apache.commons.text.StringSubstitutor e passar um conjunto de valores ao descrever o mock, por exemplo:
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess(fromFile("json/openai/response.json", [content: "Hello! How can I assist you today?"])))
Onde a parte com substituição no arquivo JSON fica assim:
... "message": { "role": "assistant", "content": "${content:-Hello there, how may I assist you today?}" }, ...
O único desafio para os desenvolvedores ao adotar a abordagem de armazenamento de arquivos é desenvolver um esquema adequado de posicionamento de arquivos em recursos de teste e um esquema de nomenclatura. É fácil cometer erros que podem piorar a experiência de trabalhar com esses arquivos. Uma solução para esse problema poderia ser a utilização de especificações, como as do Pact, que serão discutidas posteriormente.
Ao usar a abordagem descrita em testes escritos em Groovy, você pode encontrar inconvenientes: não há suporte no IntelliJ IDEA para navegar até o arquivo a partir do código, mas espera-se que o suporte para essa funcionalidade seja adicionado no futuro . Em testes escritos em Java, isso funciona muito bem.
Vamos começar com a terminologia.
O teste de contrato é um método de testar pontos de integração onde cada aplicativo é testado isoladamente para confirmar se as mensagens que ele envia ou recebe estão em conformidade com um entendimento mútuo documentado em um “contrato”. Essa abordagem garante que as interações entre as diferentes partes do sistema atendam às expectativas.
Um contrato no contexto de teste de contrato é um documento ou especificação que registra um acordo sobre o formato e a estrutura das mensagens (solicitações e respostas) trocadas entre aplicações. Serve como base para verificar se cada aplicação consegue processar corretamente os dados enviados e recebidos por outras pessoas na integração.
O contrato é estabelecido entre um consumidor (por exemplo, um cliente que deseja recuperar alguns dados) e um fornecedor (por exemplo, uma API num servidor que fornece os dados necessários ao cliente).
O teste orientado ao consumidor é uma abordagem de teste de contrato em que os consumidores geram contratos durante a execução de testes automatizados. Esses contratos são repassados ao provedor, que então executa seu conjunto de testes automatizados. Cada solicitação contida no arquivo do contrato é enviada ao provedor e a resposta recebida é comparada com a resposta esperada especificada no arquivo do contrato. Se ambas as respostas corresponderem, significa que o consumidor e o prestador de serviços são compatíveis.
Finalmente, Pacto. Pact é uma ferramenta que implementa as ideias de testes de contratos orientados ao consumidor. Ele suporta testes de integrações HTTP e integrações baseadas em mensagens, com foco no desenvolvimento de testes que priorizam o código.
Como mencionei anteriormente, podemos usar as especificações e ferramentas do contrato do Pacto para nossa tarefa. A implementação pode ser assim ( código de teste completo ):
setup: def openaiRequestCaptor = restExpectation.openai.completions(fromContract("openai/SuccessfulCompletion-Hello.json")) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
O arquivo do contrato está disponível para revisão .
A vantagem de usar arquivos de contrato é que eles contêm não apenas o corpo da solicitação e da resposta, mas também outros elementos da especificação de interações externas – caminho da solicitação, cabeçalhos e status da resposta HTTP, permitindo que uma simulação seja totalmente descrita com base em tal contrato.
É importante observar que, neste caso, nos limitamos aos testes por contrato e não nos estendemos aos testes conduzidos pelo consumidor. No entanto, alguém pode querer explorar mais o Pacto.
Este artigo revisou recomendações práticas para melhorar a visibilidade e a eficiência dos testes de integração no contexto do desenvolvimento com o Spring Framework. Meu objetivo era focar na importância de definir claramente as especificações das interações externas e minimizar o código padrão. Para atingir esse objetivo, sugeri usar wrappers DSL e JsonAssert, armazenar especificações em arquivos JSON e trabalhar com contratos através do Pact. As abordagens descritas no artigo visam simplificar o processo de escrita e manutenção de testes, melhorar sua legibilidade e, o mais importante, melhorar a qualidade do próprio teste, refletindo com precisão as interações entre os componentes do sistema.
Link para o repositório do projeto demonstrando os testes - sandbox/bot .
Obrigado por sua atenção ao artigo e boa sorte em sua busca por escrever testes eficazes e visíveis!