Contract Testing Between Frontend and Backend with Pact
AI generated
JS
() =>
JavaScript · Testing Advanced · Pact.js · API Contracts
Contract Testing Between Frontend and Backend
catch breaking changes with Pact.js before they go live

Mocks only tell you whether the frontend handles an assumed API response correctly, not whether that assumption still holds. Contract testing with Pact.js closes exactly that gap: the frontend defines its expectations as an executable contract, and the backend automatically verifies whether it actually fulfills that contract.

18 min read Pact.js · Consumer Driven Contracts · CI Node.js 20+ · any backend

1. What contract testing actually solves

A frontend team mocks the API with MSW or similar tools and reliably tests how the application reacts to an assumed response. The problem: that assumption can go stale without anyone noticing. If the backend team changes a field from price to priceAmount without informing the frontend, all mocked frontend tests stay green while the production application breaks on the next deployment. This exact scenario, distributed teams with independent deployment cycles and implicit assumptions about a shared interface, is the starting point for contract testing.

Contract testing between frontend and backend makes the frontend's assumptions about the API explicit and machine checkable. Instead of a silent contract that only exists in people's heads and outdated documentation, an executable contract emerges that both frontend and backend automatically verify. If the backend breaks an expectation, provider verification fails in the CI pipeline long before the incompatible code gets deployed.

Pact.js is the most widespread implementation of contract testing in the JavaScript ecosystem, regardless of what language the backend is written in. A Node.js backend, a PHP backend or a Java backend can all be verified against the same pact contract generated by the JavaScript frontend, because the pact file format is language agnostic.

2. The concept: consumer driven contracts

The central term in contract testing is consumer driven contract. The consumer, usually the frontend, defines which fields and structures it expects from a given API response. That expectation is automatically saved as a pact file when the consumer test runs, a JSON document with request and response schema. The provider, usually the backend, then reads that pact file and checks whether its real implementation actually delivers a response matching that contract.

This direction, from consumer to provider, fundamentally distinguishes contract testing from a classic OpenAPI specification. An OpenAPI specification describes what the provider theoretically offers, independent of what individual consumers actually use. A consumer driven contract, by contrast, describes exactly what a concrete consumer actually needs, no more and no less. If the provider changes a field no consumer uses, no contract breaks. If it changes a field a consumer depends on, provider verification fails precisely there.

3. Writing the consumer test on the frontend side

The consumer test runs against a mock server provided by Pact.js, not against the real backend. Inside the test, you first formulate the expectation of which request goes to which path and what response should come back. Then the actual frontend code, for instance a function using fetch, calls exactly that mock server, exercising the real code path while simultaneously recording the contract.


// npm install --save-dev @pact-foundation/pact vitest
// tests/contracts/product-api.pact.test.js
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { describe, it, expect } from 'vitest';
import { fetchProduct } from '../../src/api/products.js';

const { like, integer } = MatchersV3;

const provider = new PactV3({
  consumer: 'storefront-frontend',
  provider: 'catalog-backend',
});

describe('Product API contract', () => {
  it('fetches a product by id with the expected shape', () => {
    provider
      .given('a product with id 42 exists')
      .uponReceiving('a request for product 42')
      .withRequest({
        method: 'GET',
        path: '/api/products/42',
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: integer(42),
          name: like('Wireless Keyboard'),
          price: like(49.99),
          inStock: like(true),
        },
      });

    return provider.executeTest(async (mockServer) => {
      // Real application code, calling the Pact mock server instead of the real backend
      const product = await fetchProduct(mockServer.url, 42);
      expect(product.name).toBe('Wireless Keyboard');
      expect(product.price).toBe(49.99);
    });
  });
});

// Running this test generates a pact file:
// pacts/storefront-frontend-catalog-backend.json

What stands out about this contract testing approach is that the tested function fetchProduct is exactly the same implementation that also runs in production. The Pact mock server merely replaces the real network target during the test run. After a successful test run, a pact file exists that describes machine readably what the frontend expects from this endpoint, including status code, headers and body structure.

4. Matchers instead of exact values: flexible contracts

