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();
}
@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)