Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 067 / 100

Contract testing Spring Boot APIs in Java & Spring Boot

Catch breaking API changes before consumers do.

Expona Admin7 min read

Contract testing Spring Boot APIs in Java & Spring Boot

Your microservices are humming along until someone deploys a breaking API change on Friday afternoon, and suddenly a dozen downstream services fail in production. Contract testing Spring Boot APIs in Java & Spring Boot catches these breaks before they reach your users—by verifying that producers and consumers agree on the shape of their data before code ever hits staging or production.

This article walks through why contract testing matters, how consumer-driven contracts work, which tools fit Spring Boot best, and how to version contracts so you know exactly which consumers a change impacts.

The problem — Integration environments are slow and flaky

Most teams validate API compatibility by running end-to-end tests in a shared integration environment. Spin up all your services, seed data, hit the APIs, check responses. It sounds reasonable until 3am when a flaky database migration breaks your test suite for six hours, blocking all deployments.

Integration tests also give late feedback. If your payment service changes its response format, you might not discover the order service is broken until those tests run—which could be hours or days after the code was merged. By then, other work is piled on top, and the fix is expensive.

Contract testing flips this around. Instead of spinning up the entire ecosystem, each service verifies it honors the contracts its consumers depend on—right in your local build or CI pipeline, in seconds. You catch breaking changes the moment the code is written, not when it's in staging.

A practical scenario: your product service publishes a new `/products/{id}` endpoint. The mobile app team, order service, and recommendation engine all consume it. Without contract testing, you might deploy a schema change that removes the `sku` field. The mobile app wouldn't know until QA hits the updated staging environment. With contracts, your CI would fail immediately because the mobile app's contract expects `sku` to exist.

Consumer-driven contracts — Consumers publish expectations; providers verify them in CI

Consumer-driven contract testing inverts the typical dependency direction. Instead of the provider dictating what consumers must accept, consumers publish their expectations, and the provider verifies it meets them.

The workflow looks like this:

  1. The mobile app team writes a contract defining what they expect from the product service API.
  2. They check this contract into a shared repository (or broker service).
  3. The product service CI pipeline runs contract tests against that contract, using mock data that satisfies it.
  4. If the product service code violates the contract, the build fails before the code merges.
  5. If a consumer needs a new field, they update their contract first and commit it; the provider is alerted and implements it.

This approach scales better than provider-driven contracts because the consumer is closest to their own needs. They don't have to wait for the provider to guess what they want; they declare it upfront.

Real example: imagine your order service depends on a user service endpoint `/users/{id}` that returns a user object. Your contract specifies:

  • userId (string, required)
  • email (string, required)
  • accountStatus (enum: active, suspended, inactive)

Six months later, the user service team wants to remove accountStatus and replace it with a permissions array. Before they commit that change, their CI runs your order service contract against their new code. The contract test fails because the response is missing accountStatus. The user service team reaches out: "We're changing this field. Can you update your code to use permissions instead?" Now you can plan the migration together instead of discovering it in production.

Tools — Pact or Spring Cloud Contract both work with JUnit

Two mature frameworks dominate contract testing in the Java ecosystem: Pact and Spring Cloud Contract. Both integrate cleanly with JUnit and fit naturally into Spring Boot projects.

Pact

Pact is language-agnostic and consumer-focused. The consumer side writes expectations in code; Pact generates a JSON contract file. The provider imports that contract and verifies it.

Consumer test example (order service consuming product service):

java
@ExtendWith(PactConsumerTestExt.class)
class ProductServiceConsumerTest {

  @Pact(consumer = "OrderService", provider = "ProductService")
  public V4Pact createPact(PactBuilder builder) {
    return builder
      .given("product 123 exists")
      .uponReceiving("a request for product details")
      .path("/products/123")
      .method("GET")
      .willRespondWith()
      .status(200)
      .body(new PactJsonBuilder()
        .stringType("id", "123")
        .stringType("name", "Widget")
        .decimalType("price", 19.99)
        .build())
      .toPact(V4Pact.class);
  }

  @Test
  void testProductServiceReturnsProduct(MockServer mockServer) {
    ProductServiceClient client = new ProductServiceClient(mockServer.getUrl());
    Product product = client.getProduct("123");
    
    assertThat(product.getId()).isEqualTo("123");
    assertThat(product.getPrice()).isEqualTo(19.99);
  }
}

Provider test (product service verifying it honors the contract):

java
@SpringBootTest
@Provider("ProductService")
@PactFolder("pacts")
class ProductServiceProviderTest {

  @Autowired
  private ProductController controller;

  @TestTemplate
  @ExtendWith(PactVerificationSpringExtension.class)
  void verifyPacts(PactVerificationContext context) {
    context.verifyInteraction();
  }

