DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building a Robust API Contract Testing Strategy with Pact and REST-Assured

Building a Robust API Contract Testing Strategy with Pact and REST-Assured

Building a Robust API Contract Testing Strategy with Pact and REST-Assured

In modern software, teams ship APIs that other services, frontends, and mobile apps depend on. A contract-first mindset helps prevent breaking changes and reduces the blast radius when services evolve. This tutorial guides you through designing and implementing a practical API contract testing strategy using Pact for consumer-driven contracts and REST-Assured for verification in Java. You’ll get step-by-step guidance, sample code, and best practices to integrate contract tests into CI/CD, catch regressions early, and improve collaboration between teams.

Note: This tutorial assumes a Java-based tech stack and RESTful APIs, but the concepts translate to other languages and contract frameworks as well.

Table of contents

  • Why contract testing matters
  • High-level architecture
  • Tools and prerequisites
  • Define a consumer-driven contract with Pact
  • Implement provider verification with REST-Assured
  • Integrate into CI/CD
  • Best practices and common pitfalls
  • Example project walkthrough

Why contract testing matters

  • Detach consumer and provider evolutions: Contracts let teams evolve independently while ensuring compatibility.
  • Early regression detection: Contract tests fail when a provider changes behavior that a consumer relies on.
  • Clear expectations: Contracts document the surface of the API and expected interactions.
  • Faster feedback loop: Automated tests catch breaking changes before release.

High-level architecture

  • Consumers: Applications or services that call a provider API. They define expectations (pacts) describing how the provider should respond.
  • Provider: The API service that implements the contract. It runs provider verification tests to ensure it adheres to the contract.
  • Pact broker (optional): A centralized place to publish, share, and version contracts, enabling multi-team collaboration.
  • CI/CD: Runs consumer pact tests on every change and provider verification on provider deployment or PRs.

Tools and prerequisites

  • Java 11+ project
  • Maven or Gradle build system
  • Pact-JVM (for Java) and a Pact Broker (optional)
  • REST-Assured for API testing
  • JUnit 5 for tests
  • A running provider API to verify against
  • Basic familiarity with REST concepts (GET/POST/PUT/DELETE, status codes)

Define a consumer-driven contract with Pact
1) Set up a sample consumer project

  • Create a Maven or Gradle project.
  • Add dependencies for Pact Consumer, JUnit 5, and REST-Assured.

Example (Gradle):
repositories {
mavenCentral()
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.3'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.3'
testImplementation 'au.com.dius:pact-jvm-consumer-jest:4.3.0' // adjusted for Java; use pact-jvm-consumer-jotlin if needed
testImplementation 'au.com.dius:pact-jvm-consumer-library:4.3.0'
testImplementation 'io.rest-assured:rest-assured:5.3.0'
}

Note: Pact JVM has specific modules; verify latest artifact IDs for Java consumer tests.

2) Write a consumer test that defines a pact

  • The consumer test describes the expected interaction with the provider.
  • Pact generates a contract file (PACT) capturing the request and expected response.

Example (JUnit 5 with Pact JVM):
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.dsl.PactDslJsonBody;
import au.com.dius.pact.consumer.PactTestFor;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.dsl.LambdaDslJsonBody;
import au.com.dius.pact.consumer.PactVerificationResult;
import io.restassured.RestAssured;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;

class UserServiceConsumerPactTest {

@Pact(consumer = "UserServiceConsumer", provider = "UserServiceProvider")
public RequestResponsePact definePact(PactDslWithProvider builder) {
PactDslJsonBody body = new PactDslJsonBody()
.stringType("id")
.stringType("name");

return builder
  .given("User with id 123 exists")
  .uponReceiving("a request for user with id 123")
  .path("/users/123")
  .method("GET")
  .willRespondWith()
  .status(200)
  .body(body)
  .toPact();
Enter fullscreen mode Exit fullscreen mode

}

@test
@PactTestFor(providerName = "UserServiceProvider", pactMethod = "definePact")
void it_validates_the_contract() {
// This is a consumer-side test to generate the contract.
// You can use REST-Assured to exercise the API if you have a live instance in a test harness.
}
}

3) Publish the contract

  • Pact files are typically stored under src/test/resources/pacts/[consumer]-[provider].json.
  • Optionally publish to a Pact Broker for sharing with the provider team.

4) Interpret the contract

  • The contract defines the request, the provider state, and the expected response.
  • It serves as a contract boundary between consumer and provider.

Provider verification with REST-Assured
1) Set up a provider verification test

  • The provider should run tests that validate the actual API against the pact contracts.
  • Use Pact Verifier to read the pact file and verify the provider’s responses.