A common beginner mistake in contract testing is using exact values instead of matchers. If the expectation read price: 49.99 instead of price: like(49.99), provider verification would fail as soon as the real product price in the backend's test database has a different value, even though the structure of the response is entirely correct. Matchers like like() check the type and shape of a value, not its exact content, separating structural expectations from concrete test data.

Besides like() for arbitrary values of the same type, Pact.js offers integer(), string(), eachLike() for arrays with a repeating structure, and regex() for values that must follow a specific pattern, such as a UUID format or an ISO date. Choosing matchers correctly is decisive for the stability of contract testing: matchers that are too strict cause unnecessary failures on irrelevant data changes, matchers that are too loose overlook real structural breaks.


import { MatchersV3 } from '@pact-foundation/pact';

const { like, eachLike, regex, integer, iso8601DateTime } = MatchersV3;

// Contract expecting a list of orders with a repeating structure
const orderListExpectation = {
  status: 200,
  body: eachLike({
    orderId: regex('^ORD-[0-9]{6}$', 'ORD-000123'),
    total: like(129.5),
    createdAt: iso8601DateTime('2026-07-30T10:00:00Z'),
    itemCount: integer(3),
  }),
};

// eachLike() tells the provider verification: "expect an array where
// every element matches this shape" — not a fixed number of items

5. Provider verification on the backend side

On the backend side, provider verification reads the pact file generated by the frontend and replays every request described in it against the real, running backend application. For every expected state, for instance "a product with id 42 exists", the backend must provide test data through so-called provider states that actually reflect that state. Without correctly configured provider states, verification would fail, not because the contract was broken, but because the backend's test environment does not establish the assumed state.

The decisive advantage of contract testing shows here: the backend team does not need to know or inspect the frontend repository to know what to test. The pact file is self-contained and precisely describes which requests are expected with which responses. This decoupling lets frontend and backend teams work independently, while the pact file acts as the single, binding communication channel about the interface actually being used.


// tests/contracts/provider-verification.test.js — runs on the backend repository
import { Verifier } from '@pact-foundation/pact';
import { startTestServer, seedProduct } from '../helpers/server.js';

describe('Pact Provider Verification', () => {
  it('validates the catalog-backend against all consumer contracts', async () => {
    const server = await startTestServer();

    const opts = {
      provider: 'catalog-backend',
      providerBaseUrl: `http://localhost:${server.port}`,
      pactUrls: ['./pacts/storefront-frontend-catalog-backend.json'],
      stateHandlers: {
        'a product with id 42 exists': async () => {
          await seedProduct({ id: 42, name: 'Wireless Keyboard', price: 49.99 });
        },
      },
    };

    await new Verifier(opts).verifyProvider();
    await server.close();
  });
});

6. Pact Broker: exchanging contracts centrally

In larger organizations with many consumer and provider teams, manually distributing pact files quickly becomes impractical. The Pact Broker is a central service where consumers automatically upload their generated pact files after every successful test run and providers report back their verification results. For contract testing between frontend and backend at larger scale, the broker is practically indispensable, because it provides a searchable matrix of which consumer is compatible with which provider version.

The broker also visualizes so-called contract networks: who consumes which API, which version was last successfully verified, and which combinations are currently incompatible. This transparency is especially valuable in microservice architectures with many independently deployable frontends and backends, where a central overview of compatibility would otherwise be hard to establish.

7. can-i-deploy: safely gating deployments

A particularly practical feature of contract testing with Pact is the can-i-deploy command. Before a frontend or backend is deployed to a given environment, this command checks against the Pact Broker whether the current version is compatible with all counterparts already deployed. If the answer is no, the CI pipeline automatically prevents the deployment, before an incompatible combination of frontend and backend goes live.


# .gitlab-ci.yml — deployment gate using can-i-deploy
deploy-check:
  stage: pre-deploy
  script:
    - npx pact-broker can-i-deploy
        --pacticipant storefront-frontend
        --version $CI_COMMIT_SHA
        --to-environment production
        --broker-base-url $PACT_BROKER_URL
        --broker-token $PACT_BROKER_TOKEN
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

deploy-production:
  stage: deploy
  needs: ['deploy-check']
  script:
    - ./scripts/deploy.sh production