  @RestControllerAdvice
  class RestControllerConfig {
    // Spring Boot sets up normally
  }
}

Pact shines when you have multiple consumers written in different languages (Go microservice, Node.js backend, Python worker). Pact runs on all of them.

Spring Cloud Contract

Spring Cloud Contract is tightly integrated with Spring Boot and Maven/Gradle. You define contracts in Groovy or YAML, and it generates both consumer and provider tests automatically.

Contract definition (in `src/test/resources/contracts`):

java
Contract.make {
  description("should return product when id exists")
  request {
    method GET()
    urlPath("/products/123")
  }
  response {
    status OK()
    body(
      id: "123",
      name: "Widget",
      price: 19.99
    )
    headers {
      contentType(applicationJson())
    }
  }
}

Provider test (auto-generated, you just implement the stub):

java
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class ProductServiceContractTest extends ContractVerifierBase {

  @Autowired
  private ProductController controller;

  @Test
  void shouldReturnProductWhenIdExists() {
    // Auto-generated; Spring Boot handles the rest
  }
}

Spring Cloud Contract is leaner if your whole stack is Spring Boot. It integrates deeply with Spring Test and requires less boilerplate.

Which to choose? Use Pact if you have polyglot services. Use Spring Cloud Contract if you're all-in on Spring Boot and want less ceremony.

Version your contracts — Tie them to releases so you know which consumers a change affects

A contract without versioning is a contract you can't safely evolve. When the product service needs to add a new optional field, you need to know: which consumers have deployed code that depends on this field not existing? Which ones can handle it?

Versioning strategies:

Semantic versioning in contract names

Name contracts by release version:

  • ProductService-v1.2.0.json
  • ProductService-v1.3.0.json

Tag each contract with the earliest and latest consumer version that works with it. When you add an optional field in v1.3.0, mark it: "works with consumers v1.2.0+". If you remove a required field, that's a breaking change; bump to v2.0.0 and note "requires consumers v2.0.0+."

Contract broker with versioning

Tools like PactFlow store contracts with metadata: consumer name, consumer version, provider version, timestamp. When you publish a contract, you tag it:

java
// CLI or CI step
pact-broker publish pacts/ \
  --consumer-app-version=1.2.3 \
  --branch=main

The broker tracks which versions of consumers are compatible with which versions of providers. Your CI can query: "I'm about to release provider v2.0.0; which consumers break?" The broker tells you: OrderService v1.x breaks; you need OrderService v2.0.0 to be compatible.

Real-world scenario

Your payment service is the provider. Three consumers depend on it: web app, mobile app, invoicing service.

  • Web app is at v3.2.1; mobile at v2.8.0; invoicing at v1.5.0.
  • You want to add an optional paymentMetadata field in payment service v4.0.0.
  • You check the contract broker: mobile app v2.8.0's contract doesn't mention this field, which is fine (it's optional).
  • You deploy v4.0.0. Mobile app still works because it ignores unknown fields.
  • Later, you want to make paymentMetadata` required. The broker alerts you: mobile v2.8.0 will break. You open an issue: "Mobile team, please upgrade to v3.0.0 which expects this field."

Without versioning, you have no systematic way to answer "Who breaks if I change this?"

Common mistakes

Over-specifying contracts. A contract that says "the timestamp will be exactly 2024-01-15T10:30:45Z" will fail every run. Use matchers: "the timestamp will be an ISO-8601 string." The contract documents the shape, not hardcoded test data.

Treating contracts as integration tests. Contracts live in isolation. The provider test runs with a stub; the consumer test runs with a mock. They never spin up a real database or call a real downstream service. If you need to test database behavior or complex orchestration, write integration tests separately.

Ignoring the consumer's perspective. Some teams write contracts as "whatever the provider currently returns." That defeats the purpose. Contracts should be written by the consumer, expressing what they actually need. The provider then commits to meeting that expectation.

Not publishing contracts to a broker. Contracts live in files in a git repo, scattered across multiple services. When the product service team makes a change, they manually check if order service, mobile app, and analytics care. Use a broker (PactFlow, Artifactory). It centralizes contracts and alerts both sides when something breaks.

Key takeaways

  • Contract testing validates API compatibility in seconds, not hours, without spinning up integration environments.
  • Consumer-driven contracts let consumers declare what they need; providers verify they honor those expectations in CI.
  • Pact works across any language; Spring Cloud Contract integrates tightly with Spring Boot. Pick based on your stack.
  • Version contracts and tie them to consumer and provider releases. A contract broker answers "Who breaks if I deploy this?"
  • Contracts test shape and behavior, not hardcoded data. Use matchers for dynamic values like timestamps.
  • Contracts complement integration tests; they don't replace them. Use contracts for fast feedback on compatibility, integration tests for database and orchestration logic.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles