.NET Testes Microsserviços DevOps CI/CD Qualidade

Testes de contrato com Pact no .NET: garantindo compatibilidade entre microsserviços

Guia prático de consumer-driven contract testing com PactNet no .NET: evite breaking changes silenciosos entre microsserviços.

N
Neryx Digital Architects
28 de fevereiro de 2026
13 min de leitura
210 profissionais leram
Categoria: Arquitetura Público: Times de plataforma e operação Etapa: Decisão

Em arquiteturas de microsserviços, o maior risco silencioso é o seguinte: o time A muda a API do serviço B sem avisar o time C, que consome essa API. O build passa, o deploy acontece, e o sistema começa a quebrar em produção. Testes de contrato com Pact eliminam esse problema antes do merge.

O que é consumer-driven contract testing

Ao contrário de testes de integração end-to-end (lentos, frágeis, caros), testes de contrato funcionam assim:

  1. O consumidor (quem chama a API) define o contrato: "Eu espero que GET /products/{id} retorne um objeto com id, name e price"
  2. Esse contrato é salvo como um arquivo JSON (pact file)
  3. O provedor (quem expõe a API) verifica que o contrato é satisfeito por sua implementação atual
  4. Se o provedor mudar a API de forma incompatível, o teste falha — antes de chegar à produção

O Pact é o framework open-source mais adotado para isso, e o PactNet é sua implementação oficial para .NET.

dotnet add package PactNet                    # Consumidor e provedor
dotnet add package PactNet.Output.AnsiConsole # Saída formatada nos testes

Cenário de exemplo

Vamos usar dois serviços: OrderService (consumidor) que chama ProductService (provedor) para verificar estoque antes de criar pedidos.

// OrderService — cliente HTTP que consome ProductService
public class ProductServiceClient
{
    private readonly HttpClient _httpClient;

    public ProductServiceClient(HttpClient httpClient)
        => _httpClient = httpClient;

    public async Task<ProductDto?> GetProductAsync(Guid productId)
    {
        var response = await _httpClient.GetAsync($"/api/products/{productId}");
        if (response.StatusCode == HttpStatusCode.NotFound) return null;
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<ProductDto>();
    }

    public async Task<bool> CheckStockAsync(Guid productId, int quantity)
    {
        var response = await _httpClient.GetAsync(
            $"/api/products/{productId}/stock?quantity={quantity}");
        response.EnsureSuccessStatusCode();
        var result = await response.Content.ReadFromJsonAsync<StockCheckResult>();
        return result?.IsAvailable ?? false;
    }
}

public record ProductDto(Guid Id, string Name, decimal Price, bool IsActive);
public record StockCheckResult(bool IsAvailable, int AvailableQuantity);

Parte 1: Teste do Consumidor (gera o contrato)

// tests/OrderService.Tests/Contracts/ProductServiceContractTests.cs
public class ProductServiceContractTests : IDisposable
{
    private readonly IPactBuilderV4 _pactBuilder;
    private readonly string _pactDir;

    public ProductServiceContractTests()
    {
        _pactDir = Path.Combine(Directory.GetCurrentDirectory(), "pacts");

        var pact = Pact.V4("OrderService", "ProductService", new PactConfig
        {
            PactDir = _pactDir,
            // Logs detalhados para debugging
            DefaultJsonSettings = new JsonSerializerOptions
            {
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase
            }
        });

        _pactBuilder = pact.WithHttpInteractions();
    }

    [Fact]
    public async Task GetProduct_ExistingProduct_ReturnsProductData()
    {
        var productId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6");

        // Define a interação: o que o consumidor espera
        _pactBuilder
            .UponReceiving("a request for an existing product")
            .Given("product 3fa85f64 exists") // Estado do provedor
            .WithRequest(HttpMethod.Get, $"/api/products/{productId}")
            .WithHeader("Accept", "application/json")
            .WillRespond()
            .WithStatus(HttpStatusCode.OK)
            .WithHeader("Content-Type", "application/json; charset=utf-8")
            .WithJsonBody(new
            {
                // Match.Type() = aceita qualquer valor do mesmo tipo (não hardcoda o valor)
                id = Match.Type(productId),
                name = Match.Type("Produto Exemplo"),
                price = Match.Decimal(99.90m),
                isActive = Match.Type(true)
            });

        await _pactBuilder.VerifyAsync(async ctx =>
        {
            // ctx.MockServerUri é o servidor mock do Pact
            var httpClient = new HttpClient { BaseAddress = ctx.MockServerUri };
            var client = new ProductServiceClient(httpClient);

            var product = await client.GetProductAsync(productId);

            product.Should().NotBeNull();
            product!.Id.Should().Be(productId);
            product.Name.Should().NotBeNullOrEmpty();
            product.Price.Should().BeGreaterThan(0);
        });
    }

