Todos os artigos

// Knowledge.log — 技術記事

Testes de contrato com Pact no Spring Boot: parando de descobrir incompatibilidades em produção

Crie contratos entre consumidor e provedor com Pact, verifique-os no Spring Boot e bloqueie deploys incompatíveis pelo Pact Broker.

O consumidor espera id, name e price. O provedor renomeia price para amount, passa nos próprios testes e segue para produção. O consumidor também continua verde no CI: seu teste usa um mock e não sabe que o provedor mudou depois. Os dois serviços estão “corretos” quando vistos isoladamente, o que é uma maneira caprichosa de produzir uma incompatibilidade.

Pact resolve essa lacuna em duas etapas. Primeiro, o teste do consumidor registra as interações que ele realmente usa. Depois, o provedor reproduz essas interações contra sua implementação. Com os resultados publicados no Pact Broker, can-i-deploy consulta a matriz de compatibilidade e bloqueia uma versão sem verificação válida para o ambiente de destino.

O resultado esperado é verificável no pipeline:

  1. o teste do consumidor gera um arquivo Pact em target/pacts;
  2. a verificação do provedor reproduz o contrato e falha diante de uma resposta incompatível;
  3. o Broker registra versões e resultados;
  4. pact-broker can-i-deploy retorna código 1 quando a matriz não autoriza o deploy.

Versões usadas e rota de atualização

Os exemplos usam:

  • Java 21 LTS;
  • Spring Boot 3.5.16;
  • Pact-JVM 4.7.5;
  • au.com.dius.pact.consumer:junit5:4.7.5 no consumidor;
  • au.com.dius.pact.provider:spring6:4.7.5 no provedor.

O Spring Boot 4.1.0 é a linha estável atual e suporta Java 17 a 26, segundo os requisitos oficiais. Porém, o módulo Pact spring6 é documentado para Spring 6 e Spring Boot 3. Não trate Boot 4.1 com spring6 como uma matriz conjunta verificada. Para Boot 4.1/Spring 7, use o módulo au.com.dius.pact.provider:junit5 com HttpTestTarget até o Pact documentar suporte ao Spring 7.

O Spring Boot 3.5.16 suporta Java até 25. Já a documentação do Pact-JVM 4.7.x lista testes até o Java 23. Portanto, Java 25 é uma atualização possível do lado do Boot, mas rode a suíte Pact no seu projeto antes de adotá-lo como padrão.

As dependências de teste ficam separadas por aplicação. No consumidor:

<dependency>
  <groupId>au.com.dius.pact.consumer</groupId>
  <artifactId>junit5</artifactId>
  <version>4.7.5</version>
  <scope>test</scope>
</dependency>

No provedor:

<dependency>
  <groupId>au.com.dius.pact.provider</groupId>
  <artifactId>spring6</artifactId>
  <version>4.7.5</version>
  <scope>test</scope>
</dependency>

Para a verificação com JUnit 5, mantenha o Maven Surefire em 2.22.1 ou superior e desative o classloader de sistema:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <useSystemClassLoader>false</useSystemClassLoader>
    <systemPropertyVariables>
      <pact.provider.version>${env.GIT_SHA}</pact.provider.version>
      <pact.provider.branch>${env.GIT_BRANCH}</pact.provider.branch>
      <pact.verifier.publishResults>true</pact.verifier.publishResults>
    </systemPropertyVariables>
  </configuration>
</plugin>

Essas propriedades precisam chegar à JVM de teste. Colocá-las apenas no processo que iniciou o Maven e presumir que tudo será encaminhado é um daqueles contratos implícitos que não têm Pact para salvar.

O consumidor define o que realmente usa

Pact é orientado pelo consumidor. O contrato nasce enquanto o consumidor chama um servidor mock controlado pela extensão do Pact; não é um schema OpenAPI escrito separadamente.

Considere um store-consumer que busca produtos no catalog-provider com um cliente pequeno:

package academy.devdojo.store;

import org.springframework.web.client.RestClient;

public final class CatalogClient {
  private final RestClient restClient;

  public CatalogClient(String baseUrl) {
    this.restClient = RestClient.create(baseUrl);
  }

  public Product findById(long id) {
    return restClient.get()
        .uri("/products/{id}", id)
        .retrieve()
        .body(Product.class);
  }

  public record Product(long id, String name, double price) {}
}

O teste abaixo descreve um produto existente e chama o mock fornecido pelo Pact:

package academy.devdojo.store;

import static org.junit.jupiter.api.Assertions.assertEquals;

import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.RequestResponsePact;
import au.com.dius.pact.core.model.annotations.Pact;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(PactConsumerTestExt.class)
class CatalogClientPactTest {

