Estratégias de testes de contrato entre serviços com Pact

1. Fundamentos do Teste de Contrato com Pact

Testes de contrato são uma abordagem de teste que valida a compatibilidade das comunicações entre serviços, focando exclusivamente nas trocas de mensagens (requests/responses) entre consumidor e provedor. Diferentemente de testes end-to-end, que exigem todos os serviços em pé, e de testes de integração, que testam componentes internos, os testes de contrato com Pact operam em um nível mais granular: cada serviço é testado isoladamente, mas as expectativas de comunicação são compartilhadas através de contratos formais.

Os conceitos-chave são:
- Consumidor (Consumer): serviço que faz requisições a outro serviço.
- Provedor (Provider): serviço que responde às requisições.
- Pact: arquivo JSON que descreve as interações esperadas entre consumidor e provedor.

O ciclo de vida do contrato segue três etapas:
1. Geração: o consumidor define as interações esperadas e gera um Pact.
2. Verificação: o provedor reproduz as interações descritas no Pact e valida se consegue atendê-las.
3. Versionamento: os Pacts são armazenados e versionados no Pact Broker, permitindo rastreamento de mudanças.

2. Configuração Inicial do Ambiente Pact

A configuração do ambiente Pact depende da linguagem do projeto. Usaremos Python como exemplo, mas os conceitos se aplicam a outras linguagens (JS, Java, .NET, etc.).

Instalação da biblioteca Pact Python

pip install pact-python

Estrutura de projeto sugerida

meu-projeto/
├── consumer/
│   ├── tests/
│   │   └── test_consumer.py
│   └── consumer_service.py
├── provider/
│   ├── tests/
│   │   └── test_provider.py
│   └── provider_service.py
└── pact_broker/
    └── docker-compose.yml

Uso do Pact Broker com Docker

# docker-compose.yml
version: '3'
services:
  postgres:
    image: postgres
    environment:
      POSTGRES_USER: pact
      POSTGRES_PASSWORD: pact
  broker:
    image: pactfoundation/pact-broker
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgres://pact:pact@postgres/pact

3. Escrevendo Testes de Consumidor com Pact

No teste de consumidor, definimos as interações esperadas com o provedor. Utilizamos matchers para tornar os contratos flexíveis, evitando fragilidade.

Exemplo de teste de consumidor

# consumer/tests/test_consumer.py
import unittest
from pact import Consumer, Provider

pact = Consumer('UserServiceConsumer').has_pact_with(
    Provider('UserServiceProvider'),
    pact_dir='./pacts'
)

class TestUserConsumer(unittest.TestCase):
    def test_get_user(self):
        # Define a interação esperada
        (pact
         .given('user exists')
         .upon_receiving('a request for user 1')
         .with_request('GET', '/users/1')
         .will_respond_with(200, body={
             'id': 1,
             'name': 'Alice',
             'email': 'alice@example.com'
         }))

        # Executa o mock do provedor
        with pact:
            from consumer_service import get_user
            result = get_user(1)
            self.assertEqual(result['name'], 'Alice')

Matchers para evitar fragilidade

from pact.matchers import like, term

(consumer
 .given('user exists')
 .upon_receiving('a request for user 1')
 .with_request('GET', '/users/1')
 .will_respond_with(200, body={
     'id': like(1),
     'name': like('Alice'),
     'email': term('alice@example.com', '^[a-z]+@[a-z]+\.[a-z]+$')
 }))

4. Verificação de Contratos no Provedor

No provedor, configuramos a verificação para garantir que as interações definidas pelo consumidor são suportadas.

Exemplo de verificação no provedor

# provider/tests/test_provider.py
from pact import Verifier

class TestUserProvider(unittest.TestCase):
    def test_verify_pact(self):
        verifier = Verifier(provider='UserServiceProvider')
        verifier.verify_pacts(
            './pacts/UserServiceConsumer-UserServiceProvider.json',
            provider_base_url='http://localhost:5000',
            provider_states_url='http://localhost:5000/provider-states'
        )

Estados do provedor (provider states)

Os estados permitem simular diferentes cenários de dados no provedor.

# provider/provider_service.py
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/provider-states', methods=['POST'])
def set_state():
    state = request.json['state']
    if state == 'user exists':
        # Configura o banco de dados para ter o usuário 1
        setup_user(1, 'Alice', 'alice@example.com')
    return jsonify({'result': 'ok'})

@app.route('/users/<id>')
def get_user(id):
    user = find_user_by_id(id)
    return jsonify(user)

5. Estratégias de Versionamento e Evolução de Contratos

Gerenciamento de versões no Pact Broker

# Publicar pact no broker
pact-broker publish ./pacts/UserServiceConsumer-UserServiceProvider.json \
  --broker-base-url http://localhost:9292 \
  --consumer-app-version 1.0.0 \
  --tag prod

Estratégias para breaking changes

  1. Adicionar novos campos: usar matchers like() para campos opcionais.
  2. Remover campos: criar uma nova versão do contrato e coordenar a migração.
  3. Alterar tipos: usar term() para validar o formato, não o tipo exato.

Uso de tags e branches

# Tag para ambiente de desenvolvimento
pact-broker publish pact.json --consumer-app-version 1.1.0 --tag dev

# Tag para produção
pact-broker publish pact.json --consumer-app-version 1.1.0 --tag prod

6. Integração com Pipeline de CI/CD

Automação no GitHub Actions

# .github/workflows/pact.yml
name: Pact Tests

on: [push]

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run consumer tests
        run: |
          cd consumer
          pip install -r requirements.txt
          python -m unittest tests/test_consumer.py
      - name: Publish pacts
        run: |
          pact-broker publish consumer/pacts/*.json \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
            --consumer-app-version ${{ github.sha }} \
            --tag ${{ github.ref_name }}

  provider-verification:
    needs: consumer-tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Start provider
        run: |
          cd provider
          pip install -r requirements.txt
          python provider_service.py &
      - name: Verify pacts
        run: |
          pact-broker can-i-deploy \
            --pacticipant UserServiceProvider \
            --version ${{ github.sha }} \
            --to-environment production \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }}

Bloqueio de deploys com can-i-deploy

# Comando para verificar compatibilidade antes do deploy
pact-broker can-i-deploy \
  --pacticipant UserServiceProvider \
  --version 1.0.0 \
  --to-environment production \
  --broker-base-url http://localhost:9292

7. Boas Práticas e Armadilhas Comuns

Quando o teste de contrato é suficiente

Testes de contrato são ideais para validar a compatibilidade de APIs entre serviços que se comunicam via HTTP, mensageria ou gRPC. Eles são suficientes quando:
- As interações são bem definidas e estáveis.
- Há pouca variação de estado entre ambientes.
- O foco é garantir que mudanças em um serviço não quebrem outros.

Quando complementar com testes end-to-end

Testes end-to-end são necessários quando:
- Há fluxos que envolvem múltiplos serviços encadeados.
- É preciso validar comportamento de timeout, retry e circuit breaker.
- O ambiente de produção tem configurações específicas (load balancers, autenticação).

Evitando acoplamento excessivo com matchers genéricos

# Ruim: matcher muito específico
body={'id': 1, 'name': 'Alice'}

# Bom: matcher flexível
body={'id': like(1), 'name': like('Alice')}

Monitoramento de contratos obsoletos

# Listar pacts não verificados há mais de 30 dias
pact-broker list-pacts --broker-base-url http://localhost:9292 \
  --query 'verificationDate < now - 30 days'

Referências