Contract Testing com TypeScript: Pact e Verificação de APIs: Do Básico ao Avançado Já leu

O que é Contract Testing? Contract Testing é uma abordagem de testes que valida a comunicação entre dois serviços sem necessidade de integrá-los completamente. Diferente dos testes de integração tradicionais, que dependem de ambos os serviços rodando em sincronismo, o contract testing estabelece um "contrato" — uma especificação clara do que cada serviço espera receber e o que promete devolver. O contrato é como um acordo legal: se o consumidor (cliente) espera um JSON com campos específicos e o produtor (servidor) promete fornecer exatamente isso, ambos devem honrar esse compromisso. Pact é a ferramenta que nos permite definir, registrar e verificar esses contratos de forma automatizada. Isso reduz drasticamente o acoplamento entre serviços e permite que equipes trabalhem independentemente, sabendo que seus contratos serão respeitados. Por que não usar testes de integração clássicos? Testes de integração tradicionais requerem que ambos os serviços estejam disponíveis e funcionando corretamente durante a execução. Isso gera problemas: lentidão nos pipelines, dependência de infraestrutura complexa,

O que é Contract Testing?

Contract Testing é uma abordagem de testes que valida a comunicação entre dois serviços sem necessidade de integrá-los completamente. Diferente dos testes de integração tradicionais, que dependem de ambos os serviços rodando em sincronismo, o contract testing estabelece um "contrato" — uma especificação clara do que cada serviço espera receber e o que promete devolver.

O contrato é como um acordo legal: se o consumidor (cliente) espera um JSON com campos específicos e o produtor (servidor) promete fornecer exatamente isso, ambos devem honrar esse compromisso. Pact é a ferramenta que nos permite definir, registrar e verificar esses contratos de forma automatizada. Isso reduz drasticamente o acoplamento entre serviços e permite que equipes trabalhem independentemente, sabendo que seus contratos serão respeitados.

Por que não usar testes de integração clássicos?

Testes de integração tradicionais requerem que ambos os serviços estejam disponíveis e funcionando corretamente durante a execução. Isso gera problemas: lentidão nos pipelines, dependência de infraestrutura complexa, dificuldade em isolar falhas e impossibilidade de testar serviços que ainda não foram desenvolvidos. Contract testing quebra esse problema: você simula o comportamento esperado e verifica se ambos os lados respeitam o contrato.

Introdução ao Pact e sua arquitetura

Pact é uma ferramenta open-source que implementa contract testing através de interações gravadas entre consumidor e provedor. A ideia central é simples: o consumidor define o que espera receber do provedor, e o provedor verifica se realmente fornece isso. Essas expectativas são gravadas em um arquivo JSON chamado "pact file", que serve como fonte da verdade.

A arquitetura do Pact funciona em duas fases: fase de testes no consumidor e fase de verificação no provedor. Durante a fase do consumidor, você define o comportamento esperado das chamadas HTTP. O Pact coloca um servidor mock no lugar do serviço real, registra as interações e as salva em um arquivo. Depois, durante a fase do provedor, esse arquivo é executado contra o serviço real para garantir que ele honra o contrato.

Instalação e configuração inicial

Para começar com Pact em TypeScript, você precisa instalar as dependências corretas. O ecossistema Pact é rico, mas para TypeScript usaremos @pact-foundation/pact, que é mantido pela equipe oficial.

npm install --save-dev @pact-foundation/pact jest @types/jest ts-jest
npm install --save-dev typescript

Crie um arquivo jest.config.js para configurar o TypeScript:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts'],
  moduleFileExtensions: ['ts', 'js']
};

E um tsconfig.json básico:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Testando o Consumidor com Pact

Na perspectiva do consumidor, você é o código cliente que depende de um serviço externo. Seu trabalho é definir exatamente o que você espera desse serviço: quais endpoints você chamará, que métodos HTTP usará, que corpo você enviará e que respostas espera receber.

Estruturando um teste de consumidor

Vamos criar um exemplo prático: imagine um serviço de usuários que precisa consultar dados em um serviço de perfil. O consumidor fará requisições GET para /api/users/{id} e espera um JSON com nome, email e idade.

import { Pact, Interaction } from '@pact-foundation/pact';
import axios from 'axios';

// Classe que representa o cliente do serviço de perfil
class UserProfileClient {
  constructor(private baseUrl: string) {}

  async getUserProfile(userId: number) {
    const response = await axios.get(`${this.baseUrl}/api/users/${userId}`);
    return response.data;
  }
}