This safeguard is the practical core of contract testing between frontend and backend: instead of relying on manual coordination or a shared release announcement, the pipeline automatically prevents incompatible deployments. Teams can deploy independently and frequently, without having to manually coordinate compatibility across multiple repositories.

8. Limits: when contract testing is not enough

Contract testing checks structural and semantic compatibility of an interface, not the business correctness of the underlying logic. A backend can fulfill a contract perfectly and still compute the wrong price. Classic unit and integration tests on the backend side remain necessary for business correctness; contract testing does not replace them.

A second limit is the initial effort. For small teams with a single frontend and a single backend in the same repository, the added value of contract testing is small, because frontend and backend get deployed together anyway and incompatibilities surface immediately. The benefit grows significantly with the number of independent teams, repositories and deployment cycles, because that is exactly where implicit assumptions are most likely to drift apart unnoticed.

9. Contract testing compared to alternatives

The table below contrasts contract testing with other approaches for securing interfaces.

Approach Checks actual usage Catches breaking changes early Best for
Manual coordination No, error prone No, only reactive Very small, tightly aligned teams
OpenAPI specification Partially, provider centric Conditionally, no consumer relation Documentation, code generation
E2E tests against staging Yes, but slow Only after joint deployment Full system validation
Contract testing (Pact.js) Yes, consumer driven Yes, before deployment Independent teams, frequent deploys

Contract testing does not fully replace OpenAPI documentation or E2E tests, but it complements them with fast, targeted verification of exactly the interface points actually used by a consumer, without waiting for a slow, shared staging test.

Mironsoft

API contract safety for Magento and headless frontends

Prevent breaking changes between frontend and backend?

We set up contract testing with Pact.js between your frontend and backend teams, connect it to a Pact Broker and integrate can-i-deploy as a deployment gate in your CI pipeline.

Pact.js rollout

Set up consumer tests and provider verification for critical interfaces

Broker & CI gate

Set up a Pact Broker and integrate can-i-deploy as a deployment safeguard

Team workflow

Establish independent deployment processes between frontend and backend teams

10. Summary

Contract testing between frontend and backend makes implicit assumptions about a shared API explicit and machine checkable. The consumer, usually the frontend, defines its expectations as an executable test that automatically generates a pact file. The provider, usually the backend, automatically verifies whether its real implementation matches that contract, without needing to know the frontend repository. Matchers such as like(), eachLike() and regex() separate structural expectations from concrete test data and make contracts robust against irrelevant value changes.

The Pact Broker centralizes the exchange of contracts and verification results across many teams, while can-i-deploy prevents incompatible deployments directly in the CI pipeline. Contract testing does not replace business-level backend tests or full E2E tests, but it complements them with fast, targeted verification of exactly the interface points actually used, and is especially valuable for teams with independent deployment cycles.

Contract testing between frontend and backend — the essentials at a glance

Consumer driven contract

The frontend defines its expectations, the backend automatically verifies against them.

Matchers

like(), eachLike() and regex() separate structure from concrete test values for robust contracts.

Pact Broker

Central exchange of contracts and verification results between multiple teams.

can-i-deploy

Automatically prevents incompatible deployments directly in the CI pipeline.

11. FAQ: Contract Testing Between Frontend and Backend

1What is contract testing?
Checks whether a backend implements an interface the way a frontend actually expects.
2Difference to MSW?
MSW checks frontend reaction, contract testing additionally checks whether the real backend delivers the response.
3What is a consumer driven contract?
A contract defined by the consumer, describing exactly its actual requirements toward the API.
4Why matchers over exact values?
Matchers check type and shape, not exact content, preventing failures on irrelevant data differences.
5Does backend need frontend code?
No, the pact file is self-contained and describes all expected requests and responses.
6What is the Pact Broker for?
Centralizes contracts and verification results across teams with a searchable compatibility matrix.
7What does can-i-deploy do?
Checks compatibility before deployment and blocks incompatible combinations automatically.
8Does it replace E2E tests?
No, it checks structural compatibility, not full business correctness in system context.
9Worth it for small teams?
Less so if deployed together. High value for independent teams with separate deployment cycles.
10Works across languages?
Yes, the pact file format is language agnostic and works with any backend that has a Pact verifier.