    [Fact]
    public async Task GetProduct_NonExistentProduct_Returns404()
    {
        var nonExistentId = Guid.Parse("00000000-0000-0000-0000-000000000000");

        _pactBuilder
            .UponReceiving("a request for a non-existent product")
            .Given("product 00000000 does not exist")
            .WithRequest(HttpMethod.Get, $"/api/products/{nonExistentId}")
            .WillRespond()
            .WithStatus(HttpStatusCode.NotFound);

        await _pactBuilder.VerifyAsync(async ctx =>
        {
            var httpClient = new HttpClient { BaseAddress = ctx.MockServerUri };
            var client = new ProductServiceClient(httpClient);

            var product = await client.GetProductAsync(nonExistentId);

            product.Should().BeNull();
        });
    }

    [Fact]
    public async Task CheckStock_SufficientStock_ReturnsAvailable()
    {
        var productId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6");

        _pactBuilder
            .UponReceiving("a stock check for available product")
            .Given("product 3fa85f64 has 100 units in stock")
            .WithRequest(HttpMethod.Get, $"/api/products/{productId}/stock")
            .WithQuery("quantity", "5")
            .WillRespond()
            .WithStatus(HttpStatusCode.OK)
            .WithJsonBody(new
            {
                isAvailable = true,
                availableQuantity = Match.Integer(100)
            });

        await _pactBuilder.VerifyAsync(async ctx =>
        {
            var httpClient = new HttpClient { BaseAddress = ctx.MockServerUri };
            var client = new ProductServiceClient(httpClient);

            var isAvailable = await client.CheckStockAsync(productId, quantity: 5);

            isAvailable.Should().BeTrue();
        });
    }

    public void Dispose() => _pactBuilder?.Dispose();
}

Ao rodar os testes do consumidor, o Pact gera automaticamente o arquivo pacts/OrderService-ProductService.json — este é o contrato.

O arquivo de contrato gerado

// pacts/OrderService-ProductService.json (gerado automaticamente)
{
  "consumer": { "name": "OrderService" },
  "provider": { "name": "ProductService" },
  "interactions": [
    {
      "description": "a request for an existing product",
      "providerStates": [
        { "name": "product 3fa85f64 exists" }
      ],
      "request": {
        "method": "GET",
        "path": "/api/products/3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json; charset=utf-8" },
        "body": {
          "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
          "name": "Produto Exemplo",
          "price": 99.90,
          "isActive": true
        },
        "matchingRules": {
          "body": {
            "$.id": { "matchers": [{ "match": "type" }] },
            "$.name": { "matchers": [{ "match": "type" }] },
            "$.price": { "matchers": [{ "match": "decimal" }] },
            "$.isActive": { "matchers": [{ "match": "type" }] }
          }
        }
      }
    }
  ],
  "metadata": { "pactSpecification": { "version": "4.0" } }
}

Parte 2: Verificação do Provedor

// tests/ProductService.Tests/Contracts/ProductServiceProviderTests.cs
public class ProductServiceProviderTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public ProductServiceProviderTests(WebApplicationFactory<Program> factory)
        => _factory = factory;

    [Fact]
    public async Task VerifyContractsWithOrderService()
    {
        // Caminho para o pact file gerado pelo consumidor
        // Em CI: virá do Pact Broker
        var pactFile = Path.Combine(
            Directory.GetCurrentDirectory(),
            "../../../OrderService.Tests/pacts/OrderService-ProductService.json");

        var verifier = new PactVerifier("ProductService", new PactVerifierConfig
        {
            LogLevel = PactLogLevel.Information
        });

        // Inicia o servidor real da aplicação
        var server = _factory.Server;
        server.BaseAddress = new Uri("http://localhost");

        verifier
            .WithHttpEndpoint(server.BaseAddress)
            .WithFileSource(new FileInfo(pactFile))
            // Provider states: configura o banco de dados para cada estado
            .WithProviderStateUrl(new Uri(server.BaseAddress, "/pact/provider-states"))
            .Verify();
    }
}

// Endpoint de provider states (só exposto em testes)
// ProductService/src/Api/Controllers/PactProviderStatesController.cs
[ApiController]
[Route("pact")]
public class PactProviderStatesController : ControllerBase
{
    private readonly IProductRepository _repository;
    private readonly AppDbContext _context;

    public PactProviderStatesController(
        IProductRepository repository,
        AppDbContext context)
    {
        _repository = repository;
        _context = context;
    }

    [HttpPost("provider-states")]
    public async Task<IActionResult> SetProviderState([FromBody] ProviderState state)
    {
        switch (state.State)
        {
            case "product 3fa85f64 exists":
                await EnsureProductExistsAsync(
                    Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
                    "Produto Exemplo",
                    99.90m);
                break;

            case "product 00000000 does not exist":
                await EnsureProductDoesNotExistAsync(
                    Guid.Parse("00000000-0000-0000-0000-000000000000"));
                break;

            case "product 3fa85f64 has 100 units in stock":
                await EnsureProductStockAsync(
                    Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
                    stock: 100);
                break;
        }

        return Ok();
    }

    private async Task EnsureProductExistsAsync(Guid id, string name, decimal price)
    {
        var existing = await _context.Products.FindAsync(id);
        if (existing is null)
        {
            _context.Products.Add(new Product
            {
                Id = id,
                Name = name,
                Price = price,
                IsActive = true,
                Stock = 100
            });
            await _context.SaveChangesAsync();
        }
    }

