All articles

// Knowledge.log — 技術記事

Contract Testing with Pact in Spring Boot: Stop Finding Incompatibilities in Production

Create consumer-provider contracts with Pact, verify them in Spring Boot, and block incompatible deployments through Pact Broker.

The consumer expects id, name, and price. The provider renames price to amount, passes its own tests, and goes to production. The consumer also stays green in CI: its test uses a mock and does not know that the provider changed later. Both services are “correct” when viewed in isolation, which is a rather elaborate way to produce an incompatibility.

Pact closes this gap in two steps. First, the consumer test records the interactions it actually uses. Then the provider replays those interactions against its implementation. With the results published to Pact Broker, can-i-deploy checks the compatibility matrix and blocks a version without a valid verification for the target environment.

The expected outcome is verifiable in the pipeline:

  1. the consumer test generates a Pact file in target/pacts;
  2. provider verification replays the contract and fails on an incompatible response;
  3. the Broker records versions and results;
  4. pact-broker can-i-deploy returns code 1 when the matrix does not authorize the deployment.

Versions Used and the Upgrade Path

The examples use:

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

Spring Boot 4.1.0 is the current stable line and supports Java 17 through 26, according to the official requirements. However, the Pact spring6 module is documented for Spring 6 and Spring Boot 3. Do not treat Boot 4.1 with spring6 as a jointly verified matrix. For Boot 4.1/Spring 7, use the au.com.dius.pact.provider:junit5 module with HttpTestTarget until Pact documents Spring 7 support.

Spring Boot 3.5.16 supports Java up to 25. Pact-JVM 4.7.x documentation, meanwhile, lists testing up to Java 23. Java 25 is therefore a possible upgrade on the Boot side, but run the Pact suite in your project before adopting it as the default.

Keep the test dependencies separate by application. In the consumer:

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

In the provider:

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

For verification with JUnit 5, keep Maven Surefire at 2.22.1 or later and disable the system classloader:

<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>

These properties must reach the test JVM. Setting them only on the process that started Maven and assuming everything will be forwarded is one of those implicit contracts that Pact cannot save.

The Consumer Defines What It Actually Uses

Pact is consumer-driven. The contract is created while the consumer calls a mock server controlled by the Pact extension; it is not a separately authored OpenAPI schema.

Consider a store-consumer that fetches products from catalog-provider through a small client:

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) {}
}

The test below describes an existing product and calls the mock supplied by 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());
  }
}

When you run mvn test, the test calls only the mock and writes target/pacts/store-consumer-catalog-provider.json. This proves that the consumer produces the described request and understands the agreed response. It does not prove that the provider's current or future version still returns that response.

A 404 case follows the same format: declare another @Pact method with given("product 99 does not exist"), path /products/99, and status 404; then test the client's error handling. The named state will be prepared on the provider side with @State.

The Provider Must Prove It Honors the Contract

A minimal endpoint implementation can look like this:

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) {}
}

Verification starts Spring Boot on a defined port, fetches contracts from the Broker, and runs each interaction against the real provider:

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();
  }
}

In an application with a database, the @State methods must insert or remove the required data deterministically. If the state is not prepared, the failure appears in the provider verification logs with the interaction and the status or body difference. That is the safe observation point for CI: do not depend on reproducing the problem after deployment.

To start without a Broker, copy the generated JSON to catalog-provider/src/test/resources/pacts and replace @PactBroker with:

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

@PactFolder("pacts")

This local path confirms that the consumer generates a contract and the provider can verify it. It does not provide a version matrix, webhooks, or deployment decisions.

If security filters, HTTP serialization, or server configuration are part of the relevant behavior, prefer DEFINED_PORT. Verification with @WebMvcTest and Spring6MockMvcTestTarget is lighter, but it does not exercise the entire stack. In addition, with @WebMvcTest, the appropriate extension is PactVerificationInvocationContextProvider, not PactVerificationSpring6Provider.

Publishing, Branches, and Environments in Pact Broker

The Pact JSON should not become a Maven artifact. Publish it with the consumer application version, usually the commit SHA:

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"

Configure the Broker in the provider test without storing credentials in the repository:

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

The provider CI selects the relevant contracts, runs verification, and publishes the result with pact.provider.version, pact.provider.branch, and pact.verifier.publishResults=true. For repositories with concurrent branches, use Consumer Version Selectors such as matchingBranch() and deployedOrReleased() instead of indiscriminately verifying every version ever published.

After a successful deployment, record the version in the environment:

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"

Environments and record-deployment are preferable to tags for representing what is in production. The Broker matrix then shows which consumer and provider versions have a successful verification and which versions are deployed in each environment.

The Right Gate for a Breaking Change

The consumer test does not start failing when someone changes the provider hours or days later. Blocking happens at two coordinated points:

  • provider CI fetches the Pact and fails when it verifies an incompatible response;
  • before deployment, both consumer and provider query can-i-deploy for their own version.

For the consumer:

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"

The job must preserve the command output and its return code. Code 0 releases the next stage; code 1 stops the deployment. Also consult the Broker matrix and the official can-i-deploy explanation when the gate rejects a version.

Two scenarios make the responsibility clear:

  • if the provider removes price, its verification fails; the CI log shows the difference, and can-i-deploy does not authorize that version against the deployed consumers;
  • if the consumer publishes a new interaction, its version cannot be deployed until a compatible provider verifies it.

Pending pacts prevent a new, not-yet-verified contract from immediately breaking provider CI. They do not authorize deployment: the missing verification remains visible in the matrix and blocks can-i-deploy.

Pitfalls and Limits That Matter

Use one playbook to diagnose the integration:

SignalConcrete fixSafe observation in CI or Broker
The provider returns 404 for the happy pathImplement @State("product 42 exists") to prepare the dataInteraction log in provider verification
A field change goes unnoticedVerify the Pact in provider CI and use explicit matchers for relevant fieldsBody difference in the log and verification result in the matrix
The matrix accumulates ambiguous versionsPublish consumer and provider with the Git SHA, not 1.0-SNAPSHOTIdentifiable versions and branches in the Broker
Verification passes, but the result does not appearForward pact.verifier.publishResults=true and the version through the Surefire JVMResult absent or present in the Broker matrix
The pipeline publishes and deploys without checking compatibilityRun can-i-deploy immediately before deploymentExit code 1 blocks the job

Avoid matchers so broad that any object passes and exact responses so strict that harmless differences, such as variable values, become significant. The contract should express what the consumer needs: names, types, formats, and relevant states.

Contract tests do not replace load tests or end-to-end flows. They do not prove capacity, a real database, network policies, production authentication, clocks, or a business process spanning multiple services. Nor do they eliminate consumer unit tests for mapping and error handling. Pact's role is narrower: verify that request and response examples used by known versions remain compatible.

DevDojo's recommendation is firm: adopt Pact when consumer and provider have independent deployment cycles and a payload break must prevent promotion between environments. Start with one important endpoint, verification in provider CI, and the Broker as a gate. If the services are always released together and a short integration test already covers the boundary clearly, the cost of maintaining states, versions, and the Broker may not pay off.

Next Step

Choose a call that would already cause an impact if a field disappeared. Generate the first Pact, have the provider verify it, and add can-i-deploy to the existing job. There is no need to rewrite the testing pyramid; just stop letting the contract between services exist only in the team's memory.

javaspring-boottestingpact

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

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles