Contract Testing in Microservices: Catch Breaking API Changes Before They Ship
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-17returning status 200 withidandtotal. - 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
- 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.
- The contract is published to the broker, tagged with the consumer's version.
- 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.
- The results are published back to the broker.
- 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.
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.
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"));
}
}
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.
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;
}
}
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.
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");
}
}
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 type | What it checks | Needs other services running? | Speed | Finds API breaks? |
|---|---|---|---|---|
| Unit test | Logic inside one class or service | No | Very fast | No, mocks hide them |
| Contract test | The API agreement between two services | No, one side at a time | Fast | Yes, before deployment |
| Integration test (Testcontainers) | A service with its real database or broker | Only infrastructure | Medium | No |
| End-to-end test | A full user journey across services | Yes, all of them | Slow | Yes, but late and flaky |
| Schema validation (OpenAPI) | That responses match the provider's published spec | No | Fast | Only 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.
Contract Testing: Interview Questions
Common interview questions on Contract Testing, from basic to scenario-based, with short answers.
Basic questions
Q. What is the difference between consumer-driven and provider-driven contracts?
Answer: In consumer-driven contracts, each consumer defines what it needs, and the provider must satisfy all of them; Pact works this way. In provider-driven contracts, the provider defines the contract, often with Spring Cloud Contract or an OpenAPI spec, and consumers test against stubs generated from it. Consumer-driven contracts show exactly which fields are used; provider-driven contracts are simpler when the provider has many unknown consumers, such as a public API.
Q. How does Spring Cloud Contract work?
Answer: The provider team writes contracts in a Groovy or YAML DSL in its own repository, usually under src/test/resources/contracts. The build plugin generates tests that verify the provider against them, and also produces a stubs JAR with WireMock mappings. Consumers then run those stubs with Stub Runner in their own tests, so both sides test against the same contract.
Q. Should you use contract tests between a service and its own database?
Answer: No. Contract testing is for boundaries between independently deployed parts owned by different teams. A service and its own database change together and are covered by integration tests, for example with Testcontainers.
Intermediate questions
Q. What does a Pact consumer test look like in Java?
Answer: It defines the expected interaction and then runs the real client against Pact's mock server.
@Pact(consumer = "checkout-web", provider = "order-service")
V4Pact orderExists(PactDslWithProvider builder) {
return builder.given("order A-17 exists")
.uponReceiving("a request for order A-17")
.path("/orders/A-17").method("GET")
.willRespondWith().status(200)
.body(new PactDslJsonBody().stringType("id", "A-17").decimalType("total", 59.0))
.toPact(V4Pact.class);
}
stringType and decimalType are matchers: they check the type, and the values are only examples.
Q. What is a provider state, and how is it implemented on the provider side?
Answer: A provider state is a named precondition, such as "order A-17 exists", that the provider sets up before a request is replayed. In Pact JVM, the provider test has methods annotated with @State("order A-17 exists") that insert test data or configure stubs. This keeps each interaction independent of leftover data.
Q. How do you write a contract test for a Kafka event?
Answer: Use a message contract. The consumer test describes the expected message content and passes a sample to the real message handler; the provider test checks that the code which builds the event produces a matching message. No broker is needed for either side, because the contract checks the message content, not the transport.
Q. What are pending pacts and work-in-progress pacts?
Answer: A pending pact is a new or changed contract that the provider has not yet verified successfully; its failures are reported but don't break the provider's build. Work-in-progress pacts make the provider automatically verify new contracts from consumer feature branches. Together they let consumers propose changes without blocking the provider team's main branch.
Q. Can contract tests replace an OpenAPI specification?
Answer: No, they solve different problems. An OpenAPI spec documents the whole API for anyone, while contracts prove that specific consumers' real usage still works. Some tools, such as PactFlow's bi-directional contract testing, compare consumer contracts with the provider's OpenAPI spec, but the spec must then be tested against the real provider too.
Q. What is the tolerant reader pattern, and how does it relate to contract testing?
Answer: A tolerant reader ignores fields it doesn't recognise and reads only what it needs. With Jackson, for example, you disable FAIL_ON_UNKNOWN_PROPERTIES, which Spring Boot already does by default. Contract tests assume consumers are tolerant: they allow extra fields, so providers can add fields without breaking anyone.
Tricky and scenario questions
Q. Scenario: The provider must remove a field that one consumer still uses. How do you proceed?
Answer: The failing verification tells you exactly which consumer depends on the field. Agree on a plan with that team: they stop reading the field and publish a new contract, and once that consumer version is deployed everywhere, the provider can remove the field. can-i-deploy enforces this order automatically.
Q. Scenario: Contract tests pass, but production still breaks after a release. What could be wrong?
Answer: Common causes: the contract was verified against a different consumer version than the one in production; the provider state hid a real data problem; matchers were too loose, for example accepting any string where a specific format was needed; or the break was in behaviour, not shape, which contracts don't cover. Check that environments record deployed versions and that can-i-deploy runs against them.
Q. Scenario: Your company has one provider with 40 consumers, and contract verification takes 20 minutes. What do you do?
Answer: Run verification only for consumer versions that matter, such as the ones deployed in each environment and the main branch, instead of every branch. Make provider states fast, for example with in-memory stubs for downstream services, and run interactions in parallel where the tool allows. Also check whether some consumers can share a common client library with a single contract.
Q. Who should own a contract when the consumer and provider teams disagree?
Answer: The consumer owns its expectations, because it knows what it uses, but the provider decides whether it can support them. A contract is the start of a conversation, not an order. If a consumer asks for something the provider won't support, the failing verification makes that disagreement visible early, before anyone builds on it.