Pontos Principais
- Testar interações entre componentes distribuídos é difícil. Uma razão é que os stubs de testes criados para o consumidor não são testados em relação ao código do produtor;
- O teste de unidade sozinho não responde à questão de que os componentes unidos funcionam adequadamente. Os testes de integração, especialmente de comunicação entre cliente e servidor, são necessários;
- O teste de contrato envolve a definição de conversas que ocorrem entre componentes;
- O Spring Cloud Contract pode gerar stubs de teste do código do produtor e compartilhá-los com o(s) consumidor(es), que então os consente automaticamente com um "StubRunner";
- Para contratos voltados ao consumidor, o consumidor cria contratos que serão usados pelo produtor.
Imagine um desenvolvedor trabalhando em uma grande empresa. Observe o código em que esteve trabalhando nos últimos 10 anos. Ele sente orgulho disso, pois usou todos os padrões e princípios de design conhecidos para construir suas fundações. No entanto, ele não foi o único a trabalhar com a base do código. Dando um passo para trás e olhando o que foi construído. O que se observa é isso:
Depois de fazer uma auditoria interna, descobriu-se que a situação é ainda mais problemática. Existe um grande número de testes de integração de ponta-a-ponta e quase nenhum teste de unidade.
Ao longo dos anos, o processo de implantação se tornou mais complexo e agora se parece com isso:
O número de testes de ponta a ponta poderia ser limitado, mas eles capturam a maioria dos bugs dos testes de integração. Com isso, existe um problema relacionado ao fato de que não é possível detectar exceções quando a integração (HTTP ou mensagens) está com defeito.
Por que não falhamos mais rápido?
Suponhamos que temos a arquitetura a seguir:
Focando nas duas principais aplicações, de serviço Legado e de serviço de Histórico de Locação do Cliente.
Nos testes de integração do serviço legado tentamos executar um teste que enviaria uma solicitação para um stub do serviço de Histórico de Locação do Cliente. Esse esboço foi escrito manualmente como um aplicativo legado. Isso significa que ferramentas como o WireMock foi utilizada para simular uma resposta para a solicitação dada. Abaixo pode ser observado um exemplo de tal cenário:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
// start WireMock on a fixed port
@AutoConfigureWireMock(port = 6543)
public class CustomerRentalHistoryClientTests {
@Test
public void should_respond_ok_when_foo_endpoint_exists() {
// the Legacy Service is doing the stubbing so WireMock
// will act as we told it to
WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(“/foo”))
.willReturn(WireMock.aResponse().withBody(“OK”).withStatus(200)));
ResponseEntity<String> entity = new RestTemplate()
.getForEntity(“http://localhost:6543/foo“, String.class);
BDDAssertions.then(entity.getStatusCode().value()).isEqualTo(200);
BDDAssertions.then(entity.getBody()).isEqualTo(“OK”);
}
}
Então, qual o problema com este teste? O problema geralmente ocorre na produção, onde o endpoint não existe.
O que isso realmente significa? Por que o teste passa enquanto o código de produção falha?! Isso acontece devido ao fato dos stubs criados no lado do consumidor não serem testados em relação ao código do produtor.
Isso significa que existem alguns falsos positivos. Isso também significa que tempo foi perdido (portanto, dinheiro) na execução de testes de integração que não testam nada benéfico (e devem ser excluídos). O que é ainda pior, é que falhamos nos testes de ponta a ponta e precisamos gastar muito tempo para depurar o motivo dessas falhas.
Existe alguma maneira de falhar mais rápido? Talvez na máquina do desenvolvedor?
Mudando de lado
Em um pipeline de deploy, deseja-se descolar as construções com falhas o máximo possível para o outro lado. Isso significa que não desejamos esperar até o final do pipeline para verificar se existe um bug no algoritmo ou se existe uma integração incorreta. Neste caso, o objetivo é falhar na construção do aplicativo.
Para falhar rápido e começar a obter feedback imediato do aplicativo, o desenvolvimento orientado a testes e inicializa os testes unitários. Essa é a melhor maneira de começar a esboçar a arquitetura que se deseja alcançar. É possível testar as funcionalidade isoladas e obter uma resposta imediata dos fragmentos gerados. Com os testes de unidade, é muito mais facil e rapido descobrir o motivo de um bug ou um mau funcionamento específico.
Os testes unitários são suficientes? Na realidade não, já que nada funciona de forma isolada. É necessário integrar os componentes unitários testados e verificar se eles podem funcionar corretamente juntos. Um bom exemplo é afirmar se um contexto Spring pode ser iniciado corretamente e se todos os beans necessários foram registrados.
Voltando ao problema principal - testes de integração e de comunicação entre cliente e servidor. Existe a obrigatoriedade de usar stubs HTTP/mensagens escritas a mão e coordenar as alterações com os produtores? Ou existem maneiras melhores de resolver este problema? Veremos um contrato de testes e como eles podem ser úteis.
O que é um teste de contrato e como ele pode ajudar?
Suponha que antes de dois aplicativos se comunicarem, eles formalizam o modo como enviam/recebem suas mensagens. Neste ponto não estamos falando sobre esquemas. Não estamos interessados em todos os campos de solicitação/resposta possíveis e nos métodos de comunicação HTTP aceitos. O que é necessário definir são pares de conversas possíveis e reais que podem ocorrer. Essa definição é chamada de contrato. É um acordo entre o produtor da API/mensagem e o consumidor sobre como essas conversas ocorreram.
Existem inúmeras ferramentas de teste de contrato, mas as duas mais usadas são Spring Cloud Contract e Pact. Neste artigo o esforço concentrado será em fornecer explicações mais detalhadas sobre os testes de contrato por meio do Spring Cloud Contract.
No Spring Cloud Contract, um contrato pode ser definido no arquivo Groovy, YAML ou Pact. Vejamos um exemplo do seguinte contrato YAML:
description: |
Represents a scenario of sending request to /foo
request:
method: GET
url: /foo
response:
status: 200
body: “OK”
Tal contrato diz o seguinte:
- Se alguém envia uma solicitação HTTP com um método GET para a url /foo;
- Então a resposta com o status 200 e o corpo "OK" será enviado de volta.
Foi possível codificar o requisito do teste do consumidor que foi escrito contra um stub WireMock.
Armazenar apenas essas conversas não significa muito. Não há diferença em digitar isso em um arquivo de texto ou em uma página Wiki, se não for possível verificar se essa promessa é mantida em ambos os lados da comunicação. No Spring, as promessas são levadas a sério, por isso, se alguém escrever um contrato, um teste é gerado para verificar se o produtor cumpre esse contrato.
Para isso, é necessário configurar um Spring Cloud Contract Maven/Gradle Plugin no lado do produtor (aplicativo do histórico de serviço do consumidor), definir os contratos e colocá-los na estrutura de pastas adequadas. O plugin irá ler as definições do contrato e em seguida gerar os testes e os stubs do WireMock dos contratos.
É crucial lembrar que, ao contrário da abordagem anterior em que os stubs foram gerados no lado do consumidor (serviço legado), agora, os stubs e os testes serão gerados no lado do produtor (histórico de serviço do consumidor).
A figura a seguir mostra esse fluxo do ponto de vista do histórico de serviço do cliente.
Como esses testes são gerados?
public class RestTest extends RestBase {
@Test
public void validate_shouldReturnOKForFoo() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.get(“/foo”);
// then:
assertThat(response.statusCode()).isEqualTo(200);
// and:
String responseBody = response.getBody().asString();
assertThat(responseBody).isEqualTo(“OK”);
}
O Spring Cloud Contract usa uma estrutura chamada Rest Assured para enviar e receber solicitações REST de teste. O Rest Assured contém uma API que segue as boas práticas de desenvolvimento orientadas ao comportamento. O teste é descritivo e todas as entradas de solicitação e resposta definidas no contrato foram referenciadas com sucesso. Por que precisamos da classe base?
A essência dos testes de contrato não é afirmar a funcionalidade. O que queremos alcançar é a verificação da semântica. Se o produtor e o consumidor podem se comunicar com sucesso em produção.
Na classe base, podemos configurar o comportamento de simulação do serviço do aplicativo, para que eles retornem dados falsos. Imagem que existe o seguinte controlador:
@RestController
class CustomerRentalHistoryController {
private final SomeService someService;
CustomerRentalHistoryController(SomeService someService) {
this.someService = someService;
}
@GetMapping(“/foo”)
String response() {
return this.someService.callTheDatabase();
}
}
interface SomeService {
String callTheDatabase();
}
Nos testes de contrato, não se deseja chamar o banco de dados. Deseja-se que esses testes sejam rápidos e verifiquem se os dois lados podem se comunicar. Então, na classe base, seria uma afronta ao serviço de aplicativos, sendo assim:
public class BaseClass {
@Before
public void setup() {
RestAssuredMockMvc.standaloneSetup(
new CustomerRentalHistoryController(new SomeService() {
@Override public String callTheDatabase() {
return “OK”;
}
}));
}
}
Depois de configurar o plug-in e executar o teste gerado, pode-se notar que o stubs encontra-se na pasta generated-test-resources um artefato adicional com um sufixo -stubs. Esse artefato contém os contratos e os stubs. O stub é uma representação JSON padrão de um stub WireMock.
{
"id" : "63389490-864e-483c-9059-c1eba8b46b37",
"request" : {
"url" : "/foo",
"method" : "GET"
},
"response" : {
"status" : 200,
"body" : "OK",
"transformers" : [ "response-template" ]
},
"uuid" : "63389490-864e-483c-9059-c1eba8b46b37"
}
O JSON acima representa um par de solicitação e sua resposta verificada como verdadeira (devido ao fato de passar nos testes gerados). Quando executamos ./mvnw deploy ou ./gradlew, seria como publicar um .jar robusto do aplicativo junto com os stubs que seriam enviados para o Nexus/Artifactory. Dessa forma, é obtido a reutilização dos stubs fora da caixa, já que eles são gerados, confirmados e carregados apenas uma vez, depois de serem verificados contra o produtor.
É apresentado a seguir como alterar os testes do lado consumidor para reutilizar os stubs.
O Spring Cloud Contract vem com um componente chamado Stub Runner. Como o nome sugere, ele é usado para localizar e executar stubs. O Stub Runner pode buscar os stubs de vários locais, como Artifactory/Nexus, classpath, git repository ou o croker Pact. Devido a natureza plugável do Spring Cloud Contract, também é possível passar sua própria implementação. Seja qual for o armazenamento do stub escolhido, é possível alterar a maneira como os stubs são compartilhados entre os projetos. O diagrama a seguir representa uma situação em que, após passar nos testes de contrato, os stubs são carregados no armazenamento de stub para que outros projetos sejam reutilizados.
O Spring Cloud Contract não exige que utilize o Spring. Como consumidores, é possível chamar o StubRunner JUnit Rule para fazer download e iniciar stubs.
public class CustomerRentalApplicationTests {
@Rule public StubRunnerRule rule = new StubRunnerRule()
.downloadStub("com.example:customer-rental-history-service")
.withPort(6543)
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.repoRoot("https://my.nexus.com/");
@Test
public void should_return_OK_from_a_stub() {
String object = new RestTemplate()
.getForObject("http://localhost:6543/foo", String.class);
BDDAssertions.then(object).isEqualTo("OK");
}
}
Aqui é possível ver que os stubs de um aplicativo com id de grupo e com.example, e com 'artifact id' do customer-rental-history-service (histórico de serviços de locação do cliente) são obtidos a partir de uma instalação do Nexus disponível em https://my.nexus.com. Em seguida, o stub do servidor HTTP é iniciado na porta 6543 e é alimentado com os stubs baixados. Seus testes agora podem referenciar diretamente o servidor stub. O diagrama a seguir representa esse fluxo.
Qual é o resultado de tal abordagem?
- Como consumidores, a falha irá ser rápida se formos incapazes de nos comunicar com o produtor
- Como produtores, é possível ver as alterações realizadas no código que não estão quebrando os contratos que são estabelecidos com os clientes.
Essa abordagem é chamada de contrato do produtor, uma vez que o produtor define os contratos e todos os consumidores precisam seguir as diretrizes definidas nos contratos.
Há também outra maneira de trabalhar com contrato que é chamado de abordagem de contrato orientada ao consumidor. Imagine que os consumidores criem seu próprio conjunto de contratos para um determinado produtor. A estrutura de pastas definida no repositório de um produtor é a seguinte:
└── contracts
├── bar-consumer
│ ├── messaging
│ │ ├── shouldSendAcceptedVerification.yml
│ │ └── shouldSendRejectedVerification.yml
│ └── rest
│ └── shouldReturnOkForBar.yml
└── foo-consumer
├── messaging
│ ├── shouldSendAcceptedVerification.yml
│ └── shouldSendRejectedVerification.yml
└── rest
└── shouldReturnOkForFoo.yml
Suponha que essa seja a estrutura de pastas que representa os contratos que o histórico de serviços de locação do cliente precisa atender. A partir disso, sabe-se que o histórico de locação do cliente tem dois consumidores: bar-consumer e foo-consumer. Isso dá uma ideia de como a API é usada por qual consumidor. Além disso, se for realizada uma alteração significativa (por exemplo, modificação, remoção de um campo na resposta), é possível saber exatamente qual consumidor será quebrado.
Suponha que cliente foo requer que um endpoint/foo retorne o corpo "OK", enquanto o consumidor de barras requer um endpoint/bar para retornar "OK". Então o shouldReturnOkForBar.yml ficaria assim:
description: |
Represents a scenario of sending request to /bar
request:
method: GET
url: /bar
response:
status: 200
body: "OK"
Agora, presumindo que após algumas refatorações no lado do histórico de locação do cliente, é removido o /bar mapping, e os testes gerados nos informaram exatamente qual consumidor foi quebrado. Este seria o resultado da execução ./mvnw clean install
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] RestTest.validate_shouldReturnOkForBar:67 expected:<[200]> but was:<[404]>
[INFO]
[ERROR] Tests run: 11, Failures: 1, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
Do lado consumidor, é preciso configurar o Stub Runner para usar os stubs por características do cliente. Isso significa que apenas os stubs correspondentes ao consumidor determinado serão carregados. Exemplo desse teste:
@RunWith(SpringRunner.class)
//Let’s assume that the client’s name is foo-consumer
@SpringBootTest(webEnvironment = WebEnvironment.MOCK,
properties = {"spring.application.name=foo-consumer"})
//Load the stubs of com.example:customer-rental-history-service from local .m2
// and run them on a random port. Also set up the stubsPerConsumer feature
@AutoConfigureStubRunner(stubsMode = StubRunnerProperties.StubsMode.LOCAL,
ids = "com.example:customer-rental-history-service",
stubsPerConsumer = true)
public class FooControllerTest {
// Fetch the port on which customer-rental-history-service is running
@StubRunnerPort("customer-rental-history-service") int producerPort;
@Test
public void should_return_foo_for_foo_consumer() {
String response = new TestRestTemplate()
.getForObject("http://localhost:" + this.producerPort + "/foo",
String.class);
BDDAssertions.then(response).isEqualTo("OK");
}
@Test
public void should_fail_to_return_bar_for_foo_consumer() {
ResponseEntity<String> entity = new TestRestTemplate()
.getForEntity("http://localhost:" + this.producerPort + "/bar",
String.class);
BDDAssertions.then(entity.getStatusCodeValue()).isEqualTo(404);
}
}
Os contratos precisam ser armazenados sempre com o produtor? Não necessariamente. Também é possível armazenar os contratos em um único repositório. Seja qual for a escolha, o resultado é tal que é possível escrever testes que analisem esses contratos e gerar automaticamente a documentação de como sua API pode ser usada!
Além disso, tendo o relacionamento de pai-filho entre os serviços, pode facilmente esboçar um gráfico de dependências de serviços.
Tendo a seguinte estrutura de pastas:
O seguinte gráfico de dependências pode ser esboçado:
Isso é tudo o que o teste de contrato por fazer por nós?
Juntamente com os testes unitários e de integração, os testes de contrato devem ter seu lugar na pirâmide de testes.
É possível conferir no Spring Cloud Pipelines, onde é proposto a colocação de testes de contrato como uma das principais etapas dentro do pipeline de deploy (verificação de compatibilidade de API). Em nosso pipeline de deploy, também sugerimos usar o Stub Runner como um processo independente para cercar facilmente seu aplicativo com stubs.
Resumo
Através de testes de contrato, pode-se alcançar varios objetivos, como:
- Criação de uma boa API (se os consumidores estão direcionando a mudança da API, ela sabe exatamente como a API deve ser adequada às suas necessidades);
- Falha rápida quando a integração está com defeito (se não for possível enviar a solicitação que o stub entende, com certeza o aplicativo de produção também não entendera);
- Falha rápida em caso de quebra de alterações da API (os testes de contrato informaram exatamente qual alteração da API está ocorrendo);
- Reutilização e validade do Stub (stubs são publicados somente após os testes de contrato terem passado)
Vamos manter contato! Fale conosco sobre Gitter, leia a documentação e envie-nos qualquer feedback para o projeto Spring Cloud Contract.
Sobre o Autor
Marcin Grzejszczak é o autor dos livros "Mockito Instant" e Mockito Cookbook". Ele também é co-autor de Lições ao Vivo de Entrega Contínua Aplicada. Além disso, Marcin é co-fundador do Grupo de Usuários Groovy de Varsóvia e da Varsóvia Cloud Native Meetup, e lider dos projetos Spring Cloud Sleuth, Spring Cloud Contract e Spring Cloud Pipelines na Pivotal. Você pode encontrá-lo no Twitter em https://twitter.com/mgrzejszczak.