MÓDULO 02 · CONCEITO 07 DE 8

Contract testing — quando integração não escala

Como verificar a fronteira entre serviços sem subir todos juntos em E2E.

Tempo de leitura ~22 min Pré-requisito Mutation testing Próximo Snapshot, fixtures & flaky

Em uma arquitetura monolítica, "testar a integração entre módulos" significa subir o monolito e exercitar caminhos completos. Funciona, embora seja lento. Em microsserviços, essa abordagem rapidamente desmorona: você precisa subir 5, 10, 20 serviços, com seus bancos, filas, dependências externas. O tempo de feedback explode. A flakiness explode. A combinação de versões para testar explode. Em breve, "testes de integração entre serviços" vira algo que ninguém consegue rodar localmente, que demora horas no CI, e que falha por motivos não relacionados à mudança que você fez. Times maduros desistem da abordagem ingênua.

Contract testing é a alternativa que resolve esse problema. Em vez de verificar "estes serviços funcionam juntos quando subidos", você verifica "cada serviço cumpre o contrato que outros esperam dele". O contrato é um artefato compartilhado entre consumidor e provedor — schemas, exemplos de requisição e resposta, regras de validação. Cada lado é testado contra esse contrato em isolamento, sem precisar do outro presente. Quando os dois passam contra o mesmo contrato, a integração está formalmente verificada — mesmo sem nunca terem conversado de fato.

O problema concreto

Suponha que você tem dois serviços: OrderService e PaymentService. OrderService chama PaymentService para autorizar pagamento. Sem contract testing, suas opções são:

Cada uma dessas tem custos reais. Contract testing oferece um quarto caminho: defina o contrato uma vez, teste cada lado contra ele.

Os dois sabores — schema vs consumer-driven

Há duas filosofias principais de contract testing, com diferenças importantes.

Schema-based — provider-driven

O provedor publica um schema (OpenAPI, Protobuf, JSON Schema, gRPC reflection). Consumidores baixam, geram clientes, e testam contra esse schema. Validação acontece via:

Vantagens: o schema é único e canônico. Nova consumidores aparecem, pegam o schema, geram cliente, funciona. Desvantagens: o consumidor pode usar uma fração minúscula do schema; mudanças em campos que ele não usa não deveriam afetá-lo, mas se o schema dele incluir validação rígida, qualquer mudança quebra. Esse é o problema clássico de "schema descreve tudo o que o provedor pode fazer", não "o que o consumidor precisa".

Consumer-driven contracts (CDC) — Pact

A inversão: cada consumidor escreve um pact descrevendo o que ele espera do provedor, em forma de exemplos concretos. O provedor recebe esses pacts e verifica que cumpre cada um deles.

Vantagens: o contrato é dirigido pelo que realmente é usado. Mudanças em campos não-utilizados pelo consumidor não quebram seu pact. Desvantagens: você só descobre quebra quando alguém roda verify; consumidores precisam ser disciplinados em manter pacts; relação consumer→provider é explícita (não vale para "qualquer cliente futuro").

Pact é o framework dominante para CDC. OpenAPI dominante para schema-based. Times maduros frequentemente usam ambos: OpenAPI como schema canônico do provedor; Pact entre consumidores específicos com necessidades específicas.

Como Pact funciona na prática

Vou ilustrar com o caso OrderServicePaymentService.

Lado do consumidor

No OrderService, você escreve um teste que define o que ele espera:

// C# com PactNet
[Fact]
public async Task AuthorizesPayment_When_Valid() {
    pactBuilder.UponReceiving("a payment authorization request")
        .Given("user has valid card")
        .WithRequest(HttpMethod.Post, "/payments/authorize")
        .WithJsonBody(new {
            order_id = "abc-123",
            amount = 150.00m,
            currency = "BRL"
        })
        .WillRespond()
        .WithStatus(200)
        .WithJsonBody(new {
            authorization_id = Match.Type("auth-xyz"),
            status = "authorized",
            authorized_at = Match.Iso8601DateTime()
        });

    await pactBuilder.VerifyAsync(async ctx => {
        var client = new PaymentClient(ctx.MockServerUri);
        var result = await client.Authorize(
            new AuthorizeRequest {
                OrderId = "abc-123",
                Amount = 150.00m,
                Currency = "BRL"
            });

        result.Status.Should().Be("authorized");
        result.AuthorizationId.Should().NotBeNullOrEmpty();
    });
}

Este teste:

  1. Sobe um mock HTTP local que simula PaymentService.
  2. Configura o mock para responder específicamente ao request descrito.
  3. Roda o PaymentClient real contra o mock.
  4. Verifica que ele entende a resposta corretamente.
  5. Gera o arquivo pact.json registrando essa expectativa.

Note os Match.Type e Match.Iso8601DateTime: você não exige valor exato de campo gerado pelo provedor — exige tipo ou formato. O provedor pode mudar o authorization_id a cada chamada; o consumidor só precisa que seja string não-vazia.

