Testes de contrato com Pact: validando integrações entre serviços

1. Fundamentos dos Testes de Contrato

Os testes de contrato ocupam uma posição estratégica no espectro de testes de software. Diferentemente dos testes unitários, que validam unidades isoladas de código, ou dos testes de integração, que verificam a comunicação entre componentes internos, os testes de contrato focam exclusivamente nas interfaces entre serviços. Enquanto testes E2E percorrem fluxos completos e são lentos e frágeis, os testes de contrato são rápidos, determinísticos e focados na compatibilidade da comunicação.

Em arquiteturas de microsserviços, onde dezenas ou centenas de serviços se comunicam via APIs, a principal dor é garantir que mudanças em um serviço não quebrem seus consumidores. Testes de contrato resolvem isso definindo expectativas formais — os pactos — entre quem consome (consumidor) e quem fornece (provedor) um recurso. Cada interação documentada no pacto especifica: a requisição que o consumidor fará, a resposta esperada do provedor, e os estados que o provedor deve estar para responder corretamente.

2. O Pact como Ferramenta de Testes de Contrato

Pact é a ferramenta mais difundida para testes de contrato baseados em consumidor. Criado originalmente em Ruby pela DiUS (Austrália), hoje possui implementações maduras para Java, JavaScript/TypeScript, Python, Go, .NET e outras linguagens. O diferencial do Pact é sua abordagem "consumer-driven": o consumidor define o contrato primeiro, gerando um arquivo JSON (o pacto) que descreve exatamente o que espera do provedor.

A estrutura de um pacto inclui:
- Interação: par requisição-resposta
- Matchers: regras flexíveis para campos dinâmicos (datas, UUIDs, arrays)
- Provider states: condições sob as quais o provedor deve estar para responder

3. Escrevendo Testes no Lado do Consumidor

Vamos a um exemplo prático com JavaScript/TypeScript usando a biblioteca @pact-foundation/pact.

// consumer/order-service.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { OrderService } from './order-service';

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'StockService',
});

describe('OrderService - consulta estoque', () => {
  it('deve retornar quantidade disponível para um produto', async () => {
    // Define a interação esperada
    provider
      .given('produto com id 123 existe no estoque')
      .uponReceiving('uma requisição de consulta de estoque')
      .withRequest({
        method: 'GET',
        path: '/stock/123',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          productId: MatchersV3.string('123'),
          quantity: MatchersV3.integer(10),
          lastUpdated: MatchersV3.isoDate(),
        },
      });

    // Executa o teste contra o mock do provedor
    await provider.executeTest(async (mockServer) => {
      const client = new OrderService(mockServer.url);
      const result = await client.checkStock('123');
      expect(result.quantity).toBeGreaterThanOrEqual(0);
    });
  });
});

Ao executar o teste, o Pact gera automaticamente um arquivo pacts/OrderService-StockService.json com o contrato. Esse arquivo deve ser versionado e compartilhado com a equipe do provedor.

4. Verificando o Contrato no Lado do Provedor

No provedor, utilizamos o Pact para verificar se o serviço real atende ao contrato definido pelo consumidor.

// provider/stock-service-verification.test.ts
import { Verifier } from '@pact-foundation/pact';
import { startServer, stopServer } from './server';

describe('Verificação do contrato StockService', () => {
  let server;

  beforeAll(async () => {
    server = await startServer(3001);
  });

  afterAll(async () => {
    await stopServer(server);
  });

  it('deve validar os pactos dos consumidores', async () => {
    const verifier = new Verifier({
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: ['./pacts/OrderService-StockService.json'],
      stateHandlers: {
        'produto com id 123 existe no estoque': async () => {
          // Prepara o estado no banco de dados
          await seedProduct({ id: '123', quantity: 10 });
        },
      },
    });

    await verifier.verifyProvider();
  });
});

Os estados do provedor (provider states) são hooks que preparam dados específicos antes de cada verificação. Para provedores com múltiplos consumidores, basta adicionar mais arquivos de pacto ou apontar para um broker.

5. Integração Contínua e Ciclo de Vida dos Pactos

O Pact Broker é o componente central para gerenciar contratos em escala. Ele armazena versões de pactos, relaciona consumidores com provedores e permite visualizar dependências entre serviços.

Em um pipeline de CI típico:

# Pipeline de CI - Consumidor
1. Executa testes do consumidor → gera pacto
2. Publica pacto no broker (com versão do consumidor)
3. Se for merge na main, marca como "production-ready"

# Pipeline de CI - Provedor
1. Verifica todos os pactos publicados contra o provedor
2. Se houver falha, o deploy é bloqueado
3. Se passar, publica verificação no broker

Estratégias avançadas incluem canary releases onde apenas uma fração do tráfego vai para a nova versão, e rollback automático se o broker detectar incompatibilidade nos contratos.

6. Padrões Avançados e Desafios Práticos

Matchers flexíveis são essenciais para lidar com dados dinâmicos:

body: {
  id: MatchersV3.uuid(),
  createdAt: MatchersV3.isoDate(),
  metadata: MatchersV3.eachLike({
    key: MatchersV3.string('default'),
    value: MatchersV3.string('value'),
  }),
  optionalField: MatchersV3.boolean(true),
}

Para provedores assíncronos (mensageria, filas), o Pact oferece suporte através de mensagens Pact (Pact for Message Queues). O consumidor define a mensagem esperada e o provedor verifica se consegue produzi-la.

Limitações importantes do Pact:
- Não substitui testes de performance ou segurança
- Para APIs públicas (muitos consumidores desconhecidos), OpenAPI/Swagger pode ser mais adequado
- gRPC tem seu próprio ecossistema de testes de contrato (protobuf)

7. Exemplo Prático: Validação de uma API REST entre Serviços

Cenário: Serviço de Pedidos (consumidor) consulta Serviço de Estoque (provedor) para verificar disponibilidade antes de criar um pedido.

Teste do consumidor (já apresentado na seção 3) gera o pacto:

{
  "consumer": { "name": "OrderService" },
  "provider": { "name": "StockService" },
  "interactions": [{
    "description": "uma requisição de consulta de estoque",
    "providerState": "produto com id 123 existe no estoque",
    "request": {
      "method": "GET",
      "path": "/stock/123",
      "headers": { "Accept": "application/json" }
    },
    "response": {
      "status": 200,
      "headers": { "Content-Type": "application/json" },
      "body": {
        "productId": "123",
        "quantity": 10,
        "lastUpdated": "2024-01-15"
      }
    }
  }],
  "metadata": {
    "pactSpecification": { "version": "3.0.0" }
  }
}

Verificação no provedor: O serviço de estoque executa a verificação, que falha se, por exemplo, o campo lastUpdated mudar de formato ou quantity passar a ser string.

Quando uma quebra é detectada, o time do provedor pode:
1. Corrigir o provedor para manter compatibilidade
2. Negociar com o consumidor uma nova versão do contrato
3. Usar versionamento de API (ex: /v2/stock/123)

O fluxo completo garante que integrações entre serviços evoluam com segurança, sem surpresas em produção.

Referências