00:00

Contract Testing in Microservices: Catch Breaking API Changes Before They Ship

Last updated:

Contract testing in microservices checks that two services still agree on how they talk to each other, without starting both of them together. The consumer, the service that calls an API, writes down what it expects, and the provider, the service that offers the API, proves in its own build that it still meets those expectations. If someone renames a field or changes a type, the provider's build fails long before production does.

It fills the gap between fast unit tests, which can't see other services, and slow end-to-end tests, which need every service running at once.

1. Why Microservices Need Contract Tests

Imagine a checkout web app that calls an order service to show an order's total. The order team decides total should be called totalAmount, a harmless-looking cleanup. Their unit tests pass, because they updated them. The checkout team's unit tests also pass, because they mock the order service with the old field name. Everything is green, and the checkout page shows "€0.00" in production.

The bug lives between the services, where neither team's tests look. The traditional answer is end-to-end testing: deploy every service into a shared environment and click through the app. That works, but it is slow, fragile, and tells you about problems late. With twenty services, the shared test environment is often broken for reasons that have nothing to do with your change.

Contract tests catch this kind of break in each team's own build, in seconds, using only one service at a time.

2. Key Terms

  • Consumer: the service or app that calls an API or reads a message. Here, the checkout web app.
  • Provider: the service that offers the API or publishes the message. Here, the order service.
  • Interaction: one request and the response the consumer expects, such as GET /orders/A-17 returning status 200 with id and total.
  • Contract: a file listing all interactions one consumer relies on. In Pact, the most popular tool, it is a JSON file called a pact.
  • Provider state: a precondition the provider must set up before an interaction is replayed, such as "order A-17 exists".
  • Broker: a server that stores contracts and verification results, such as the Pact Broker or PactFlow.

Contracts are consumer-driven: they list only what a consumer actually uses, not the provider's whole API. If no consumer reads the currency field, the provider is free to change it.

3. How Consumer-Driven Contract Testing Works

Consumer-driven contract testing workflowStep 1: the consumer test runs the consumer's client against a mock provider and generates a contract file. Step 2: the contract is published to a broker. Step 3: the provider's build fetches the contracts from the broker and replays each request against the real provider application. Step 4: the verification results are published back to the broker. Step 5: before deploying, each pipeline asks the broker whether the versions are compatible. CONSUMER BUILD PROVIDER BUILD Consumer test client runs against a mock provider Contract file expected requests and responses Contract broker contracts + verification results Provider verification replays each recorded request Real provider app started in a test environment can-i-deploy check 1. generate 2. publish 3. fetch 4. results replay requests 5. ask before deploying
The consumer's test produces the contract, the provider's build proves it can honour it, and the broker remembers which versions work together so either side can check before it deploys.
  1. The consumer test generates the contract. The consumer's real HTTP client runs against a mock provider that the tool starts. Each expected interaction is recorded into the contract file.
  2. The contract is published to the broker, tagged with the consumer's version.
  3. The provider verifies it. In the provider's build, the tool fetches all contracts that concern it, sets up each provider state, replays every recorded request against the real provider app and compares the actual response with the expected one.
  4. The results are published back to the broker.
  5. Before deploying, each pipeline asks the broker whether the version it is about to deploy has been verified against the versions running in the target environment.
What happens internally: the comparison in step 3 is not "is the response identical?". Tools use matchers: rules such as "total is a number" or "id matches this pattern". Extra fields in the response are allowed, because a consumer ignores fields it does not read. This is why providers can add fields freely but cannot rename or remove ones that consumers use.

4. Code Examples

The real tools (Pact JVM, Spring Cloud Contract) need build plugins, so these JDK-only Java 21 programs show the mechanics they implement.

4.1 Example 1: Checking a Response Against a Contract

The consumer needs three fields with specific types. Four provider versions are checked against that contract.

ContractCheckDemo.java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class ContractCheckDemo {

    // What the consumer (checkout page) needs from GET /orders/{id}: field name -> expected type
    static final Map<String, Class<?>> CONTRACT = new LinkedHashMap<>();
    static {
        CONTRACT.put("id", String.class);
        CONTRACT.put("status", String.class);
        CONTRACT.put("total", Double.class);
    }

    static List<String> verify(Map<String, Object> response) {
        List<String> problems = new ArrayList<>();
        CONTRACT.forEach((field, type) -> {
            Object value = response.get(field);
            if (value == null) problems.add("missing field '" + field + "'");
            else if (!type.isInstance(value)) problems.add("'" + field + "' should be " + type.getSimpleName()
                    + " but was " + value.getClass().getSimpleName());
        });
        return problems; // extra fields are ignored on purpose
    }

    static void check(String version, Map<String, Object> response) {
        List<String> problems = verify(response);
        System.out.println(version + " -> " + (problems.isEmpty() ? "PASS" : "FAIL " + problems));
    }

    public static void main(String[] args) {
        check("order-service v1 (original)",
                Map.of("id", "A-17", "status", "PAID", "total", 59.0));
        check("order-service v2 (adds a field)",
                Map.of("id", "A-17", "status", "PAID", "total", 59.0, "currency", "EUR"));
        check("order-service v3 (renames total)",
                Map.of("id", "A-17", "status", "PAID", "totalAmount", 59.0));
        check("order-service v4 (total as text)",
                Map.of("id", "A-17", "status", "PAID", "total", "59.00"));
    }
}
Output
order-service v1 (original) -> PASS
order-service v2 (adds a field) -> PASS
order-service v3 (renames total) -> FAIL [missing field 'total']
order-service v4 (total as text) -> FAIL ['total' should be Double but was String]