Lado do provedor

No PaymentService, você roda verificação contra o pact:

// PactNet — verifier no provedor
[Fact]
public void Verify_OrderServiceContract() {
    var verifier = new PactVerifier(new PactVerifierConfig());

    verifier
        .ServiceProvider("PaymentService", new Uri("http://localhost:5001"))
        .WithFileSource(new FileInfo("../pacts/orderservice-paymentservice.json"))
        .WithProviderStateUrl(new Uri("http://localhost:5001/pact/states"))
        .Verify();
}

O verifier:

  1. Lê o pact gerado pelo OrderService.
  2. Para cada interação, reproduz o request contra o PaymentService rodando.
  3. Verifica que a resposta bate com a esperada (estrutura, tipos, campos).
  4. Falha se algo divergir.

O WithProviderStateUrl aponta para um endpoint especial que prepara estado antes de cada interação ("user has valid card" → seed banco com usuário tendo cartão válido). Esse é o ponto delicado de Pact: provedor precisa expor um caminho para configurar pre-condições para cada teste.

Pact Broker — orquestração

Em times pequenos, pacts podem ser arquivos commitados em repositórios. Em times maiores, o Pact Broker resolve:

A "can-i-deploy" é o real ganho de Pact em escala: antes de deployar PaymentService v2.5 em produção, CI pergunta "há combinação verificada com cada consumidor de produção?". Se OrderService em produção é v3.1 e v3.1 não foi verificado contra v2.5, deploy não rola. É garantia formal de compatibilidade.

OpenAPI / Schema-based — abordagem complementar

Para APIs HTTP REST, OpenAPI (anteriormente Swagger) é o formato padrão de schema. Define endpoints, métodos, parâmetros, schemas de request/response. Pode ser fonte da verdade ou gerado a partir do código.

Validações úteis:

Comparado a Pact, OpenAPI é menos preciso (descreve "tudo o que pode acontecer") mas mais leve (sem coordenação entre serviços durante teste). Para cenários onde o schema é estável e consumidores múltiplos, OpenAPI vence. Para cenários onde poucos consumidores muito acoplados, Pact dá garantia mais forte.

O mesmo nas três linguagens

C# — PactNet
// Consumidor
var pact = Pact.V3("OrderService", "PaymentService", new PactConfig());
var pactBuilder = pact.WithHttpInteractions();

pactBuilder.UponReceiving("authorization request")
    .WithRequest(HttpMethod.Post, "/payments/authorize")
    .WithJsonBody(new { order_id = "abc", amount = 100m })
    .WillRespond()
    .WithStatus(200)
    .WithJsonBody(new {
        authorization_id = Match.Type("auth-x"),
        status = "authorized"
    });

// Provedor — verifier
new PactVerifier(new PactVerifierConfig())
    .ServiceProvider("PaymentService", new Uri("http://localhost:5001"))
    .WithPactBrokerSource(new Uri("https://broker.example.com"),
        opts => opts.PublishResults("v1.2.3"))
    .Verify();

PactNet é o port oficial de Pact para .NET. Suporta Pact v3 e v4. Integração com xUnit/NUnit é simples. Verifier usa state handlers via HTTP endpoint.

Python — pact-python
# Consumidor
from pact import Consumer, Provider, Like, EachLike

pact = Consumer('OrderService').has_pact_with(Provider('PaymentService'))
pact.start_service()

(pact
 .given('user has valid card')
 .upon_receiving('authorization request')
 .with_request('POST', '/payments/authorize',
               body={'order_id': 'abc', 'amount': 100})
 .will_respond_with(200, body={
     'authorization_id': Like('auth-x'),
     'status': 'authorized'
 }))

with pact:
    client = PaymentClient(pact.uri)
    result = client.authorize(order_id='abc', amount=100)
    assert result.status == 'authorized'

# Provedor — verifier (CLI ou Python)
# pact-verifier --provider-base-url=http://localhost:5001 \
#               --pact-url=./pacts/orderservice-paymentservice.json

pact-python tem maturidade variada — versão estável funciona bem para HTTP. CLI pact-verifier independe de linguagem e frequentemente é mais robusto que SDKs específicos.

Go — pact-go
// Consumidor
import "github.com/pact-foundation/pact-go/v2/consumer"

mockProvider, _ := consumer.NewV4Pact(consumer.MockHTTPProviderConfig{
    Consumer: "OrderService",
    Provider: "PaymentService",
})

err := mockProvider.AddInteraction().
    Given("user has valid card").
    UponReceiving("authorization request").
    WithRequest("POST", "/payments/authorize",
        func(b *consumer.V4RequestBuilder) {
            b.JSONBody(map[string]interface{}{
                "order_id": "abc", "amount": 100,
            })
        }).
    WillRespondWith(200, func(b *consumer.V4ResponseBuilder) {
        b.JSONBody(matchers.Map{
            "authorization_id": matchers.Like("auth-x"),
            "status":           "authorized",
        })
    }).
    ExecuteTest(t, func(c consumer.MockServerConfig) error {
        client := NewPaymentClient(c.URL)
        result, err := client.Authorize("abc", 100)
        if err != nil { return err }
        if result.Status != "authorized" {
            t.Fatalf("expected authorized, got %s", result.Status)
        }
        return nil
    })