  @Pact(provider = "catalog-provider", consumer = "store-consumer")
  RequestResponsePact getProduct(PactDslWithProvider builder) {
    return builder
        .given("product 42 exists")
        .uponReceiving("a request for product 42")
        .path("/products/42")
        .method("GET")
        .willRespondWith()
        .status(200)
        .headers(Map.of("Content-Type", "application/json"))
        .body("{\"id\":42,\"name\":\"Cafe\",\"price\":19.90}")
        .toPact();
  }

  @Test
  @PactTestFor(pactMethod = "getProduct")
  void returnsProduct(MockServer mockServer) {
    var client = new CatalogClient(mockServer.getUrl());

    var product = client.findById(42);

    assertEquals(42, product.id());
    assertEquals("Cafe", product.name());
    assertEquals(19.90, product.price());
  }
}

Ao rodar mvn test, o teste chama apenas o mock e grava target/pacts/store-consumer-catalog-provider.json. Isso comprova que o consumidor produz a requisição descrita e entende a resposta combinada. Não comprova que a versão atual ou futura do provedor ainda entrega essa resposta.

Um caso 404 segue o mesmo formato: declare outro método @Pact com given("product 99 does not exist"), caminho /products/99 e status 404; depois teste o tratamento de erro do cliente. O estado nomeado será preparado no lado do provedor com @State.

O provedor precisa provar que cumpre o contrato

Uma implementação mínima do endpoint pode ser:

package academy.devdojo.catalog;

import java.math.BigDecimal;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/products")
class ProductController {

  @GetMapping("/{id}")
  Product findById(@PathVariable long id) {
    if (id != 42) {
      throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    }
    return new Product(42, "Cafe", new BigDecimal("19.90"));
  }

  record Product(long id, String name, BigDecimal price) {}
}

A verificação sobe o Spring Boot em uma porta definida, busca os contratos no Broker e executa cada interação contra o provedor real:

package academy.devdojo.catalog;

import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.State;
import au.com.dius.pact.provider.junitsupport.loader.PactBroker;
import au.com.dius.pact.provider.spring.junit5.PactVerificationSpring6Provider;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
    properties = "server.port=8080")
@Provider("catalog-provider")
@PactBroker
class ContractVerificationTest {

  @State("product 42 exists")
  void productExists() {
    // Prepare aqui os dados exigidos pela interação.
  }

  @State("product 99 does not exist")
  void productDoesNotExist() {
    // Garanta aqui que o produto não está no repositório de teste.
  }

  @TestTemplate
  @ExtendWith(PactVerificationSpring6Provider.class)
  void verifyPact(PactVerificationContext context) {
    context.verifyInteraction();
  }
}

Em uma aplicação com banco, os métodos @State devem inserir ou remover os dados necessários de forma determinística. Se o estado não for preparado, a falha aparece nos logs da verificação do provedor, com a interação e a diferença de status ou corpo. Essa é a observação segura para o CI: não dependa de reproduzir o problema depois do deploy.

Para começar sem Broker, copie o JSON gerado para catalog-provider/src/test/resources/pacts e troque @PactBroker por:

import au.com.dius.pact.provider.junitsupport.loader.PactFolder;

@PactFolder("pacts")

Esse caminho local confirma que o consumidor gera um contrato e que o provedor consegue verificá-lo. Ele não oferece matriz de versões, webhooks nem decisão de deploy.

Se filtros de segurança, serialização HTTP ou configuração do servidor fazem parte do comportamento relevante, prefira DEFINED_PORT. Uma verificação com @WebMvcTest e Spring6MockMvcTestTarget é mais leve, mas não percorre toda a pilha. Além disso, com @WebMvcTest a extensão indicada é PactVerificationInvocationContextProvider, não PactVerificationSpring6Provider.

Publicação, branches e ambientes no Pact Broker

O JSON Pact não deve virar artefato Maven. Publique-o com a versão da aplicação consumidora, normalmente o SHA do commit:

pact-broker publish target/pacts \
  --consumer-app-version "$GIT_SHA" \
  --branch "$GIT_BRANCH" \
  --broker-base-url "$PACT_BROKER_BASE_URL" \
  --broker-username "$PACT_BROKER_USERNAME" \
  --broker-password "$PACT_BROKER_PASSWORD"

No teste do provedor, configure o Broker sem gravar credenciais no repositório:

pactbroker:
  host: ${PACT_BROKER_HOST}
  auth:
    username: ${PACT_BROKER_USERNAME}
    password: ${PACT_BROKER_PASSWORD}

