In a system of microservices, the most common production failure isn't a bug inside one service but a change in one service that breaks another: a renamed field, a new required parameter, a changed status code. Contract testing catches these before deployment by checking each side against an agreed contract, without running every service together. This guide covers consumer-driven contracts with Pact and validating APIs against their OpenAPI (Swagger) specification.
Contract Testing vs Other API Tests
| Functional API tests | End-to-end tests | Contract tests | |
|---|---|---|---|
| Checks | One service's behaviour | The whole system together | That consumer and provider agree on requests and responses |
| Needs other services running? | No (mocks) | Yes, all of them | No: each side is tested separately against the contract |
| Speed | Fast | Slow and often flaky | Fast |
| Catches breaking changes between teams? | Rarely | Late, after deployment to a shared environment | Yes, before deployment |
How Consumer-Driven Contract Testing Works
In a microservices system: Service A calls Service B.
Service B team changes their API (adds required field, renames field, changes type).
Without contract testing: Service A breaks in production. Caught too late.
With contract testing: Service B's CI fails BEFORE deployment. Caught immediately.
Contract testing is the fastest, cheapest way to catch integration bugs in microservices.
| Approach | How | Speed | Catches Breaking Changes? |
|---|---|---|---|
| Integration Tests (full) | Both services running together | Slow (minutes) | Yes, but late |
| E2E Tests | Full system running | Very Slow (hours) | Yes, but very late |
| Contract Tests (Pact) | Each service tested independently against contract | Fast (seconds) | Yes, immediately on each commit |
Pact Consumer Test — Defining the Contract
// Consumer: OrderService — calls UserService to validate user before placing order
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "UserService", port = "8081")
public class OrderServiceConsumerPactTest {
// ── Define what OrderService EXPECTS from UserService ─────────────
@Pact(consumer = "OrderService")
public RequestResponsePact getUserById(PactDslWithProvider builder) {
return builder
.given("user with ID 42 exists and is active")
.uponReceiving("GET request for user 42")
.path("/api/users/42")
.method("GET")
.headers(Map.of("Authorization", "Bearer valid-token"))
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(newJsonBody(o -> {
o.integerType("id", 42);
o.stringType("name", "Naveed Khan");
o.stringType("email", "naveed@test.com");
o.booleanType("isActive", true);
o.stringType("role", "CUSTOMER");
}).build())
.toPact();
}
@Pact(consumer = "OrderService")
public RequestResponsePact getUserNotFound(PactDslWithProvider builder) {
return builder
.given("user with ID 999 does not exist")
.uponReceiving("GET request for non-existent user 999")
.path("/api/users/999")
.method("GET")
.willRespondWith()
.status(404)
.body(newJsonBody(o -> {
o.stringType("error", "USER_NOT_FOUND");
}).build())
.toPact();
}
// ── Test using the Pact mock server ──────────────────────────────
@Test
@PactTestFor(pactMethod = "getUserById")
void placeOrder_validUser_succeeds(MockServer mockServer) {
UserServiceClient client = new UserServiceClient(mockServer.getUrl(), "valid-token");
UserDto user = client.getUser(42);
assertThat(user.getId()) .isEqualTo(42);
assertThat(user.isActive()) .isTrue();
assertThat(user.getName()) .isEqualTo("Naveed Khan");
}
@Test
@PactTestFor(pactMethod = "getUserNotFound")
void placeOrder_userNotFound_throwsException(MockServer mockServer) {
UserServiceClient client = new UserServiceClient(mockServer.getUrl(), "valid-token");
assertThatThrownBy(() -> client.getUser(999))
.isInstanceOf(UserNotFoundException.class);
}
}
Pact Provider Verification
// Provider: UserService — verifies it can fulfil OrderService's contract
@Provider("UserService")
@PactBroker(
url = "${PACT_BROKER_URL}",
consumerVersionSelectors = {
@VersionSelector(mainBranch = true), // verify against consumer main branch
@VersionSelector(deployedOrReleased = true) // and deployed versions
}
)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserServiceProviderPactTest {
@LocalServerPort
private int port;
@TestTarget
public final Target target = new HttpTestTarget("localhost", port);
@Autowired UserRepository userRepository;
// ── Seed data for each "given" state in the contract ─────────────
@State("user with ID 42 exists and is active")
public void userExists() {
userRepository.deleteAll();
userRepository.save(User.builder()
.id(42L).name("Naveed Khan")
.email("naveed@test.com")
.isActive(true).role("CUSTOMER")
.build());
}
@State("user with ID 999 does not exist")
public void userNotFound() {
userRepository.deleteById(999L); // ensure 999 is deleted
}
}
Pact Broker — Publishing & Verification
# pom.xml — Pact broker plugin configuration
<plugin>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>maven</artifactId>
<version>4.6.7</version>
<configuration>
<pactBrokerUrl>${PACT_BROKER_URL}</pactBrokerUrl>
<pactBrokerToken>${PACT_BROKER_TOKEN}</pactBrokerToken>
<tags><tag>${GIT_BRANCH}</tag></tags>
<publishVersion>${PROJECT_VERSION}</publishVersion>
</configuration>
</plugin>
# CI Pipeline integration
# Consumer pipeline:
# 1. Run consumer pact tests (generates pact JSON files)
# 2. mvn pact:publish → upload pacts to Pact Broker
# Provider pipeline:
# 1. mvn verify -Dpact.verifier.publishResults=true → verify AND publish results
# 2. Can-I-Deploy check: pact-broker can-i-deploy --pacticipant UserService
# --version $(git rev-parse HEAD) --to-environment production
# 3. Gates deployment if provider breaks any consumer contract!
OpenAPI / Swagger Spec Validation
OpenAPI (formerly Swagger) is the standard way to document REST APIs.
Contract-from-spec testing: validate your API responses against its OWN documentation.
If the API says "name is required" but returns a response without name → spec violation.
Catches: undocumented fields, missing required fields, wrong types — automatically.
Tool: swagger-request-validator + RestAssured integration.
OpenAPI Spec Validation with RestAssured
<!-- pom.xml dependency -->
<dependency>
<groupId>com.atlassian.oai</groupId>
<artifactId>swagger-request-validator-restassured</artifactId>
<version>2.40.0</version>
<scope>test</scope>
</dependency>
// Setup: attach OpenAPI validator as a RestAssured filter
public class OpenApiValidationTest {
private static OpenApiValidationFilter openApiFilter;
@BeforeClass
public static void setup() {
// Load OpenAPI spec (URL or local file)
openApiFilter = new OpenApiValidationFilter(
"https://api.myapp.com/v3/api-docs" // Swagger UI docs URL
// OR: "classpath:openapi/api-spec.yaml"
);
}
@Test
void getUser_responseMatchesOpenApiSpec() {
given()
.spec(withAuth("admin"))
.filter(openApiFilter) // ← validate against spec automatically
.pathParam("id", 42)
.when()
.get("/api/users/{id}")
.then()
.statusCode(200);
// openApiFilter automatically fails test if:
// - response has undocumented fields
// - required fields are missing
// - field types differ from spec
// - status code not documented
}
@Test
void createUser_requestValidatedAgainstSpec() {
given()
.spec(withAuth("admin"))
.filter(openApiFilter)
.body(TestDataFactory.createUserRequest())
.when()
.post("/api/users")
.then()
.statusCode(201);
// Also validates the REQUEST body matches the spec schema
}
}
OpenAPI Spec — What to Check Manually
| Check | What to Verify | Why It Matters |
|---|---|---|
| Endpoints documented | Every API endpoint is in the spec | Undocumented endpoints = hidden attack surface |
| Status codes complete | All possible status codes listed (200,201,400,401,403,404,422,500) | Consumers need to handle all cases |
| Request schema | Required fields, types, formats, min/max defined | Client validation + auto-generated SDKs |
| Response schema | All response fields typed, required fields marked | Consumer knows what to expect |
| Auth documented | SecurityScheme defined (bearerAuth, apiKey) | API Gateway + code generation depends on this |
| Examples provided | Request + response examples for each endpoint | Developer experience, Postman import |
| Deprecation marked | Old endpoints marked deprecated with migration guide | Consumer has time to migrate |
Pact or OpenAPI Validation: Which Do You Need?
- OpenAPI validation checks that the provider matches its published specification. It's quick to add when a spec exists, but it can't tell you which fields consumers actually rely on.
- Pact records what each consumer really uses, so a provider can change anything no consumer depends on and is warned about changes that would break a consumer.
- Many teams use both: spec validation in every provider build, Pact between services owned by different teams, and
can-i-deployas a gate in the release pipeline.
FAQs
What is consumer-driven contract testing?
The consumer writes tests that define the requests it sends and the responses it needs; those expectations become a contract (a pact file) that the provider must verify in its own build.
Is contract testing a replacement for end-to-end tests?
No, but it lets you keep far fewer of them. Contract tests cover the interface between services; a small set of end-to-end tests still checks critical journeys work in a real environment.
What does Pact Broker can-i-deploy do?
It checks whether a given version of a service has been verified against the versions of its consumers and providers in the target environment, and fails the pipeline if deploying it would break a contract.