In a microservices architecture, applications are split into independent services that communicate over network boundaries using API endpoints. Traditional integration testing relies on mocking these services during local test runs. However, mocks are passive—they never evolve automatically. If the backend team renames a response field, removes a property, or changes a status code, your frontend tests will still pass against the outdated mock. Your application breaks in production.
This is where API Contract Testing is essential.
This post explains the mechanics of Consumer-Driven Contract testing, contrasts it with traditional E2E testing, provides contract test scripts in TypeScript using Pact, and maps out a CI/CD contract verification pipeline.
What is Contract Testing?
Contract testing ensures that two separate systems—the Consumer (who fetches data) and the Provider (who generates data)—comply with a shared schema agreement called the Contract.
Instead of waiting for slow, environment-dependent integration tests, both sides verify their implementations against the contract document in complete isolation:
CONTRACT VERIFICATION PIPELINE:
┌─────────────────┐ Publishes ┌──────────────────┐
│ Consumer Test ├────────────►│ Contract File │
│ (Frontend Pact) │ │ (JSON Schema) │
└─────────────────┘ └────────┬─────────┘
│
│ Shared via Pact Broker
▼
┌─────────────────┐ Validates ┌──────────────────┐
│ Provider Test ├────────────►│ Contract Broker │
│ (Backend API) │ │ (PactFlow / OSS) │
└─────────────────┘ └──────────────────┘If either team attempts to merge code that violates the contract, their tests fail immediately at the pull request build gate—before any code reaches staging.
Comparison: Contract Testing vs. E2E Testing
| Metric | Contract Testing | End-to-End (E2E) Testing |
|---|---|---|
| Feedback Loop | Fast (seconds, runs locally during compilation) | Slow (minutes/hours, requires fully deployed environments) |
| Flakiness | Extremely Low (deterministic, no network calls) | High (subject to database sync, delays, and network glitches) |
| Debugging | Precise (tells you exactly which schema key fails) | Difficult (requires tracing logs across multiple microservices) |
| Maintenance | Low (contracts update programmatically) | High (requires maintaining visual scripts and environment sync) |
| Coverage Scope | Schema integrity between two services | Full user journey across all services |
Contract testing does not replace E2E testing—it eliminates the class of failures that E2E tests are worst at diagnosing: silent schema drift between teams.
Writing a Consumer Pact Test in TypeScript
On the consumer side, the frontend defines the expected schema for a /users/:id endpoint. The Pact library spins up a local mock server that returns the agreed contract response, so the consumer test runs with zero network dependency:
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
// 1. Initialize Pact Provider Mock Setup
const provider = new PactV3({
consumer: 'Frontend-User-App',
provider: 'Backend-User-Service',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('User Profile API Contract', () => {
it('should receive a valid user profile response', async () => {
// 2. Define the expected interaction contract
provider.uponReceiving('a request for user metadata')
.withRequest({
method: 'GET',
path: '/users/101',
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
// Use matchers to validate type and structure rather than exact values
id: MatchersV3.integer(101),
name: MatchersV3.string('Sabaoon'),
email: MatchersV3.email('info@sabaoon.dev'),
isActive: MatchersV3.boolean(true),
},
});
// 3. Run consumer test against the local mock server
await provider.executeTest(async (mockServer) => {
const response = await fetch(`${mockServer.url}/users/101`);
const user = await response.json();
expect(response.status).toBe(200);
expect(user.name).toBeDefined();
expect(user.isActive).toBe(true);
});
});
});When this test runs, Pact outputs a contract JSON file (Frontend-User-App-Backend-User-Service.json) containing the exact schema constraints. This file is the source of truth shared between teams.
Writing a Provider Verification Test
On the backend (provider) side, the team pulls the published contract from the Pact Broker and verifies their live API implementation against it:
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
describe('Provider Verification: Backend-User-Service', () => {
it('should satisfy the Frontend-User-App contract', async () => {
const verifier = new Verifier({
provider: 'Backend-User-Service',
providerBaseUrl: 'http://localhost:3001', // Local running backend
pactBrokerUrl: process.env.PACT_BROKER_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT_SHA,
});
await verifier.verifyProvider();
});
});If the backend team renames isActive to status, this verification test fails immediately on their PR—before the change reaches production and breaks the frontend.
The CI/CD Integration Pipeline
Once the contract JSON is generated, the flow becomes automated:
- Publish: The consumer (frontend) CI pipeline uploads the contract to a central Pact Broker (self-hosted or PactFlow) after every successful build.
- Verify: The provider (backend) CI pipeline retrieves the contract and runs verification against its actual API endpoints.
- Can I Deploy?: Both pipelines call
can-i-deployvia the Pact CLI before promoting to production. This CLI tool checks the Broker to verify that the deployed consumer and provider versions are mutually compatible.
# GitHub Actions: Consumer CI Step
- name: Publish Pact contracts
run: npx pact-broker publish ./pacts \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }} \
--consumer-app-version ${{ github.sha }}
- name: Can I Deploy?
run: npx pact-broker can-i-deploy \
--pacticipant Frontend-User-App \
--version ${{ github.sha }} \
--to-environment productionThis can-i-deploy gate is the decisive advantage of contract testing over mocking: it gives teams a cryptographic guarantee that their version is compatible before a single byte hits production.
Conclusion
API mocking provides a temporary sense of security, but fails silently when the real provider changes. By implementing consumer-driven contract testing with Pact, you create a shared, versioned, and automatically verified schema agreement between services. The feedback is fast, the failures are precise, and the can-i-deploy gate eliminates the category of integration bugs that only appear in staging or production. For any team operating more than two microservices, contract testing is not optional—it is the engineering discipline that makes independent deployability safe.