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 testsEnd-to-end testsContract tests
ChecksOne service's behaviourThe whole system togetherThat consumer and provider agree on requests and responses
Needs other services running?No (mocks)Yes, all of themNo: each side is tested separately against the contract
SpeedFastSlow and often flakyFast
Catches breaking changes between teams?RarelyLate, after deployment to a shared environmentYes, before deployment
Advertisement

How Consumer-Driven Contract Testing Works

Why Contract Testing?

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.

ApproachHowSpeedCatches Breaking Changes?
Integration Tests (full)Both services running togetherSlow (minutes)Yes, but late
E2E TestsFull system runningVery Slow (hours)Yes, but very late
Contract Tests (Pact)Each service tested independently against contractFast (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

Why OpenAPI Validation Matters

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

CheckWhat to VerifyWhy It Matters
Endpoints documentedEvery API endpoint is in the specUndocumented endpoints = hidden attack surface
Status codes completeAll possible status codes listed (200,201,400,401,403,404,422,500)Consumers need to handle all cases
Request schemaRequired fields, types, formats, min/max definedClient validation + auto-generated SDKs
Response schemaAll response fields typed, required fields markedConsumer knows what to expect
Auth documentedSecurityScheme defined (bearerAuth, apiKey)API Gateway + code generation depends on this
Examples providedRequest + response examples for each endpointDeveloper experience, Postman import
Deprecation markedOld endpoints marked deprecated with migration guideConsumer 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-deploy as 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.