describe('UserProfileClient - Consumer Tests', () => {
  const provider = new Pact({
    consumer: 'UserService',
    provider: 'ProfileService',
    port: 8081,
    logLevel: 'warn'
  });

  beforeAll(() => provider.setup());

  afterEach(() => provider.verify());

  afterAll(() => provider.finalize());

  it('should retrieve user profile by ID', async () => {
    // Define a interação esperada
    await provider.addInteraction({
      state: 'user 123 exists',
      uponReceiving: 'a request for user profile',
      withRequest: {
        method: 'GET',
        path: '/api/users/123'
      },
      willRespondWith: {
        status: 200,
        body: {
          id: 123,
          name: 'João Silva',
          email: 'joao@example.com',
          age: 28
        }
      }
    });

    // Instancia o cliente apontando para o mock do Pact
    const client = new UserProfileClient('http://localhost:8081');

    // Faz a chamada
    const profile = await client.getUserProfile(123);

    // Valida a resposta
    expect(profile).toEqual({
      id: 123,
      name: 'João Silva',
      email: 'joao@example.com',
      age: 28
    });
  });

  it('should handle user not found', async () => {
    await provider.addInteraction({
      state: 'user 999 does not exist',
      uponReceiving: 'a request for non-existent user',
      withRequest: {
        method: 'GET',
        path: '/api/users/999'
      },
      willRespondWith: {
        status: 404,
        body: {
          error: 'User not found'
        }
      }
    });

    const client = new UserProfileClient('http://localhost:8081');

    try {
      await client.getUserProfile(999);
      fail('Should have thrown an error');
    } catch (error: any) {
      expect(error.response.status).toBe(404);
      expect(error.response.data.error).toBe('User not found');
    }
  });
});

Entendendo as interações

Cada interação no Pact possui quatro componentes essenciais: um estado (state), uma descrição do que está sendo requisitado, a requisição esperada e a resposta esperada. O estado é importante porque permite testar diferentes cenários — quando o usuário existe, quando não existe, quando há erro no servidor, etc.

A requisição define o método HTTP, o caminho, headers e body (se aplicável). A resposta define o código de status e o corpo da resposta. O Pact grava tudo isso e cria um arquivo chamado pact/UserService-ProfileService.json que será usado posteriormente para validar o servidor.

Verificando o Contrato no Provedor

Depois que os testes do consumidor rodam com sucesso, você tem um arquivo pact que descreve o contrato. Agora é hora de verificar se o provedor (o servidor real) honra esse contrato. Isso é feito através de "testes de verificação" que executam cada interação contra a API real.

Implementando o provedor

Primeiro, vamos criar um servidor Express simples que simula o serviço de perfil:

import express, { Express } from 'express';

export function createProfileServer(): Express {
  const app = express();
  app.use(express.json());

  // Mock database
  const users = {
    123: { id: 123, name: 'João Silva', email: 'joao@example.com', age: 28 },
    456: { id: 456, name: 'Maria Santos', email: 'maria@example.com', age: 32 }
  };

  app.get('/api/users/:id', (req, res) => {
    const userId = parseInt(req.params.id);
    const user = users[userId as keyof typeof users];

    if (user) {
      res.json(user);
    } else {
      res.status(404).json({ error: 'User not found' });
    }
  });

  return app;
}

Escrevendo testes de verificação

Os testes de verificação são diferentes: você não define interações, apenas verifica se o provedor cumpre as definidas pelo consumidor. O Pact lê o arquivo gerado anteriormente e executa cada interação contra seu servidor real.

import { Verifier } from '@pact-foundation/pact';
import { createProfileServer } from '../server';