Adding currency is safe; renaming total and changing it from a number to text both break the consumer. Notice v4: the field still exists, so a check that only looks at field names would pass. Contracts should check types, not only names.

4.2 Example 2: Both Sides of the Contract

This program plays both roles. First, the consumer's real client runs against a stub built from the contract. Then the same recorded request is replayed against two provider versions.

ConsumerProviderContractDemo.java
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ConsumerProviderContractDemo {

    // The contract: one recorded interaction, listing only the fields the consumer reads
    record Interaction(String path, int status, String exampleBody) {}

    static final Interaction GET_ORDER =
            new Interaction("/orders/A-17", 200, "{\"id\":\"A-17\",\"total\":59.0}");

    // The consumer's real client code: it only reads the fields it needs
    record OrderSummary(String id, double total) {}

    static OrderSummary fetchOrder(HttpClient http, String baseUrl) throws Exception {
        HttpResponse<String> response = http.send(
                HttpRequest.newBuilder(URI.create(baseUrl + GET_ORDER.path())).build(),
                HttpResponse.BodyHandlers.ofString());
        Map<String, String> json = parseFlatJson(response.body());
        return new OrderSummary(json.get("id"), Double.parseDouble(json.get("total")));
    }

    // Starts an HTTP server on a free port that answers GET_ORDER.path() with the given body
    static HttpServer serve(int status, Supplier<String> body) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
        server.createContext(GET_ORDER.path(), exchange -> {
            byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().add("Content-Type", "application/json");
            exchange.sendResponseHeaders(status, bytes.length);
            exchange.getResponseBody().write(bytes);
            exchange.close();
        });
        server.start();
        return server;
    }

    public static void main(String[] args) throws Exception {
        HttpClient http = HttpClient.newHttpClient();

        // Step 1 - consumer test: run the real client against a stub built from the contract
        HttpServer stub = serve(GET_ORDER.status(), GET_ORDER::exampleBody);
        OrderSummary summary = fetchOrder(http, "http://localhost:" + stub.getAddress().getPort());
        stub.stop(0);
        System.out.println("Consumer test against stub: " + summary);

        // Step 2 - provider verification: replay the same request against the real provider
        String[] providerBodies = {
                "{\"id\":\"A-17\",\"status\":\"PAID\",\"total\":59.0,\"currency\":\"EUR\"}",
                "{\"id\":\"A-17\",\"status\":\"PAID\",\"grandTotal\":59.0}"
        };
        for (String body : providerBodies) {
            HttpServer provider = serve(200, () -> body);
            String url = "http://localhost:" + provider.getAddress().getPort() + GET_ORDER.path();
            HttpResponse<String> actual = http.send(HttpRequest.newBuilder(URI.create(url)).build(),
                    HttpResponse.BodyHandlers.ofString());
            provider.stop(0);
            Map<String, String> expected = parseFlatJson(GET_ORDER.exampleBody());
            Map<String, String> got = parseFlatJson(actual.body());
            boolean ok = actual.statusCode() == GET_ORDER.status() && got.keySet().containsAll(expected.keySet());
            System.out.println("Provider verification " + got.keySet() + " -> " + (ok ? "PASS" : "FAIL"));
        }
    }

    // Tiny parser for flat JSON objects, enough for this demo
    static Map<String, String> parseFlatJson(String json) {
        Map<String, String> fields = new LinkedHashMap<>();
        Matcher m = Pattern.compile("\"(\\w+)\":\\s*(\"([^\"]*)\"|[^,}]+)").matcher(json);
        while (m.find()) fields.put(m.group(1), m.group(3) != null ? m.group(3) : m.group(2).trim());
        return fields;
    }
}
Output
Consumer test against stub: OrderSummary[id=A-17, total=59.0]
Provider verification [id, status, total, currency] -> PASS
Provider verification [id, status, grandTotal] -> FAIL

The contract only lists id and total, the fields fetchOrder actually reads. That is the key idea of consumer-driven contracts: the first provider sends status and currency too, which the contract happily ignores. The second renamed total to grandTotal and fails verification in the provider's own build, not in production.

4.3 Example 3: Can I Deploy?

A passing contract on a developer's branch means little if production runs different versions. The broker keeps a matrix of which consumer versions were verified against which provider versions, and pipelines query it before deploying.

CanIDeployDemo.java
import java.util.List;
import java.util.Map;

public class CanIDeployDemo {

    // One row in the broker: this consumer version's contract was checked against this provider version
    record Verification(String consumer, String consumerVersion, String providerVersion, boolean passed) {}

