Pact Contract Testing: A Hands-On Tutorial With CI
Quick answer
Pact contract testing is consumer-driven: the consumer's tests record the requests it makes and the responses it needs into a pact file, publish it to a Pact Broker or PactFlow, and the provider replays every pact against its real code. can-i-deploy then checks the broker's verification matrix before each release, so neither side ships a breaking change.
Reviewed by Sushant Joshi
Part of the OpenAPI, Contract & Schema Testing hub. Start with the pillar guide: OpenAPI testing: how to test an API from its spec.
Pact contract testing lets two services prove they are compatible without being deployed together. The consumer writes a test that records what it sends and what it needs back; Pact turns that into a contract (a "pact"), and the provider replays the pact against its real code. If you are new to the idea of contracts between services, read what API contract testing is first; this post is the hands-on part.
We'll build the full loop with runnable code: a pact-js consumer test, publishing to a Pact Broker or PactFlow, provider verification in pact-js and pact-jvm, can-i-deploy in CI, the pitfalls that make teams give up on Pact, and an honest comparison with schema-first OpenAPI and bi-directional contract testing.
What is Pact contract testing and how does it work?
Pact is an open-source, consumer-driven contract testing framework with libraries for JavaScript/TypeScript, JVM, .NET, Python, Go, Rust and more. "Consumer-driven" means the consumer defines the contract: it lists only the requests it makes and the response fields it actually reads. The provider must satisfy every consumer's pact, and nothing more.
The loop has five steps:
- Consumer test. The consumer's client code calls a local Pact mock server. Each interaction (request plus minimal expected response) is recorded.
- Pact file. If the test passes, Pact writes a JSON file such as
OrderWeb-OrderService.json. - Publish. CI publishes the pact to a Pact Broker (self-hosted) or PactFlow (hosted), tagged with the consumer's git SHA and branch.
- Provider verification. The provider's CI fetches the relevant pacts, starts the real service, sets up each "provider state", replays each request and compares the responses. Results go back to the broker.
- can-i-deploy. Before any deploy, each side asks the broker whether the version it is about to release has been verified against the versions running in the target environment.
The running example is a web front end (OrderWeb) that reads orders from OrderService.
Step 1: write a consumer test with pact-js
Install the library and a test runner:
npm install --save-dev @pact-foundation/pact jest
The consumer's API client is ordinary code. Pact tests it, not a hand-written stub:
// src/orderClient.js
export class OrderClient {
constructor(baseUrl) { this.baseUrl = baseUrl; }
async getOrder(id) {
const res = await fetch(`${this.baseUrl}/orders/${id}`, {
headers: { Accept: 'application/json' },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`OrderService returned ${res.status}`);
return res.json();
}
}
The consumer test describes two interactions: an order that exists and one that doesn't.
// test/orderClient.pact.test.js
import path from 'node:path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { OrderClient } from '../src/orderClient.js';
const { like, eachLike, integer, decimal, regex, datetime } = MatchersV3;
const provider = new PactV3({
consumer: 'OrderWeb',
provider: 'OrderService',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('OrderService contract', () => {
it('returns an existing order', () => {
provider
.given('order 42 exists')
.uponReceiving('a request for order 42')
.withRequest({
method: 'GET',
path: '/orders/42',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(42),
status: regex('PENDING|PAID|SHIPPED', 'PAID'),
total: decimal(25.5),
createdAt: datetime("yyyy-MM-dd'T'HH:mm:ssX", '2026-10-19T09:30:00Z'),
items: eachLike({ sku: like('BOOK-1'), quantity: integer(2) }),
},
});
return provider.executeTest(async (mockServer) => {
const order = await new OrderClient(mockServer.url).getOrder(42);
expect(order.id).toBe(42);
expect(order.items[0].sku).toBe('BOOK-1');
});
});
it('returns null for a missing order', () => {
provider
.given('order 999 does not exist')
.uponReceiving('a request for a missing order')
.withRequest({ method: 'GET', path: '/orders/999' })
.willRespondWith({ status: 404 });
return provider.executeTest(async (mockServer) => {
expect(await new OrderClient(mockServer.url).getOrder(999)).toBeNull();
});
});
});
Three things make this a good consumer test:
- Matchers, not literal values.
integer(42)means "any integer, 42 in the example". Literal values would make the provider return exactly 42 for every order. - Only the fields the consumer reads. The real order may have 30 fields; the pact lists five. The provider stays free to add or change the rest.
- The real client is exercised. The test calls
OrderClient, so a typo in the path breaks the test, not production.
Run npx jest, and pacts/OrderWeb-OrderService.json appears.
Pact covers consumer-driven contracts. To start from your OpenAPI spec instead, the Contract Testing Starter Kit has templates for both approaches and a CI checklist you can adapt.
Step 2: publish pacts to a Pact Broker or PactFlow
Pact files need a home both sides can reach. You have two options:
- Pact Broker (open source). Run it yourself with the
pactfoundation/pact-brokerDocker image and a Postgres database. - PactFlow (commercial, by SmartBear). A hosted broker with SSO, teams, secrets and bi-directional contract testing.
The CLI is the same for both. Install it as a standalone binary, through the pactfoundation/pact-cli Docker image, or with npm (@pact-foundation/pact-cli), then publish with the git SHA as the version:
Score your OpenAPI spec (free)
Free, runs in your browser, no sign-up needed.
export PACT_BROKER_BASE_URL=https://your-broker.example.com
export PACT_BROKER_TOKEN=*** # PactFlow; a self-hosted broker may use basic auth instead
pact-broker publish ./pacts \
--consumer-app-version "$(git rev-parse HEAD)" \
--branch "$(git rev-parse --abbrev-ref HEAD)"
Use the commit SHA, never a semantic version. The broker builds its compatibility matrix from exact versions, and two builds tagged 1.4.0 make the matrix meaningless.
Step 3: verify the provider with pact-js or pact-jvm
Provider verification runs in the provider's pipeline, against the real service with its real code, and a test database. Provider states (given(...)) are hooks that put the data in place for each interaction.
pact-js provider (Node service):
// test/provider.pact.test.js
import { Verifier } from '@pact-foundation/pact';
import { startServer } from '../src/server.js';
import { db } from '../src/db.js';
let server;
beforeAll(async () => { server = await startServer(8080); });
afterAll(() => server.close());
it('honours all consumer pacts', () => {
return new Verifier({
provider: 'OrderService',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
providerVersion: process.env.GIT_SHA,
providerVersionBranch: process.env.GIT_BRANCH,
publishVerificationResult: process.env.CI === 'true',
consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
enablePending: true,
stateHandlers: {
'order 42 exists': async () => {
await db.orders.upsert({ id: 42, status: 'PAID', total: 25.5,
createdAt: new Date(), items: [{ sku: 'BOOK-1', quantity: 2 }] });
},
'order 999 does not exist': async () => { await db.orders.delete(999); },
},
}).verifyProvider();
}, 60000);
pact-jvm provider (Spring Boot, JUnit 5):
@Provider("OrderService")
@PactBroker // reads pactbroker.url and pactbroker.auth.token from system properties or env
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderServicePactVerificationTest {
@LocalServerPort int port;
@Autowired OrderRepository orders;
@BeforeEach
void target(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verify(PactVerificationContext context) {
context.verifyInteraction();
}
@State("order 42 exists")
void order42() {
orders.save(new Order(42L, "PAID", new BigDecimal("25.50"), List.of(new Item("BOOK-1", 2))));
}
@State("order 999 does not exist")
void noOrder999() { orders.deleteById(999L); }
}
The consumerVersionSelectors matter. mainBranch verifies what consumers are building next; deployedOrReleased verifies what is live today. enablePending: true means a brand-new consumer expectation doesn't fail the provider's build until the provider has passed it once. This is what stops one consumer's work in progress from blocking every provider release.
Step 4: gate releases with can-i-deploy in CI
Verification results are only useful if deployments check them. Each pipeline asks the broker before it deploys, and records the deployment after:
# .github/workflows/consumer.yml (OrderWeb)
name: consumer
on: [push]
env:
PACT_BROKER_BASE_URL: ${{ vars.PACT_BROKER_BASE_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_SHA: ${{ github.sha }}
GIT_BRANCH: ${{ github.ref_name }}
jobs:
test-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx jest test/orderClient.pact.test.js
- run: npx pact-broker publish ./pacts --consumer-app-version "$GIT_SHA" --branch "$GIT_BRANCH"
deploy:
needs: test-and-publish
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Can I deploy?
run: >
npx pact-broker can-i-deploy
--pacticipant OrderWeb --version "$GIT_SHA"
--to-environment production
--retry-while-unknown 6 --retry-interval 10
- run: ./scripts/deploy.sh production
- run: >
npx pact-broker record-deployment
--pacticipant OrderWeb --version "$GIT_SHA" --environment production
The provider pipeline mirrors this: run verification (which publishes results), can-i-deploy --pacticipant OrderService, deploy, record-deployment. Add a broker webhook that triggers the provider's verification when a consumer publishes a changed pact, so the consumer's can-i-deploy gets an answer within minutes instead of waiting for the next provider commit. --retry-while-unknown covers that gap.
Pact pitfalls that break adoption
- Using Pact as a functional test tool. Pact checks the shape of the conversation, not business rules. "Order total is 25.50 after a 10% discount" belongs in the provider's own tests. Over-specified pacts make every provider refactor a cross-team negotiation.
- Literal values instead of matchers. The pact then demands exact ids and timestamps, and verification fails on data, not contract.
- Provider states that don't set up data. Empty state handlers mean verification passes or fails depending on whatever is in the test database.
- Versions that aren't git SHAs. The matrix needs exact, immutable versions.
- Skipping can-i-deploy. Publishing and verifying without gating deploys gives you reports, not protection.
- No webhook. Without one, consumer builds wait for provider builds, and teams start bypassing the gate.
- Trying to enrol external consumers. Pact works when you can get every consumer to write tests. Public APIs and partner integrations rarely can, and that is where a spec-first approach fits better.
Free PDF + code examples
OpenAPI to Test Generation Template Pack
Go from OpenAPI spec to full test coverage. Includes sample specs, example generated tests, edge case patterns, and CI/CD integration guides.
Download FreePact vs schema-first OpenAPI and bi-directional contract testing
Pact is one of three common ways to enforce a contract. They answer slightly different questions.
| Pact (consumer-driven) | Schema-first (OpenAPI) | Bi-directional (PactFlow) | |
|---|---|---|---|
| Who writes the contract | Each consumer, in tests | The provider, as a spec | Both: consumer pacts + provider spec |
| What is checked | Provider replays each consumer's interactions | Running API is validated against the spec | Broker compares pacts against the spec statically |
| Needs consumer participation | Yes, every consumer | No | Yes, for the pact side |
| Catches "field the consumer reads was removed" | Yes, precisely | Only if the spec changes too | Yes, if the spec is accurate |
| Catches "API drifted from its spec" | No | Yes | Only with provider self-verification |
| Works for public / partner APIs | Poorly | Well | Partly |
| Licence | Open source; PactFlow optional | Many open-source and commercial tools | PactFlow feature (commercial) |
Bi-directional contract testing is a PactFlow feature. The consumer still publishes pacts, but the provider publishes its OpenAPI document plus evidence that the implementation matches it (results from a schema-based tool). PactFlow then checks each pact is a valid subset of the spec, without replaying requests against the provider. It lowers the provider's cost of joining, at the price of trusting that the spec matches the code.
Schema-first is the other route: the OpenAPI document is the contract, and the provider's tests prove the running API conforms to it. This is how Shift-Left API approaches contracts. It imports your OpenAPI spec and generates tests covering schema conformance, status codes and negative cases. It flags passing responses that no longer match the documented schema (required fields, types, enums). When you re-import a changed spec, it classifies each change as breaking or non-breaking for review before the new version is applied. It does not read pact files or connect to a Pact Broker. Teams that need consumer-driven guarantees run Pact alongside a spec-driven suite, not instead of it.
A practical split many teams land on:
- Pact for the handful of internal consumer–provider pairs where each side moves fast and the consumer's exact needs matter.
- Schema-first testing for every API with a spec, especially public, partner and many-consumer APIs, plus a spec diff in CI to catch breaking changes.
For a side-by-side of the tools in each camp (Pact, PactFlow, Spring Cloud Contract, Specmatic, Schemathesis and others), see contract testing tools for microservices. If you're coming from collections, Pact vs Postman for contract testing covers what a collection can and can't prove.
Pact contract testing FAQ
What is Pact contract testing?
Pact is an open-source, consumer-driven contract testing tool. The consumer's unit tests run against a Pact mock server and record each expected request and minimal response into a JSON pact file. The provider then replays those interactions against its real implementation. If verification passes, the two sides are compatible without ever being deployed together.
Is Pact contract testing free?
Yes. The Pact libraries and the open-source Pact Broker are free and MIT-licensed; you can self-host the broker with Docker and Postgres. PactFlow, the hosted broker from SmartBear, is a commercial product (check SmartBear's current plans) that adds features such as bi-directional contract testing.
What is the difference between Pact and contract testing?
Contract testing is the practice of checking that a provider and its consumers agree on an interface. Pact is one tool for it, and it uses the consumer-driven style. Other approaches include schema-first testing against an OpenAPI document and Spring Cloud Contract, where the provider writes the contracts.
Does Pact replace integration or end-to-end tests?
It replaces most of the integration tests whose only job is to check that two services still agree on request and response shapes. It does not test business logic, data correctness across services or user journeys, so keep a small set of API integration tests and end-to-end tests for those.
Can Pact test Kafka and other message-based APIs?
Yes. Pact message pacts describe the messages a consumer expects to receive, and the provider verifies that its producer code creates matching messages. The transport (Kafka, SNS, RabbitMQ) is not part of the contract; Pact checks the message body and metadata.
What are the alternatives to Pact contract testing?
Spring Cloud Contract suits JVM teams that want provider-written contracts. Schema-first tools validate a running API against an OpenAPI document, which works when you can't enrol every consumer. Specmatic turns OpenAPI into executable contracts, and PactFlow's bi-directional mode compares consumer pacts with a provider's OpenAPI spec.
Sources and further reading
- Pact documentation and how Pact works.
- Pact JS and Pact JVM JUnit 5 provider.
- Consumer version selectors and pending pacts.
- can-i-deploy and recording deployments and releases.
- Pact Broker webhooks.
- Pact Nirvana: CI/CD setup guide.
- PactFlow bi-directional contract testing.
- Martin Fowler: Consumer-Driven Contracts.
- OpenAPI Specification.
Ready to shift left with your API testing?
Import your OpenAPI spec and get a runnable test suite, in our cloud or on your own infrastructure.