Example (Gradle, provider verification):
dependencies {
testImplementation 'au.com.dius:pact-jvm-provider-junit5:4.3.0'
testImplementation 'io.rest-assured:rest-assured:5.3.0'
}

import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.Provider;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.DisplayName;

@provider("UserServiceProvider")
class UserServiceProviderPactTest {

@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void run(PactVerificationContext context) {
context.verifyInteraction();
}

@org.junit.jupiter.api.BeforeEach
void setup(PactVerificationContext context) {
// Point REST-Assured to your provider base URL
}

// The provider verification logic is wired by Pact; you also supply a state handler if needed.
}

2) Connect to the deployed provider

  • The verifier reads the pact file and makes requests to the provider’s deployed base URL.
  • Ensure the provider has a stable test environment or a dedicated test endpoint that mimics production behavior.

3) Verifying multiple contracts

  • Each consumer-provider pair gets its own pact file.
  • The provider verification can run against a harness that iterates through all pact files.

Integrate into CI/CD

  • On consumer PRs:
    • Run Pact tests to generate or verify contract changes.
    • If the pact contract changes, require approval or add a review step before merging.
  • On provider deployment:
    • Run provider verification tests against the latest consumer contracts.
    • If verification fails, block deployment and surface failing interactions in CI logs.
  • Optional Pact Broker workflow:
    • Publish contracts to the broker after consumer tests pass.
    • Provider tests can pull contracts from the broker to verify against the latest consumer expectations.
    • Use tag-based filtering (e.g., test, prod) to control environments.

Suggested CI workflow (high level)

  • CI for consumer:
    • Run unit tests
    • Run Pact tests to generate contract files
    • If contracts change, require reviewer approval
    • Publish contracts to Pact Broker (optional)
  • CI for provider:
    • Fetch latest contracts from Pact Broker (or local pact files)
    • Run provider verification against a test environment
    • If verification passes, promote deployment; otherwise, fail the build

Best practices and common pitfalls

  • Keep contracts stable and explicit:
    • Only change a contract when the provider’s behavior truly changes. Use provider states to describe test scenarios.
  • Use consumer-driven change management:
    • Let consumers drive contract evolution; providers should adapt to new expectations rather than force breaking changes unilaterally.
  • Version contracts:
    • Maintain versioned pact files; support multiple versions to handle gradual migrations.
  • Don’t test business logic in contracts:
    • Contracts should reflect API surface and behavior (status codes, payload shape), not deep business rules.
  • Separate contract tests from integration tests:
    • Keep contract tests fast and deterministic; reserve slower, end-to-end tests for broader coverage.
  • Guard against flaky tests:
    • Stabilize test data and avoid relying on non-deterministic conditions in contracts.

Example project walkthrough

  • Scenario: A simple user service with endpoints:
    • GET /users/{id} returns 200 with a user object or 404 if not found
    • POST /users creates a new user and returns 201 with the created user
  • Step 1: Create a consumer that calls GET /users/123 and expects a user payload
    • Pact defines the request and the expected 200 response with id and name fields
  • Step 2: Implement provider verification that confirms the provider returns the same payload for the pact
    • Provider must return the exact shape and status code for the given id
  • Step 3: Run tests locally and push changes
    • The pact file is generated and can be pushed to a broker
    • Provider tests verify against the stored pact file

Illustrative example snippet: consumer pact and provider verifier

  • Consumer Pact (conceptual representation)
    {
    "consumer": { "name": "UserServiceConsumer" },
    "provider": { "name": "UserServiceProvider" },
    "interactions": [
    {
    "description": "GET /users/123 returns a user",
    "request": { "method": "GET", "path": "/users/123" },
    "response": { "status": 200, "body": { "id": "123", "name": "Alice" } }
    }
    ],
    "metadata": { "pactSpecification": { "version": "3.0.0" } }
    }

  • Provider verification (conceptual)

    • Read the pact above
    • Send GET /users/123 to the provider
    • Expect 200 with body { "id": "123", "name": "Alice" }

Tips for a smoother adoption

  • Start small: pick a critical, stable API surface to contract-test first.
  • Automate contract publishing: integrate Pact Broker publishing into your consumer CI workflow.
  • Align timelines: keep consumer and provider contract versions in sync to avoid drift.
  • Foster collaboration: use provider states to describe test scenarios in meaningful business terms (e.g., “existing_user_123”).
  • Monitor contracts in production: consider lightweight verifications or health checks that ensure ongoing compatibility.

If you want, I can tailor a starter repository template (Gradle or Maven) with a complete working example for your stack, including a simple Pact broker setup and a sample provider harness. Would you prefer Maven or Gradle, and do you want the example to run with a local mock provider or a real deployed service?

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)