Consumer-driven contract testing with Spring Cloud Contract — covering the contract DSL, stub generation, stub runner, and integrating contract verification into CI/CD pipelines.
The hardest integration bugs in microservices are the ones that don’t show up until production. Two teams working in parallel, both confident their services are correct, both with green test suites — and a deployment that breaks because the consumer expected a field called claimReference and the producer renamed it to claimRef in a refactor three weeks ago. End-to-end integration tests catch this, but they’re slow, fragile, and require all services to be deployed simultaneously. Consumer-driven contract testing is the middle ground: fast, service-isolated, and catches API breaking changes before they ship.
Spring Cloud Contract implements this pattern cleanly in the Spring Boot ecosystem.
The consumer defines what it expects from the producer. That expectation becomes a contract — a formal specification of the API interaction. The producer verifies it can satisfy every contract. The consumer tests against a stub generated from the same contract.
The critical insight: the stub and the real implementation are verified against the same contract file. If the producer verification passes and the consumer stub test passes, they will work together in production. You don’t need them deployed together to know that.
Contracts live in the producer’s repository under src/test/resources/contracts/. They can be written in Groovy DSL or YAML. I prefer Groovy for REST contracts — the DSL is concise and handles request/response matching expressively.
A REST contract for an eligibility assessment endpoint:
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "Returns ELIGIBLE when a working-age claimant requests assessment"
request {
method POST()
url "/eligibility/assess"
headers {
contentType applicationJson()
}
body([
age: 30,
employmentStatus: "UNEMPLOYED"
])
}
response {
status 200
headers {
contentType applicationJson()
}
body([
outcome: "ELIGIBLE",
reasonCode: $(anyNonEmptyString())
])
}
}
The $(anyNonEmptyString()) matcher lets the producer return any non-empty reason code — the contract specifies shape and constraints, not exact values where variation is acceptable.
For Kafka event contracts, the DSL targets a message rather than HTTP:
Contract.make {
description "ClaimApproved event published when a claim is approved"
label "claim_approved"
input {
triggeredBy("approveClaim()")
}
outputMessage {
sentTo "benefit.claims.approved"
body([
claimId: $(anyNonEmptyString()),
approvedAt: $(anyIso8601WithOffset()),
assessorId: $(anyNonEmptyString())
])
headers {
header("contentType", applicationJson())
}
}
}
On the producer side, Spring Cloud Contract generates a base test class from each contract. You provide the base class that sets up the Spring context:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMessageVerifier
public abstract class ContractVerifierBase {
@Autowired
private EligibilityController eligibilityController;
@BeforeEach
void setup() {
RestAssuredMockMvc.standaloneSetup(eligibilityController);
}
// Method called by Kafka contract test
public void approveClaim() {
claimService.approve(new ClaimId("TEST-001"), new AssessorId("ASSESSOR-001"), "Test approval");
}
}
Run mvn verify. Spring Cloud Contract generates test classes from your contract files, executes them against your running application, and fails the build if any contract is violated. This is the producer side of the guarantee.
The plugin configuration in pom.xml:
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<extensions>true</extensions>
<configuration>
<baseClassForTests>
com.trinitylogic.eligibility.ContractVerifierBase
</baseClassForTests>
</configuration>
</plugin>
The producer build publishes stub JARs to your Maven repository. The consumer tests use StubRunner to pull those stubs and run them as local HTTP/messaging fakes:
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.trinitylogic:eligibility-service:+:stubs:8090",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class EligibilityClientTest {
@Autowired
private EligibilityClient eligibilityClient;
@Test
void shouldReceiveEligibleOutcomeForWorkingAgeClaimant() {
AssessmentRequest request = AssessmentRequest.builder()
.age(30)
.employmentStatus(EmploymentStatus.UNEMPLOYED)
.build();
AssessmentResult result = eligibilityClient.assess(request);
assertThat(result.getOutcome()).isEqualTo(AssessmentOutcome.ELIGIBLE);
}
}
The + version selector picks the latest published stub. StubsMode.LOCAL looks in your local Maven cache — use REMOTE in CI to pull from your Nexus or Artifactory instance.
The stub responds exactly as the contract specifies. If the consumer test passes and the producer verification passes against the same contract file, the services will work together.
For Kafka contracts, the consumer test triggers the label and asserts the event was received:
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.trinitylogic:claims-service:+:stubs",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class ClaimApprovedListenerTest {
@Autowired
private StubTrigger stubTrigger;
@Autowired
private ProcessedClaimRepository processedClaimRepository;
@Test
void shouldProcessClaimApprovedEvent() throws Exception {
stubTrigger.trigger("claim_approved");
await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(processedClaimRepository.count()).isGreaterThan(0)
);
}
}
The StubTrigger fires the Kafka message defined in the contract. Your actual consumer listener receives it and you assert the downstream effect. Real Kafka behaviour, no producer deployment required.
The workflow that makes this valuable is:
This separates two questions cleanly: “does my service work?” (unit and integration tests) and “will my service work with this other service?” (contract tests). Neither question requires both services to be deployed.
For teams using a contract registry like the Pact Broker, Spring Cloud Contract can publish and fetch contracts centrally rather than committing them to the producer repo. This is worth the additional setup once you have more than three or four service pairs with contracts.
anyNonEmptyString(), anyPositiveInt()) for fields where variation is acceptable.test, integration-test, verify) lets you fail fast on unit tests and only run the slower contract verification when unit tests pass.If you’re looking for a Java contractor who has implemented contract testing across distributed microservices, get in touch.