    static final List<Verification> RESULTS = List.of(
            new Verification("checkout-web", "3.4.0", "order-service 2.1.0", true),
            new Verification("checkout-web", "3.4.0", "order-service 2.2.0", true),
            new Verification("mobile-app", "7.0.1", "order-service 2.1.0", true),
            new Verification("mobile-app", "7.0.1", "order-service 2.2.0", false),
            new Verification("invoice-service", "1.9.0", "order-service 2.1.0", true));

    // Consumer versions currently running in production
    static final Map<String, String> IN_PRODUCTION = Map.of(
            "checkout-web", "3.4.0", "mobile-app", "7.0.1", "invoice-service", "1.9.0");

    static void canIDeploy(String providerVersion) {
        System.out.println("Can I deploy " + providerVersion + " to production?");
        boolean safe = true;
        for (String consumer : IN_PRODUCTION.keySet().stream().sorted().toList()) {
            String version = IN_PRODUCTION.get(consumer);
            String verdict = RESULTS.stream()
                    .filter(r -> r.consumer().equals(consumer) && r.consumerVersion().equals(version)
                            && r.providerVersion().equals(providerVersion))
                    .findFirst()
                    .map(r -> r.passed() ? "verified" : "FAILED")
                    .orElse("NOT VERIFIED"); // no result is not the same as a pass
            if (!verdict.equals("verified")) safe = false;
            System.out.printf("  %-16s %-6s %s%n", consumer, version, verdict);
        }
        System.out.println("  => " + (safe ? "yes" : "no"));
    }

    public static void main(String[] args) {
        canIDeploy("order-service 2.1.0");
        canIDeploy("order-service 2.2.0");
    }
}
Output
Can I deploy order-service 2.1.0 to production?
  checkout-web     3.4.0  verified
  invoice-service  1.9.0  verified
  mobile-app       7.0.1  verified
  => yes
Can I deploy order-service 2.2.0 to production?
  checkout-web     3.4.0  verified
  invoice-service  1.9.0  NOT VERIFIED
  mobile-app       7.0.1  FAILED
  => no

Version 2.2.0 is blocked for two different reasons. The mobile app's contract failed, and the invoice service was never verified against 2.2.0. Many teams treat "no result" as "probably fine". It isn't: a missing verification means nobody checked.

5. Contract Testing vs Other Kinds of Tests

Test typeWhat it checksNeeds other services running?SpeedFinds API breaks?
Unit testLogic inside one class or serviceNoVery fastNo, mocks hide them
Contract testThe API agreement between two servicesNo, one side at a timeFastYes, before deployment
Integration test (Testcontainers)A service with its real database or brokerOnly infrastructureMediumNo
End-to-end testA full user journey across servicesYes, all of themSlowYes, but late and flaky
Schema validation (OpenAPI)That responses match the provider's published specNoFastOnly if the spec is right and consumers follow it

Contract tests don't replace the others. They let you keep only a small number of end-to-end tests, for the most important journeys.

6. Common Mistakes and Pitfalls

1. Testing business logic in contracts. A contract checks the shape of the conversation, not whether discounts are calculated correctly. Put business rules in the provider's own unit tests.

2. Over-specifying values. Expecting exactly "total": 59.0 instead of "any number" makes contracts break on harmless data changes. Use matchers for values, and exact values only where they really matter, such as an enum the consumer switches on.

3. Contracts that nobody verifies. If the provider's build doesn't fetch and verify contracts, they are just documentation. Make verification part of the provider's CI pipeline and let it fail the build.

4. Forgetting provider states. "Order A-17 exists" must be set up by the provider before replay. Without it, verification fails randomly or depends on leftover test data.

5. Ignoring messages. Contracts aren't only for REST. Events published to Kafka or RabbitMQ break consumers just as easily. Pact and Spring Cloud Contract both support message contracts.

7. Best Practices

  • Write contracts from the consumer's real client code, not by hand, so they match what the code actually sends and reads.
  • Keep each contract minimal: only the fields and interactions the consumer uses.
  • Run provider verification on every provider build, and on a webhook whenever a consumer publishes a changed contract.
  • Use can-i-deploy as a deployment gate in every pipeline, on both sides.
  • Record which versions are deployed in each environment, so the broker can answer for the right versions.
  • Combine with tolerant readers: consumers should ignore unknown fields, so providers can add fields safely.
  • Talk before breaking: when a provider must remove a field, the failing contract tells them exactly which consumers to contact.

8. Summary

  • Contract testing in microservices checks the agreement between a consumer and a provider without running them together.
  • Consumer-driven contracts list only what the consumer actually uses.
  • The consumer test generates the contract; the provider's build replays and verifies it.
  • Matchers check types and patterns, and extra fields are allowed.
  • A broker stores contracts and results, and can-i-deploy blocks unsafe deployments.
  • Contracts complement unit, integration and a few end-to-end tests; they don't replace them.

Start with the one API that breaks most often, add a single consumer contract, and verify it in the provider's build. Once that loop works, contract testing in microservices becomes the cheapest safety net you have for independent deployments.