    private async Task EnsureProductDoesNotExistAsync(Guid id)
    {
        var existing = await _context.Products.FindAsync(id);
        if (existing is not null)
        {
            _context.Products.Remove(existing);
            await _context.SaveChangesAsync();
        }
    }

    private async Task EnsureProductStockAsync(Guid id, int stock)
    {
        var product = await _context.Products.FindAsync(id);
        if (product is not null)
        {
            product.Stock = stock;
            await _context.SaveChangesAsync();
        }
    }
}

public record ProviderState(string State, Dictionary<string, string>? Params = null);

Pact Broker: compartilhando contratos na equipe

Para times maiores, o Pact Broker é o servidor central que armazena e versiona os contratos:

# docker-compose.yml — Pact Broker local para desenvolvimento
services:
  pact-broker:
    image: pactfoundation/pact-broker:latest
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgres://pact:password@postgres/pact
      PACT_BROKER_BASIC_AUTH_USERNAME: admin
      PACT_BROKER_BASIC_AUTH_PASSWORD: admin
    depends_on:
      - postgres

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: pact
      POSTGRES_PASSWORD: password
      POSTGRES_DB: pact
// Publicar contrato no Pact Broker após os testes do consumidor
// Adicionar ao pipeline de CI
verifier
    .WithHttpEndpoint(server.BaseAddress)
    // Busca contrato do Pact Broker em vez de arquivo local
    .WithPactBrokerSource(new Uri("http://pact-broker:9292"), options =>
    {
        options.ConsumerVersionSelectors(new ConsumerVersionSelector
        {
            MainBranch = true, // Verifica contratos da branch main
            MatchingBranch = true // E da branch atual (para PRs)
        });
        options.EnablePending();
        options.BasicAuthentication("admin", "admin");
        options.PublishResults(
            providerVersion: Environment.GetEnvironmentVariable("GIT_SHA") ?? "local",
            providerVersionBranch: Environment.GetEnvironmentVariable("GIT_BRANCH") ?? "local");
    })
    .Verify();

GitHub Actions: fluxo completo

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

on:
  push:
    branches: [main, "feature/**"]

jobs:
  consumer-tests:
    name: Consumer — OrderService
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.x

      - name: Run consumer contract tests
        run: dotnet test tests/OrderService.Tests --filter "Category=Contract"

      # Publica o pact file gerado no Pact Broker
      - name: Publish pact to broker
        run: |
          curl -X PUT \
            http://${{ secrets.PACT_BROKER_URL }}/pacts/provider/ProductService/consumer/OrderService/version/${{ github.sha }} \
            -H "Content-Type: application/json" \
            -u ${{ secrets.PACT_BROKER_USER }}:${{ secrets.PACT_BROKER_PASSWORD }} \
            -d @tests/OrderService.Tests/pacts/OrderService-ProductService.json

  provider-verification:
    name: Provider — ProductService
    runs-on: ubuntu-latest
    needs: consumer-tests
    steps:
      - uses: actions/checkout@v4
        with:
          repository: org/ProductService # Repositório do provedor

      - name: Run provider verification
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_USER: ${{ secrets.PACT_BROKER_USER }}
          PACT_BROKER_PASSWORD: ${{ secrets.PACT_BROKER_PASSWORD }}
          GIT_SHA: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}
        run: dotnet test tests/ProductService.Tests --filter "Category=Contract"

Padrões de matching úteis

// Match.Type() — qualquer valor do mesmo tipo primitivo
"name" = Match.Type("qualquer string")

// Match.Regex() — valida formato
"email" = Match.Regex("user@example.com", @"^[^@]+@[^@]+\.[^@]+$")

// Match.Integer() — qualquer inteiro
"count" = Match.Integer(42)

// Match.Decimal() — qualquer decimal
"price" = Match.Decimal(9.99m)

// Match.Date() — qualquer data no formato especificado
"createdAt" = Match.Date("2026-04-27", "yyyy-MM-dd")

// Match.DateTime() — qualquer datetime ISO
"updatedAt" = Match.DateTime("2026-04-27T10:00:00Z", "yyyy-MM-dd'T'HH:mm:ss'Z'")

// Match.MinType() — array com pelo menos N itens do mesmo tipo
"items" = Match.MinType(new { id = Guid.NewGuid(), name = "item" }, 1)

Quando não usar testes de contrato

  • APIs públicas/externas que você não controla (use mocks fixos)
  • Serviços com apenas um consumidor e um provedor no mesmo repositório (testes de integração normais bastam)
  • Contratos que mudam com muita frequência (a disciplina de manter os estados de provedor pode superar o benefício)

Testes de contrato brilham quando há múltiplos consumidores de um mesmo serviço, times diferentes trabalhando nos serviços, ou quando o custo de breaking changes em produção é alto.


Na Neryx implementamos contract testing como parte da estratégia de qualidade em arquiteturas de microsserviços. Se você tem dificuldade em coordenar deploys entre serviços, fale com a gente.

Quer sair do modo reativo e priorizar o que mais importa?

O diagnóstico de maturidade ajuda a transformar sintomas operacionais em um plano mais claro de evolução.

Avaliar maturidade

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.