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:
- o teste do consumidor gera um arquivo Pact em
target/pacts; - a verificação do provedor reproduz o contrato e falha diante de uma resposta incompatível;
- o Broker registra versões e resultados;
pact-broker can-i-deployretorna código1quando 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.5no consumidor;au.com.dius.pact.provider:spring6:4.7.5no 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-deploypara 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 ecan-i-deploynã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:
| Sinal | Correção concreta | Observação segura no CI ou Broker |
|---|---|---|
O provedor responde 404 no caso feliz | Implemente o @State("product 42 exists") para preparar os dados | Log da interação na verificação do provedor |
| Uma mudança de campo passa despercebida | Verifique o Pact no CI do provedor e use matchers explícitos para campos relevantes | Diferença de corpo no log e resultado da verificação na matriz |
| A matriz acumula versões ambíguas | Publique consumidor e provedor com o SHA do Git, não 1.0-SNAPSHOT | Versões e branches identificáveis no Broker |
| A verificação passa, mas o resultado não aparece | Encaminhe pact.verifier.publishResults=true e a versão pela JVM do Surefire | Resultado ausente ou presente na matriz do Broker |
| O pipeline publica e implanta sem consultar compatibilidade | Execute can-i-deploy imediatamente antes do deploy | Có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.