O CI do provedor seleciona os contratos relevantes, executa a verificação e publica o resultado com pact.provider.version, pact.provider.branch e pact.verifier.publishResults=true. Para repositórios com branches simultâneas, use Consumer Version Selectors, como matchingBranch() e deployedOrReleased(), em vez de verificar indiscriminadamente toda versão já publicada.

Depois de um deploy bem-sucedido, registre a versão no ambiente:

pact-broker record-deployment \
  --pacticipant store-consumer \
  --version "$GIT_SHA" \
  --environment production \
  --broker-base-url "$PACT_BROKER_BASE_URL" \
  --broker-username "$PACT_BROKER_USERNAME" \
  --broker-password "$PACT_BROKER_PASSWORD"

Ambientes e record-deployment são preferíveis a tags para representar o que está em produção. A matriz do Broker passa a mostrar quais versões de consumidor e provedor têm uma verificação bem-sucedida e quais estão implantadas em cada ambiente.

O gate correto para uma mudança incompatível

O teste do consumidor não passa a falhar quando alguém altera o provedor horas ou dias depois. O bloqueio acontece em dois pontos coordenados:

  • o CI do provedor busca o Pact e falha ao verificar uma resposta incompatível;
  • antes do deploy, consumidor e provedor consultam can-i-deploy para a própria versão.

Para o consumidor:

pact-broker can-i-deploy \
  --pacticipant store-consumer \
  --version "$GIT_SHA" \
  --to-environment production \
  --broker-base-url "$PACT_BROKER_BASE_URL" \
  --broker-username "$PACT_BROKER_USERNAME" \
  --broker-password "$PACT_BROKER_PASSWORD"

O job deve preservar a saída do comando e seu código de retorno. Código 0 libera o estágio seguinte; código 1 interrompe o deploy. Consulte também a matriz do Broker e a explicação oficial de can-i-deploy quando o gate negar uma versão.

Dois cenários deixam a responsabilidade clara:

  • se o provedor remover price, sua verificação falha; o log do CI mostra a diferença e can-i-deploy não autoriza essa versão contra os consumidores implantados;
  • se o consumidor publicar uma interação nova, sua versão não pode ser implantada até um provedor compatível verificá-la.

Pending pacts evitam que um contrato novo e ainda não verificado derrube imediatamente o CI do provedor. Eles não liberam o deploy: a ausência de verificação continua visível na matriz e bloqueia can-i-deploy.

Armadilhas e limites que importam

Use um único roteiro para diagnosticar a integração:

SinalCorreção concretaObservação segura no CI ou Broker
O provedor responde 404 no caso felizImplemente o @State("product 42 exists") para preparar os dadosLog da interação na verificação do provedor
Uma mudança de campo passa despercebidaVerifique o Pact no CI do provedor e use matchers explícitos para campos relevantesDiferença de corpo no log e resultado da verificação na matriz
A matriz acumula versões ambíguasPublique consumidor e provedor com o SHA do Git, não 1.0-SNAPSHOTVersões e branches identificáveis no Broker
A verificação passa, mas o resultado não apareceEncaminhe pact.verifier.publishResults=true e a versão pela JVM do SurefireResultado ausente ou presente na matriz do Broker
O pipeline publica e implanta sem consultar compatibilidadeExecute can-i-deploy imediatamente antes do deployCódigo de saída 1 bloqueia o job

Evite matchers tão abertos que qualquer objeto sirva e respostas exatas demais que tornem irrelevantes diferenças inofensivas, como valores variáveis. O contrato deve expressar o que o consumidor precisa: nomes, tipos, formatos e estados relevantes.

Testes de contrato não substituem testes de carga nem fluxos ponta a ponta. Eles não comprovam capacidade, banco real, políticas de rede, autenticação de produção, relógios ou um processo de negócio atravessando vários serviços. Também não eliminam testes unitários do consumidor para mapeamento e tratamento de erro. O papel do Pact é mais estreito: verificar se exemplos de requisição e resposta usados por versões conhecidas continuam compatíveis.

A recomendação da DevDojo é firme: adote Pact quando consumidor e provedor têm ciclos de deploy independentes e uma quebra de payload precisa impedir promoção entre ambientes. Comece com um endpoint importante, verificação no CI do provedor e Broker como gate. Se os serviços sempre são publicados juntos e um teste de integração curto já cobre a fronteira com clareza, o custo de manter estados, versões e Broker pode não se pagar.

Próximo passo

Escolha uma chamada que já causaria impacto se um campo desaparecesse. Gere o primeiro Pact, faça o provedor verificá-lo e adicione can-i-deploy ao job existente. Não é preciso reescrever a pirâmide de testes; basta parar de deixar o contrato entre serviços existir apenas na memória do time.

javaspring-boottestingpact

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos