Contract Testing with Spring Cloud Contract — Consumer-Driven Contracts
Ensure microservices don't break each other with Spring Cloud Contract. Covers consumer-driven contracts, Groovy DSL, auto-generated tests, stub runner, and the workflow between producer and consumer teams.
The Problem — Integration Testing Across Services
You have two microservices. The Order Service calls the Payment Service. One day, the Payment team renames a field from amount to totalAmount. Their tests pass. They deploy. Your Order Service breaks in production.
This happens because:
- Unit tests don’t catch it (they mock the HTTP call)
- Integration tests are slow and flaky (they need both services running)
- E2E tests catch it too late (after deployment)
Contract testing solves this: the consumer defines what it expects, the producer verifies it can deliver.
What Are Contracts
A contract is a formal agreement between two services about the shape of their HTTP interaction.
graph LR
A[Consumer<br/>Order Service] -->|"Defines expectations<br/>(what I call, what I expect)"| B[Contract<br/>Groovy DSL]
B -->|"Auto-generates tests"| C[Producer<br/>Payment Service]
C -->|"Generates stubs"| D[Stub JAR]
D -->|"Used in tests"| A
The contract lives in the producer’s repository but represents the consumer’s expectations. If the producer breaks the contract, their build fails — before deployment.
Setup
Parent POM (Spring Cloud BOM)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<properties>
<spring-cloud.version>2024.0.0</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Producer Dependencies
1
2
3
4
5
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
Consumer Dependencies
1
2
3
4
5
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>
Writing a Contract — Groovy DSL
Contracts are written in Groovy and placed in src/test/resources/contracts/:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import org.springframework.cloud.contract.spec.Contract
Contract.make {
name "should return payment by id"
description "When GET /api/payments/1 is called, return payment details"
request {
method GET()
url "/api/payments/1"
headers {
contentType(applicationJson())
}
}
response {
status OK()
headers {
contentType(applicationJson())
}
body(
id: 1,
orderId: "ORD-2024-001",
amount: 99.99,
currency: "USD",
status: "COMPLETED"
)
bodyMatchers {
jsonPath('$.id', byType())
jsonPath('$.orderId', byRegex('[A-Z]{3}-\\d{4}-\\d{3}'))
jsonPath('$.amount', byType())
jsonPath('$.currency', byRegex('[A-Z]{3}'))
jsonPath('$.status', byRegex('COMPLETED|PENDING|FAILED'))
}
}
}
Key elements:
body(...)— the concrete example used for stubsbodyMatchers— flexible matchers for the generated tests (types, regexes)
Producer Side — Auto-Generated Tests
When you run mvn clean test on the producer, Spring Cloud Contract:
- Reads contracts from
src/test/resources/contracts/ - Generates JUnit 5 test classes
- Runs them against your controller using a base test class
The base test class sets up MockMvc:
1
2
3
4
5
6
7
8
9
10
11
12
13
@SpringBootTest
public abstract class BaseContractTest {
@Autowired
private PaymentController paymentController;
@BeforeEach
void setup() {
StandaloneMockMvcBuilder standaloneMockMvcBuilder =
MockMvcBuilders.standaloneSetup(paymentController);
RestAssuredMockMvc.standaloneSetup(standaloneMockMvcBuilder);
}
}
Configure the plugin to use this base class:
1
2
3
4
5
6
7
8
9
10
11
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<extensions>true</extensions>
<configuration>
<testFramework>JUNIT5</testFramework>
<baseClassForTests>
com.anupam.contract.producer.BaseContractTest
</baseClassForTests>
</configuration>
</plugin>
If the producer changes the response shape (renames a field, changes a status code), the auto-generated test fails.
Consumer Side — StubRunner
The consumer doesn’t call the real producer. Instead, it uses stubs generated from the contracts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.anupam:contract-testing-producer:+:stubs:8090",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class PaymentClientContractTest {
@Test
void shouldGetPaymentFromProducerStub() {
PaymentClient paymentClient = new PaymentClient("http://localhost:8090");
PaymentClient.PaymentResponse payment = paymentClient.getPayment(1L);
assertThat(payment).isNotNull();
assertThat(payment.id()).isEqualTo(1L);
assertThat(payment.orderId()).matches("[A-Z]{3}-\\d{4}-\\d{3}");
assertThat(payment.amount()).isGreaterThan(BigDecimal.ZERO);
assertThat(payment.currency()).matches("[A-Z]{3}");
assertThat(payment.status()).isIn("COMPLETED", "PENDING", "FAILED");
}
}
@AutoConfigureStubRunner starts a WireMock server on port 8090 loaded with the producer’s stubs. The consumer’s HTTP client calls this stub server exactly like it would call the real producer.
The Workflow
sequenceDiagram
participant Consumer Team
participant Producer Repo
participant CI/CD
participant Artifact Repo
Consumer Team->>Producer Repo: PR with new contract (shouldReturnPayment.groovy)
Producer Repo->>CI/CD: Build triggered
CI/CD->>CI/CD: Generate tests from contracts
CI/CD->>CI/CD: Run tests against producer code
alt Tests pass
CI/CD->>Artifact Repo: Publish stub JAR
CI/CD-->>Consumer Team: PR merged, stubs available
Consumer Team->>Artifact Repo: Pull stubs
Consumer Team->>Consumer Team: Run tests with StubRunner
else Tests fail
CI/CD-->>Producer Repo: Build fails
CI/CD-->>Consumer Team: Producer doesn't support this contract yet
end
This workflow ensures:
- Breaking changes are caught before deployment
- Consumer and producer can develop independently
- Stubs are always in sync with the producer’s actual behavior
Contract Testing vs Integration vs E2E
| Aspect | Contract Tests | Integration Tests | E2E Tests |
|---|---|---|---|
| Speed | Fast (ms) | Slow (seconds) | Very slow (minutes) |
| Infrastructure | None (stubs) | All services running | Full environment |
| Flakiness | Low | Medium | High |
| Catches API breaks | Yes | Yes | Yes |
| Catches logic bugs | No | Yes | Yes |
| Catches UI bugs | No | No | Yes |
| Maintenance cost | Low | Medium | High |
| Feedback speed | Immediate (in CI) | After deployment | After full deploy |
| Runs where | Unit test phase | Separate phase | Staging environment |
Contract tests fill the gap between unit tests and integration tests. They verify that services can talk to each other without requiring all services to be running.
Common Problems
| Problem | Cause | Solution |
|---|---|---|
| “No base class found” | Plugin misconfigured | Set baseClassForTests in spring-cloud-contract-maven-plugin |
| Stub not found | Producer not installed locally | Run mvn install on producer first |
| Port conflict | StubRunner port in use | Change port in @AutoConfigureStubRunner(ids = "...:stubs:9090") |
| Contract body mismatch | Controller returns different structure | Fix controller or update contract |
| “No matching stub” | Request doesn’t match contract | Check URL, method, and headers in contract |
| Groovy compilation error | Syntax error in contract | Validate Groovy DSL (watch for missing commas) |
| Spring Cloud version mismatch | BOM not imported | Add spring-cloud-dependencies BOM to dependencyManagement |
When to Use Contracts
Contract testing works best for:
- Microservices owned by different teams — the contract is the handshake
- APIs that evolve frequently — catch breaking changes immediately
- Services with many consumers — one contract per consumer, all verified
Skip contracts when:
- You own both services and deploy them together (just write integration tests)
- The API is truly public with unknown consumers (use OpenAPI + semantic versioning)
- The interaction is fire-and-forget messaging with no response (use schema registries instead)
Full Working Example
Complete producer and consumer projects:
https://github.com/AnupamSinha/spring-boot-examples/tree/main/31-contract-testing
1
2
3
4
5
6
7
# Build producer (generates stubs)
cd 31-contract-testing/producer
mvn clean install
# Run consumer tests (uses stubs)
cd ../consumer
mvn clean test

