Contract Testing Stops Microservice Integration Failures Before Deploy
Your service works in isolation but breaks when paired with a different version of its upstream. That is not a bug in either service. It is a contract nobody wrote down. Here is how to catch every breaking API change before deploy using consumer-driven contract tests with Pact, with working Node.js examples.
Your team owns the checkout service. Another team owns the inventory service. Your service calls theirs to reserve stock before charging the customer. The integration tests pass in staging. The deployment pipeline is green. You ship.
Three minutes later, PagerDuty lights up. Every checkout is failing with a 500 error. The inventory team deployed a new version of their API that renamed stockLevel to availableQuantity and swapped the response format from an object to an array. They updated their own consumers. They did not know yours existed. Their tests passed. Yours passed. The integration broke.
This is not a blame problem. It is a contract problem. Two services agreed on a shared interface implicitly, through a README and a Slack message from three months ago. When one side changed the interface without the other knowing, there was no mechanism to catch the mismatch before production.
Consumer-driven contract testing (CDCT) is the mechanism. It formalizes the implicit agreement between a service consumer and a service provider into a machine-readable contract that both sides verify independently. This post walks through the pattern with Pact, the most mature CDCT tool for Node.js, from zero to a running pipeline.
What contract testing fixes
Integration tests test that two real services work together. They are expensive to write, slow to run, and brittle. They require both services to be deployed, a database to be seeded, and network calls to work. They also test too much: they verify that the entire stack works end-to-end when what you really need to know is whether your API client still matches the provider’s response format.
End-to-end tests catch contract breaks, but they do it late (after deploy to a shared environment) and noisily (a timeout in the database layer fails the same test that was supposed to verify the response shape). When the suite takes fifteen minutes to run and flakes twice a week, teams stop trusting it.
Unit tests on the consumer side mock the HTTP response. They verify your deserialization logic works, but the mock describes what you think the provider returns, not what it actually returns. When the provider changes the response, your mock stays stale and your tests stay green. The mismatch is discovered in production.
Contract testing sits between these two extremes. The consumer publishes a contract (an “I expect this request to return this shape” declaration). The provider verifies that declaration against its real API. Both sides stay independent. No shared deployment, no database, no network flakiness. The contract is the only shared artifact.
The Pact workflow in three steps
Pact is a consumer-driven contract testing framework. The flow is:
- Consumer writes a Pact test that describes the request it will make and the response it expects. Running the test generates a pact file (a JSON contract).
- Consumer publishes the pact file to a Pact Broker (a shared repository).
- Provider runs a verification suite that replays the consumer’s expected requests against its real API and checks that the responses match.
If the verification passes, both services are compatible. If it fails, the provider knows exactly which consumer will break and what the mismatch is, before anyone deploys.
Step 1: Consumer writes the pact
You are the checkout service. You call the inventory service to reserve items. Here is the Pact test that describes that interaction.
First, install the Pact library:
npm install --save-dev @pact-foundation/pact
Then write the consumer-side Pact test.
// checkout/test/contract/inventory.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { InventoryClient } from '../src/inventory-client';
const { like, eachLike, integer, string } = MatchersV3;
const provider = new PactV3({
consumer: 'checkout-service',
provider: 'inventory-service',
dir: path.resolve(__dirname, '../../pacts'),
});
describe('Inventory API contract', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
describe('POST /reserve', () => {
it('reserves stock and returns a confirmation', async () => {
// Arrange: tell Pact what request we expect and what response to return
await provider.addInteraction({
uponReceiving: 'a request to reserve stock',
withRequest: {
method: 'POST',
path: '/reserve',
headers: { 'Content-Type': 'application/json' },
body: {
sku: 'SKU-001',
quantity: 2,
requestId: string('req-abc-123'),
},
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: like({
reservationId: string('rsv-xyz-789'),
sku: string('SKU-001'),
quantityReserved: integer(2),
expiresAt: string('2026-06-14T10:00:00Z'),
}),
},
});
// Act: use the real client against Pact's mock server
const client = new InventoryClient(provider.mockService.baseUrl);
const result = await client.reserveStock('SKU-001', 2, 'req-abc-123');
// Assert: the client parsed the response correctly
expect(result.reservationId).toBeTruthy();
expect(result.quantityReserved).toBe(2);
});
});
});
The key detail is the matchers (like, eachLike, integer, string). These tell Pact which parts of the response shape must match exactly and which parts are variable. For example, string('req-abc-123') means “a string is expected, but the exact value can differ when the provider verifies.” This is what makes Pact tests flexible: they do not lock in exact values, only the shape and type.
Run the test:
npx jest checkout/test/contract/inventory.pact.test.ts
This generates a JSON pact file at pacts/checkout-service-inventory-service.json.
{
"consumer": { "name": "checkout-service" },
"provider": { "name": "inventory-service" },
"interactions": [{
"description": "a request to reserve stock",
"request": {
"method": "POST",
"path": "/reserve",
"headers": { "Content-Type": "application/json" },
"body": {
"sku": "SKU-001",
"quantity": 2,
"requestId": "req-abc-123"
}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"reservationId": "rsv-xyz-789",
"sku": "SKU-001",
"quantityReserved": 2,
"expiresAt": "2026-06-14T10:00:00Z"
}
}
}]
}
This JSON is the contract. It says: “The checkout service sends a POST with these fields and expects this response shape.” Commit this file. Publish it to the Pact Broker.
Step 2: Publish the pact
Set up a Pact Broker (they offer a free developer tier at pactflow.io, or you can run one with Docker):
docker run -d -p 9292:9292 \
-e PACT_BROKER_DATABASE_URL=postgres://postgres@postgres/postgres \
pactfoundation/pact-broker
Publish the pact from your CI pipeline:
npx pact-broker publish ./pacts \
--broker-base-url https://your-broker.example.com \
--consumer-app-version $GIT_COMMIT \
--branch $GIT_BRANCH
The Pact Broker stores the contract and shows a matrix of which consumer versions are compatible with which provider versions. When the provider runs its verification, the broker tells it exactly which pacts to verify.
Step 3: Provider verifies the pact
Now the inventory service team takes over. They add a verification step to their test suite.
// inventory/test/contract/verify-pacts.ts
import { Verifier } from '@pact-foundation/pact';
import { startServer } from '../src/server';
describe('Pact verification', () => {
let server;
beforeAll(async () => {
server = await startServer(4000);
});
afterAll(async () => {
await server.close();
});
it('satisfies all consumer pacts', async () => {
const opts = {
provider: 'inventory-service',
providerBaseUrl: 'http://localhost:4000',
pactBrokerUrl: 'https://your-broker.example.com',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT,
consumerVersionSelectors: [
{ mainBranch: true },
{ deployedOrReleased: true },
],
};
await new Verifier(opts).verifyProvider();
});
});
Run the verification:
npx jest inventory/test/contract/verify-pacts.ts
The Verifier fetches every pact that applies to the inventory service from the broker. For each interaction, it sends the exact request described in the pact to the real running provider and checks that the response matches the expected shape (allowing for flexible matchers).
If the checkout service expects a reservationId string and the provider now returns a number, the verification fails with a clear diff:
Verification failed for interaction "a request to reserve stock"
Expected response body:
reservationId: String
Actual:
reservationId: 12345
Mismatch: Expected reservationId to be a String but got Integer
No deploy needed. No staging environment required. The provider knows their change will break the checkout service before they merge the PR.
Setting up the CI pipeline
The contract testing flow only works if it is automated. Here is the minimal CI setup for each side.
Consumer CI (checkout-service):
# .github/workflows/consumer-contracts.yml
name: Consumer contract tests
on: [push]
jobs:
test-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm test -- --testPathPattern=contract
- name: Publish pacts
run: |
npx pact-broker publish ./pacts \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--consumer-app-version ${{ github.sha }} \
--branch ${{ github.ref_name }}
if: success()
Provider CI (inventory-service):
# .github/workflows/provider-contracts.yml
name: Provider contract verification
on: [push]
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
# Provider verification against a real, running server
- run: |
npx jest --testPathPattern=verify-pacts \
--forceExit
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
The provider publishes its verification results back to the broker. The broker then marks the contract as verified (green checkmark) or failed (red X). The consumer’s CI can read this status and block merges if the contract is not satisfied.
What to test and what to skip
Not every API interaction needs a contract test. Here is the heuristic I use.
Test with a contract when:
- The consumer and provider are owned by different teams or deployed independently
- The API response shape is non-trivial (nested objects, arrays, conditional fields)
- The consumer is in production and any regression is immediately visible
Skip contract tests when:
- Both sides are in the same deployable unit (same repo, same deploy)
- The API is internal to a single team and both sides are always deployed together
- The endpoint is experimental and changes daily
Contract tests replace a subset of integration tests. They do not replace end-to-end tests for critical user journeys. They do not replace unit tests for business logic. They replace the specific failure mode where two independently deployed services disagree on the shape of a response.
Handling breaking changes the right way
When a contract verification fails, the provider cannot simply change the response and merge. They need to follow a versioning strategy.
The cleanest approach for REST APIs is to add a new field or endpoint without removing the old one, then coordinate with the consumer to migrate. Pact makes this explicit: the broker shows exactly which consumers depend on which fields. If you want to remove stockLevel, the broker tells you that checkout-service still uses it. You cannot remove it until checkout-service publishes a new pact that no longer references it.
For versioned APIs (e.g., /v1/reserve and /v2/reserve), use separate pacts for each version. The consumer publishes against the version it targets, and the provider verifies both versions independently.
The Pact Broker supports a “can I deploy” check that queries the matrix:
npx pact-broker can-i-deploy \
--pacticipant inventory-service \
--version $GIT_COMMIT \
--to-environment production
This returns a boolean and an exit code. If any consumer that depends on inventory-service is not verified against this version, the check fails. Wire this into your deploy pipeline as a gating step. If the check fails, the deploy is blocked.
The maturity curve
Teams usually go through three phases with contract testing.
Phase 1: One consumer, one provider. The first team writes a pact, publishes it manually, and the provider runs verification on their machine. This already catches the most common contract breaks and takes an afternoon to set up.
Phase 2: CI automation. The consumer publishes pacts on every push. The provider verifies in CI and publishes results. The broker becomes the source of truth for API compatibility. Both teams can see the verification status in their pull requests.
Phase 3: Deploy gating. The “can I deploy” check runs in the provider’s CD pipeline. If the check fails, the deploy is blocked. The consumer’s CD pipeline also checks that their pacts are verified against the provider version running in production. This eliminates the deploy-time surprise entirely.
Phase 1 takes a day. Phase 2 takes a week. Phase 3 takes buy-in from your release engineering team but is the most impactful investment you can make for microservice reliability.
When Pact is not the right tool
Contract testing assumes the provider and consumer communicate synchronously (HTTP request/response). If your services communicate through a message broker (Kafka, RabbitMQ) or an event stream, Pact has experimental support for asynchronous interactions, but the patterns are less mature. For async contracts, consider schema registries (like Confluent Schema Registry for Avro) or AsyncAPI with a contract testing tool like Microcks.
Contract testing also assumes you control both sides of the communication. If you are consuming a third-party API, you cannot make the provider verify your pact. In that case, use Pact to generate the mock client and test your consumer, but skip the provider verification step. The pact file is still useful documentation of how you expect the external API to behave.
The takeaway
The checkout service and the inventory service were never going to catch their contract break with unit tests (mocks are stale) or end-to-end tests (too slow, too flaky). The mismatch was invisible until production because neither side had a formal description of what they agreed on.
Consumer-driven contract testing with Pact gives you that formal description. It runs in unit-test time (milliseconds, not minutes). It runs independently on each service. It catches the exact failure mode of “someone renamed a field and deployed without telling the other team.” And it creates an audit trail of who depends on what, so breaking changes are negotiated instead of discovered at 3 p.m. by PagerDuty.
Publish your first pact this week. It takes an afternoon, and it is the single highest-leverage reliability investment for a microservice architecture.
A note from Yojji
Building microservice architectures where every team can deploy independently without breaking the rest of the system requires more than good intentions. It requires the kind of disciplined testing strategy, contract negotiation, and CI/CD maturity that comes from shipping production systems day in and day out. Yojji builds and deploys Node.js microservices for clients who cannot afford integration surprises.
Yojji is an international custom software development company with offices in Europe, the US, and the UK. Their teams work extensively with the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and distributed system patterns. If your team is scaling microservices and wants to ship faster without breaking things, Yojji is worth a conversation.