Available Hire Me
← All Writing Testing

Contract Testing with Pact in Spring Boot Microservices

How to implement consumer-driven contract testing with Pact in Spring Boot — writing consumer pacts, verifying providers, and wiring to a Pact Broker in CI.

In a microservices system, integration tests that spin up multiple services are slow, brittle, and hard to maintain. Contract testing solves this by splitting verification in two: consumers define what they expect from a provider, providers prove they deliver it — without either side needing the other to be running.

Pact is the dominant Java library for this. This post covers the full workflow: writing a consumer pact test in Spring Boot, verifying it against a Spring Boot provider, and publishing contracts to a Pact Broker for team-wide CI visibility.

What contract testing is and isn’t

Contract testing verifies the shape of the interface — request structure, response structure, status codes — not the business logic behind it. It replaces integration tests for the API boundary, not unit tests for service internals.

The consumer owns the contract. The provider doesn’t decide what it looks like — it just has to pass it.

Dependencies

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

The scenario

A TradeService (consumer) calls an AccountService (provider) to fetch account balance before placing an order. The consumer defines what response shape it needs; the provider verifies it delivers exactly that.

Writing the consumer test

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "AccountService", port = "8090")
class AccountClientPactTest {

    @Pact(consumer = "TradeService")
    public RequestResponsePact accountBalancePact(PactDslWithProvider builder) {
        return builder
            .given("account 12345 exists with balance 1000.00")
            .uponReceiving("a request for account balance")
                .path("/accounts/12345/balance")
                .method("GET")
                .headers(Map.of("Accept", "application/json"))
            .willRespondWith()
                .status(200)
                .headers(Map.of("Content-Type", "application/json"))
                .body(new PactDslJsonBody()
                    .stringType("accountId", "12345")
                    .decimalType("availableBalance", 1000.00)
                    .stringType("currency", "GBP")
                    .booleanType("active", true))
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "accountBalancePact")
    void fetchAccountBalance_returnsMappedBalance(MockServer mockServer) {
        var client = new AccountClient("http://localhost:8090");
        var balance = client.getBalance("12345");

        assertThat(balance.accountId()).isEqualTo("12345");
        assertThat(balance.availableBalance()).isEqualByComparingTo(new BigDecimal("1000.00"));
        assertThat(balance.currency()).isEqualTo("GBP");
        assertThat(balance.active()).isTrue();
    }
}

PactDslJsonBody uses matchers (stringType, decimalType) rather than exact values. You’re verifying shape, not a specific balance figure. If you hardcode 1000.00 as an exact match, every different account balance will fail the contract check on the provider side — which defeats the point.

The client under test

@Component
public class AccountClient {

    private final RestClient restClient;

    public AccountClient(@Value("${account-service.url}") String baseUrl) {
        this.restClient = RestClient.builder()
            .baseUrl(baseUrl)
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            .build();
    }

    public AccountBalance getBalance(String accountId) {
        return restClient.get()
            .uri("/accounts/{id}/balance", accountId)
            .retrieve()
            .body(AccountBalance.class);
    }
}

public record AccountBalance(
    String accountId,
    BigDecimal availableBalance,
    String currency,
    boolean active
) {}

Publishing the contract

Running the consumer test generates a pacts/ directory with the JSON contract. Publish it to a Pact Broker (self-hosted or PactFlow) via the Maven plugin:

<plugin>
    <groupId>au.com.dius.pact.provider</groupId>
    <artifactId>maven</artifactId>
    <version>4.6.15</version>
    <configuration>
        <pactBrokerUrl>https://your-broker.pactflow.io</pactBrokerUrl>
        <pactBrokerToken>${env.PACT_BROKER_TOKEN}</pactBrokerToken>
        <tags>
            <tag>${env.GIT_BRANCH}</tag>
        </tags>
        <consumerVersion>${project.version}</consumerVersion>
    </configuration>
</plugin>

Provider verification

On the provider side, a Spring Boot test loads the application context and verifies the pact:

@Provider("AccountService")
@PactBroker(
    url = "${PACT_BROKER_URL}",
    authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}")
)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AccountServicePactVerificationTest {

    @LocalServerPort
    int port;

    @MockBean
    AccountRepository accountRepository;

    @BeforeEach
    void setup(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }

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

    @State("account 12345 exists with balance 1000.00")
    void accountWithBalance() {
        var account = new Account("12345", new BigDecimal("1000.00"), "GBP", true);
        given(accountRepository.findById("12345")).willReturn(Optional.of(account));
    }
}

The @State method string must match the given(...) string in the consumer pact exactly. When the broker sends the pact to the provider, Pact calls the matching state method to seed the right data before making the request.

The provider endpoint

@RestController
@RequestMapping("/accounts")
class AccountController {

    private final AccountRepository repository;

    @GetMapping("/{id}/balance")
    ResponseEntity<AccountBalanceResponse> getBalance(@PathVariable String id) {
        return repository.findById(id)
            .map(account -> ResponseEntity.ok(new AccountBalanceResponse(
                account.id(),
                account.balance(),
                account.currency(),
                account.active()
            )))
            .orElse(ResponseEntity.notFound().build());
    }
}

record AccountBalanceResponse(
    String accountId,
    BigDecimal availableBalance,
    String currency,
    boolean active
) {}

The field names here must match what the consumer’s PactDslJsonBody expects. If you rename availableBalance to balance on the provider without updating the consumer pact, verification fails — which is exactly the point.

Running in CI

The pipeline flow:

  1. Consumer CI: run consumer pact tests → publish contract to broker with the consumer version and branch tag
  2. Provider CI: fetch contracts from broker → run provider verification → publish verification results

Gate deployments with can-i-deploy:

pact-broker can-i-deploy \
  --pacticipant TradeService \
  --version $GIT_COMMIT \
  --to-environment production

This blocks a deployment if any provider hasn’t verified the consumer’s current contract — the right place to catch interface breakage before it reaches production.

What to watch out for

State teardown matters. If a @State method sets up a mock that bleeds into the next interaction, pact tests give false positives. Reset mocks in @AfterEach or use @State(value = "...", action = StateChangeAction.TEARDOWN) for explicit cleanup.

Don’t overspecify in consumer pacts. The more fields you assert and the more exact the matching, the more brittle the pact. Match on shape and type unless exact values genuinely matter to the consumer’s behaviour.

Pact is not a substitute for end-to-end tests. It verifies the contract boundary, not the full integration path including auth, network behaviour, or downstream side effects. Use it alongside a small number of smoke tests against a real environment.

Use webhooks for triggered verification. Configure the Pact Broker to trigger the provider’s CI pipeline when a new consumer pact is published. Without this, providers only re-verify on their own schedule and breaking changes go undetected longer than they should.

Contract testing scales well once established, but the initial CI wiring takes a day. If you’re introducing Pact into a legacy microservices estate, let’s talk.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.