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:
- O consumidor (quem chama a API) define o contrato: "Eu espero que GET /products/{id} retorne um objeto com id, name e price"
- Esse contrato é salvo como um arquivo JSON (pact file)
- O provedor (quem expõe a API) verifica que o contrato é satisfeito por sua implementação atual
- 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.