Teste de Contrato Guiado pelo Consumidor
Testar software pode ser algo bastante desafiador. Automatizar os testes, mais desafiador ainda. Automatizar testes em um mundo cada vez mais orientado a serviços, pode se transformar em uma verdadeira epopéia. Por isso, temos inúmeras estratégias para que possamos testar integração entre aplicações. Uma delas é o teste de contrato.
Mas antes de entender o teste de contrato, vamos entender o que é um contrato.
Um contrato, nada mais é que a interface pela qual vamos expor uma mensagem. Em uma arquitetura orientada a serviços, as aplicações se comunicam através de troca de mensagens. Se estivermos falando de comunicação via web, a interface poderia ser uma api REST.
A comunicação via REST é feita seguindo uma série de regras. Por exemplo, realizando uma requisição para um recurso através de uma url utilizando um método http. Passando alguns parâmetros e aguardando uma resposta com um código http específico e um json com determinados atributos. Esse seria o contrato. Se por um acaso o contrato for quebrado, a comunicação entre os serviços deixa de funcionar.
curl --location --request POST 'http://localhost:3000/customers' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "Jon Doe",
"birthDate": "2001-07-22",
"email": "jon@mail.com"
}'
Imagine que um serviço cliente, ou serviço consumidor/consumer (consumer porque ele consome a mensagem), envia uma request para um serviço provedor/provider (o que provê a mensagem ou recursos que serão consumidos) e espera como resposta um código http 200. Esse é o contrato desse endpoint. Se o time do provider alterar o código http de retorno para 201 sem avisar o time do consumer, o serviço consumer irá quebrar.
Dito isto, como garantir que a integração entre os serviços esteja sempre funcionando?
Existem várias maneiras de se fazer isso. Uma delas é criar testes de integração automatizados. O problema é que esse tipo de teste é lento, geralmente caro, complexo e sujeito à flakies. Flaky é quando os testes quebram de forma intermitente. Às vezes passam, às vezes falham. Isso pode ser causado devido um setup instável, ou aos testes de integração acessarem serviços em ambientes de stage que estão sujeitos à intermitência de rede e problemas de infra.
Outro ponto é que, devido a todos esses problemas ao se criar testes de integração, muitos times decidem mockar determinados serviços. Não há nenhum problema nisso, se fizer sentido, mas deixa de ser um teste de integração, deixando de garantir o contrato entre os serviços.
Para solucionar esses problemas, podemos usar um “sabor” de teste de contrato: o teste de contrato orientado ao consumidor.
Pact
O Pact é um framework para realizar testes de contrato consumer-driven. Ele possui bibliotecas em diversas linguagens.
https://github.com/lirantal/awesome-contract-testing
Funciona, basicamente assim:
- O contrato entre provider e consumer é chamado de pacto.
- O time responsável pelo consumer irá gerar o pacto em sua suíte de testes.
- O time responsável pelo provider irá verificar o pacto gerado pelo consumer, validando em sua base de código, cada estado gerado pelo contrato.
Dessa forma, os dois times devem trabalhar em conjunto para garantir a estabilidade do contrato. Vamos ver um exemplo na prática.
A aplicação Provider
Temos uma api provider que irá retornar uma lista de locais em Westeros.
curl --location --request GET 'localhost:9292/westeros'
A resposta irá retornar um código http 200 com o seguinte body:
[
{
place: "Ponta Tempestade",
house: "Baratheon",
},
{
place: "Rochedo Casterly",
house: "Lannister",
},
{
place: "Atalaia da água cinzenta",
house: "Reed",
},
{
place: "Pedra do Dragão",
house: "Baratheon",
},
];
O provider está escrito em ruby usando o micro framework Sinatra.
require 'sinatra/base'
module Westeros
class Api < Sinatra::Base
places = [
{
place: "Ponta Tempestade",
house: "Baratheon",
},
{
place: "Rochedo Casterly",
house: "Lannister",
},
{
place: "Atalaia da água cinzenta",
house: "Reed",
},
{
place: "Pedra do Dragão",
house: "Baratheon",
}
]
get '/westeros' do
content_type :json, charset: 'utf-8'
places.to_json
end
end
end
O teste no lado do Consumer
Imagine que temos uma aplicação que consome esse serviço. O time da aplicação consumer possui uma suíte de testes escrita em javascript. Vamos usar a biblioteca javascript do pact para gerar o pacto. Para isso, vamos escrever um teste.
import { Pact } from "@pact-foundation/pact";
import path from "path";
const axios = require("axios").default;
const MOCK_SERVER_PORT = 8080;
const EXPECTED_BODY = [
{
place: "Ponta Tempestade",
house: "Baratheon",
},
{
place: "Rochedo Casterly",
house: "Lannister",
},
{
place: "Atalaia da água cinzenta",
house: "Reed",
},
{
place: "Pedra do Dragão",
house: "Baratheon",
},
];
describe("Pact consumer", () => {
const provider = new Pact({
consumer: "WesterosConsumer",
provider: "WesterosProvider",
port: MOCK_SERVER_PORT,
});
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
test("display a list of westeros places", async () => {
await provider.addInteraction({
state: "list of places",
uponReceiving: "a request for places",
withRequest: {
method: "GET",
path: "/westeros",
},
willRespondWith: {
status: 200,
body: EXPECTED_BODY,
},
});
const response = await axios.get(
`${provider.mockService.baseUrl}/westeros`
);
expect(response.status).toBe(200);
});
});
Nosso setup de testes usa o framework de testes jest. Eis o package.json do projeto:
{
"name": "pact-consumer-test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"author": "",
"license": "ISC",
"dependencies": {
"@pact-foundation/pact": "^9.16.1",
"@babel/core": "^7.15.5",
"@babel/preset-env": "^7.15.6",
"axios": "^0.21.4",
"babel-jest": "^27.2.3",
"jest": "^27.2.3"
}
}
A lib do pact é a @pact-foundation/pact.
O teste no lado do consumer é mockado, ou seja, não irá acessar a api do provider. Nosso objetivo não é fazer um teste de integração. O pact irá se encarregar de fazer mock do provider para nós, sem a necessidade de adicionar outras bibliotecas para isso.
Vamos analisar o arquivo de teste:
- new Pact(options) cria a instância do servidor que irá mockar a api do provider.
- No método beforeAll, inicializamos o servidor de mock através do chamada provider.setup().
- provider.addInteraction(options) registra as especificações da api mockada. Essas especificações irão aparecer no arquivo do pacto.
- axios.get(
${provider.mockService.baseUrl}/westeros
) realiza a request para o endpoint mockado. - Finalmente com expect(response.status).toBe(200), testamos se o endpoint está retornando o esperado.
Quando o teste for executado, o arquivo do pacto será gerado.
O arquivo será armazenado no diretório pacts na raiz do projeto.
Agora precisamos testar o pacto contra a api do provider, mas o trabalho do consumer termina aqui.
Teste no lado do Provider
O provider é feito em ruby e é testado com o excelente framework de teste Rspec. Vamos usar a gem pact, que é a implementação do pact em ruby.
Dentro do diretório spec criamos dois arquivos. O provider_states.rb mapeia os estados do contrato. No nosso caso temos apenas um, o “list of places”. Esse estado está no arquivo json do pact gerado pelo consumer, no atributo providerState. Podemos ter inúmeros estados, mas no nosso exemplo temos apenas um.
Pact.provider_states_for 'WesterosConsumer' do
provider_state "list of places" do
no_op # If there's nothing to do because the state name is more for documentation purposes,
# you can use no_op to imply this.
end
end
O arquivo pact_helper.rb é o que vai ler o arquivo do pacto através do método pact_uri. Também é necessário mapear os nomes do provider e do consumer exatamente como estão no arquivo json do pacto.
require 'pact/provider/rspec'
require './spec/provider_states'
Pact.service_provider "WesterosProvider" do
# app { Westeros::Api.new }
honours_pact_with 'WesterosConsumer' do
# This example points to a local file, however, on a real project with a continuous
# integration box, you would use a [Pact Broker](https://github.com/pact-foundation/pact_broker) or publish your pacts as artifacts,
# and point the pact_uri to the pact published by the last successful build.
pact_uri '../pact-consumer-test/pacts/westerosconsumer-westerosprovider.json'
end
end
Lembrando que o arquivo do pacto é o mesmo gerado pelo consumer. Tanto os testes do provider, quanto do consumer devem ser executados em um ambiente de integração contínua. Então é necessário enviar o arquivo do pacto para algum repositório como o S3, por exemplo, para que o provider possa acessar o arquivo diretamente do repositório. Mas o pact possui uma solução chamada pact broker que resolve esse problema. Podemos ver sua implementação em outra ocasião.
Para rodar o teste do provider, usamos o seguinte comando: rake pact:verify.
Este teste não é mockado. Ele testa o contrato contra a base de código do provider, realizando requests reais.
Se o time do provider adicionar o teste em um servidor de ci para ser executado sempre que fizer um deploy, caso o teste venha a falhar, o time receberá o feedback imediato de que o contrato foi quebrado.
Imagine que existam 10 serviços consumidores da api do provider com contratos diferentes. Se cada consumer tiver um pacto, o teste do provider poderá testar todos os pactos, e assim, garantir que os contratos estão sendo mantidos para todos os consumidores da api.
Conclusão
O teste de contrato pode ser muito útil para garantir a estabilidade na comunicação entre serviços, sem a necessidade de escrever testes de integração. É claro que não substitui os testes e2e, mas nos ajuda a deixá-los mais flexíveis. Por exemplo, mockando apis nos testes e2e para testar o fluxo e deixando o pact se encarregar dos testes do contrato.
Reposítório do teste do consumer