pact-go v2 é a versão moderna, baseada no core Rust de Pact. API mais verbosa que outras linguagens, mas estável. Verifier rodando contra provedor é direto.

Quando contract testing vale

Contract testing introduz complexidade: mais um sistema (Broker), mais um conceito (provider state), coordenação adicional entre times. O ROI aparece em contextos específicos:

Quando não vale:

armadilha de adoção

Contract testing tem curva de aprendizado significativa. Provider states, matchers, Broker, can-i-deploy — cada um precisa ser compreendido. Times que adotam sem essa preparação frequentemente ficam com Pact mal-configurado, gerando falsos positivos e falsos negativos. Resultado: equipe perde confiança e abandona. Trate adoção como projeto: alguém estuda profundamente, configura protótipo, evangeliza com ROI demonstrado.

Padrões e antipatterns

Bom: matchers em vez de valores exatos

Use Like, EachLike, regex para campos gerados ou variáveis. Especificar "id": "auth-12345" exato amarra teste demais — provedor não pode mudar formato sem quebrar pact.

Bom: state explícito por interação

Cada interação declara seu estado pré-requisito ("user has valid card", "account is locked"). Provedor expõe endpoint para configurar. Mantém testes isolados.

Antipattern: pact gigante com 50 interações

Um pact deveria cobrir o que esse consumidor usa. Não tudo o que o provedor faz. 50 interações em um pact é sinal de que você está usando Pact como schema-based — provavelmente OpenAPI cabe melhor.

Antipattern: pacts gerados manualmente

Pact files devem ser gerados por testes do consumidor. Editar JSON na mão é como editar mock manualmente — viole-se a garantia de que o cliente real consegue consumir o que está no pact.

Antipattern: ignorar verify failures

Provider verifier falha → "ah, é só o ambiente, ignora". Se você ignora, perdeu o valor. Trate verify falhando como o mesmo nível de severidade que CI vermelho.

Como praticar

  1. Implemente Pact entre dois serviços do projeto. OrderService chama PaymentService. Escreva pact no consumidor; verify no provedor. Ambos passam — quebre o provedor, veja verify falhar.
  2. Configure can-i-deploy. Suba um Pact Broker local (Docker). Publique pacts. Tente "deployar" provedor sem ter consumidor pact compatível. Veja can-i-deploy bloquear.
  3. Compare com OpenAPI. Gere OpenAPI do mesmo provedor. Use schemathesis contra ele. Compare a força das duas abordagens — onde Pact é mais preciso, onde OpenAPI cobre mais.

Referências para aprofundar

  1. livro Building Microservices (2nd ed.) — Sam Newman (2021). Capítulo sobre testing distribuído trata contract testing como solução central. Newman é referência na área.
  2. livro Microservices Patterns — Chris Richardson (2018). Capítulo 9 (Testing microservices) tem tratamento detalhado de Pact e CDC.
  3. artigo Consumer-Driven Contracts — Ian Robinson (Martin Fowler bliki). martinfowler.com/articles/consumerDrivenContracts.html — texto fundacional de 2006 que cunhou o termo. Releitura recomendada.
  4. artigo What is Pact? — Pact docs. docs.pact.io/getting_started/what_is_pact — overview oficial. Curto, claro, formativo.
  5. artigo Contract Testing in Practice — ThoughtWorks Tech Radar. thoughtworks.com/radar — análises trimestrais. Contract testing aparece consistentemente como prática recomendada.
  6. artigo Schema-Based vs Consumer-Driven Contracts — Pact blog. pact.io/blog — comparação entre as duas abordagens. Útil para decidir qual cabe.
  7. docs Pact Documentation. docs.pact.io — fonte primária. Cobre setup, matchers, provider states, broker, can-i-deploy.
  8. docs OpenAPI Specification. spec.openapis.org — especificação oficial 3.1. Para schema-based contracts em REST.
  9. docs Schemathesis. schemathesis.readthedocs.io — testa que provedor está conforme OpenAPI spec, automaticamente, com property-based.
  10. docs Pact Broker. github.com/pact-foundation/pact_broker — repositório central. Roda via Docker em minutos.
  11. vídeo Contract Testing with Pact — Beth Skurrie. YouTube. Skurrie é mantenedora do Pact. Várias palestras dela cobrem desde basics até can-i-deploy avançado.
  12. vídeo Microservices Testing — Distributed Systems — Cindy Sridharan. YouTube. Tratamento amplo de testes em distribuídos. Contract testing aparece como peça central.