describe('ProfileService - Provider Verification', () => {
  let server: any;
  const port = 3000;

  beforeAll(async () => {
    const app = createProfileServer();
    server = app.listen(port);
  });

  afterAll(() => {
    server.close();
  });

  it('validates the pact with the consumer', async () => {
    const verifier = new Verifier({
      providerBaseUrl: `http://localhost:${port}`,
      pactFiles: ['./pact/UserService-ProfileService.json'],
      providerVersion: '1.0.0'
    });

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

Quando este teste rodas, o Pact automaticamente:
1. Lê o arquivo pact gerado pelo consumidor
2. Extrai cada interação (requisição esperada)
3. Faz a requisição contra seu servidor real
4. Compara a resposta real com a resposta esperada
5. Relata se há divergências

Se o servidor responder diferente do esperado, o teste falha imediatamente, alertando que o contrato foi quebrado.

Gerenciando estados do provedor

Em cenários mais complexos, você pode precisar preparar o provedor em diferentes estados. O Pact suporta "provider states" — funções que preparam o banco de dados ou o estado da aplicação antes de executar cada interação.

import { Verifier } from '@pact-foundation/pact';

describe('ProfileService - Provider Verification com States', () => {
  let server: any;
  const port = 3000;

  beforeAll(async () => {
    const app = createProfileServer();
    server = app.listen(port);
  });

  afterAll(() => {
    server.close();
  });

  it('validates pact with provider states', async () => {
    const verifier = new Verifier({
      providerBaseUrl: `http://localhost:${port}`,
      pactFiles: ['./pact/UserService-ProfileService.json'],
      providerVersion: '1.0.0',
      stateHandlers: {
        'user 123 exists': async () => {
          // Garante que o usuário 123 existe no banco
          console.log('Preparando estado: user 123 exists');
        },
        'user 999 does not exist': async () => {
          // Garante que o usuário 999 não existe
          console.log('Preparando estado: user 999 does not exist');
        }
      }
    });

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

Fluxo de Trabalho Completo e Boas Práticas

Um fluxo de contract testing maduro envolve múltiplos passos e considerações importantes. Não é apenas escrever testes — é sobre estabelecer uma cultura de contratos e verificação contínua.

Estrutura de diretórios recomendada

projeto/
├── src/
│   ├── clients/
│   │   └── profileClient.ts
│   ├── server.ts
│   └── services/
├── __tests__/
│   ├── consumer/
│   │   └── profileClient.test.ts
│   └── provider/
│       └── profileService.test.ts
├── pact/
│   └── UserService-ProfileService.json
└── jest.config.js

Executando e publicando contratos

Depois que seus testes rodam, você provavelmente quer compartilhar os contratos com a equipe do provedor. Pact oferece um "Broker" — um repositório central onde contratos podem ser publicados e acessados. Isso é especialmente útil em arquiteturas de microsserviços com múltiplas equipes.

# Publicar contrato no Pact Broker
npm run test:consumer
npx pact publish ./pact \
  --consumer-app-version=1.0.0 \
  --broker-url=https://seu-broker.pact.sh \
  --broker-token=seu-token

Validações importantes no contrato

Um contrato bem escrito é específico, mas não frágil. Evite capturar detalhes que mudarão frequentemente. Use matchers do Pact para definir padrões em vez de valores exatos quando apropriado:

import { Matchers } from '@pact-foundation/pact';

await provider.addInteraction({
  state: 'user 123 exists',
  uponReceiving: 'a request for user profile',
  withRequest: {
    method: 'GET',
    path: '/api/users/123'
  },
  willRespondWith: {
    status: 200,
    body: {
      id: Matchers.number(123),
      name: Matchers.string('João Silva'),
      email: Matchers.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'joao@example.com'),
      age: Matchers.number(28)
    }
  }
});

Matchers permitem validar formatos sem capturar valores específicos. Um regex para email, por exemplo, valida que qualquer email em formato correto será aceito, não apenas "joao@example.com".

Integração com CI/CD

Em um pipeline de continuous integration, você quer rodar testes de consumidor e provedor em paralelo, sempre que código for alterado:

# .github/workflows/contract-tests.yml
name: Contract Tests

on: [push, pull_request]

jobs:
  consumer:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm install
      - run: npm run test:consumer
      - name: Publish pact
        if: success()
        run: npm run pact:publish

  provider:
    runs-on: ubuntu-latest
    needs: consumer
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm install
      - run: npm run test:provider

Conclusão

Contract testing com Pact e TypeScript oferece três benefícios fundamentais que transformam como você trabalha com APIs. Primeiro, desacopla equipes: consumidores e provedores podem desenvolver independentemente, confiando apenas no contrato, sem esperar por serviços prontos. Segundo, acelera feedback: ao contrário de testes de integração que requerem infraestrutura complexa, contract tests rodam rapidamente e isoladamente em qualquer máquina. Terceiro, documenta expectativas: o arquivo pact é documentação viva que sempre reflete a realidade de como dois serviços se comunicam.

O conhecimento prático que você ganhou aqui — estruturar interações no consumidor, implementar verificações no provedor, usar matchers para tornar contratos resilientes — é suficiente para iniciar em projetos reais. A partir daqui, explore integração com Pact Broker para arquiteturas maiores e considere estender esse padrão para todos os seus consumidores de API.

Referências


Artigos relacionados