Stopping breaking changes before they reach production
Contract testing with Pact replaces expensive end to end tests across service boundaries with fast, isolated checks. The consumer defines its expectations of an interface as a machine readable contract, which the provider automatically verifies in its own CI pipeline. This catches incompatible API changes between frontend and backend long before they affect customers in production.
Table of Contents
- 1. Why contract testing secures interfaces between services
- 2. How Pact works technically: consumer, mock and contract
- 3. The pact file: the contract as a machine readable agreement
- 4. The Pact Broker: publishing and distributing contracts
- 5. Provider verification: the contract meets the real API
- 6. Stopping breaking changes: contract verification in CI
- 7. can-i-deploy, versioning and tagging of contracts
- 8. Practical example: a Hyva frontend as consumer of a checkout API
- 9. Contract testing vs. full E2E tests compared
- 10. Summary
- 11. FAQ
1. Why contract testing secures interfaces between services
Consumer-driven contract testing flips the classic testing order around. Instead of a central team writing a full end-to-end suite across every service involved, the consumer itself, usually a frontend or a calling service, states its concrete expectations of an interface: which endpoints it calls, which fields it reads from the response and in what shape it expects them. These expectations are captured as a contract, a precise, machine readable description of real interactions rather than an abstract interface specification.
The benefit shows up most clearly in microservice landscapes with many independently deployable services. Full E2E tests across multiple service boundaries are slow, require elaborate test environments with realistic data and are prone to flakiness from network latency, timing and concurrency. Pact, the leading contract testing framework, moves the check to the boundary of responsibility: the consumer tests against a mock, the provider verifies against the real contract, each independently, without ever needing both systems running together.
2. How Pact works technically: consumer, mock and contract
The flow starts on the consumer side. Using a normal test framework such as Jest, Mocha or PHPUnit, the developer defines an interaction: an expected request to the provider and the expected response, including status code, headers and body. Pact spins up a local mock provider server for this, against which the consumer's actual API client is tested. If the test fails because the client formats requests differently or expects different fields, that is a bug in the consumer code, not in the provider.
What matters is using matchers instead of hardcoded values. Rather than expecting exactly "4711", like('4711') describes that a string of the same shape is expected, regardless of the concrete value. This keeps the contract stable against legitimate data changes while still catching structural changes that would otherwise slip through unnoticed. Once the test passes, Pact automatically writes a JSON file, the actual contract, documenting precisely what the consumer expects from the provider.
// consumer/pricing.pact.test.js
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, eachLike, decimal } = MatchersV3;
const { fetchCartPrice } = require('../src/api/pricingClient');
const provider = new PactV3({
consumer: 'hyva-checkout-frontend',
provider: 'pricing-service',
dir: './pacts',
});
describe('Pricing API contract', () => {
it('returns the price for a valid cart id', async () => {
provider
.given('cart 4711 exists with two items')
.uponReceiving('a request for the cart price')
.withRequest({
method: 'GET',
path: '/v1/carts/4711/price',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
cartId: like('4711'),
currency: like('EUR'),
grandTotal: decimal(89.97),
items: eachLike({
sku: like('MS-1001'),
rowTotal: decimal(29.99),
}),
},
});
await provider.executeTest(async (mockServer) => {
const price = await fetchCartPrice(mockServer.url, '4711');
expect(price.currency).toBe('EUR');
expect(price.items.length).toBeGreaterThan(0);
});
});
});
3. The pact file: the contract as a machine readable agreement
The generated pact file is plain JSON and follows the Pact specification. It contains consumer and provider names, a list of interactions each with its request, expected response and the matchingRules that define which fields are checked exactly and which are only checked structurally. This exact split between a concrete example value and an abstract rule is what distinguishes a Pact contract from a static OpenAPI schema: the contract describes real, executable interactions, not a pure type definition.
Because the file is produced from real test runs, it cannot go stale without the associated consumer test failing. If the frontend's expected behavior changes, the contract changes automatically on the next test run. That fundamentally sets Pact apart from hand-maintained API documentation, which regularly drifts from the actual implementation because nobody keeps it in sync.
{
"consumer": { "name": "hyva-checkout-frontend" },
"provider": { "name": "pricing-service" },
"interactions": [
{
"description": "a request for the cart price",
"providerState": "cart 4711 exists with two items",
"request": {
"method": "GET",
"path": "/v1/carts/4711/price",
"headers": { "Accept": "application/json" }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"cartId": "4711",
"currency": "EUR",
"grandTotal": 89.97,
"items": [
{ "sku": "MS-1001", "rowTotal": 29.99 }
]
},
"matchingRules": {
"body": {
"$.cartId": { "matchers": [{ "match": "type" }] },
"$.grandTotal": { "matchers": [{ "match": "decimal" }] },
"$.items": { "matchers": [{ "match": "type", "min": 1 }] }
}
}
}
}
],
"metadata": {
"pactSpecification": { "version": "3.0.0" }
}
}
4. The Pact Broker: publishing and distributing contracts
A contract that lives only in the consumer's local repository is of no use to the provider. The Pact Broker is the central hub that consumers publish their generated contracts to after every successful test run. The broker versions every contract, links it to the consumer application version, usually the Git SHA, and provides a network diagram showing which consumer connects to which provider through which contracts. In grown microservice landscapes, that alone is a significant improvement over scattered, outdated documentation.
The broker also supports webhooks: whenever a new or changed contract is published, a build in the provider's CI pipeline can be triggered automatically to verify exactly that contract right away. This closes the loop between the consumer and provider teams without either side having to manually coordinate when an interface change gets checked. In practice, publishing runs as the last step of the consumer pipeline, right after the contract tests pass.
#!/usr/bin/env bash
# Publish the generated pact file to the Pact Broker after consumer tests pass
set -euo pipefail
pact-broker publish ./pacts \
--consumer-app-version="$(git rev-parse --short HEAD)" \
--branch="$(git rev-parse --abbrev-ref HEAD)" \
--broker-base-url="https://pact-broker.mironsoft.de" \
--broker-token="$PACT_BROKER_TOKEN"
echo "Contract published for hyva-checkout-frontend, triggering provider verification via webhook"
5. Provider verification: the contract meets the real API
During provider verification, the provider downloads the most recently published contracts from the broker and replays every interaction they contain against the actual running API, not against a mock. The real response is checked against the matchers defined in the contract. If a field name, a data type or a status code differs, verification fails right at the point where the incompatibility originates, not somewhere deep inside a nested end-to-end test chain.
For interactions such as "cart 4711 exists with two items" to be reproducibly testable at all, the provider registers state handlers: small functions that set up the matching test data before the respective interaction runs. Without state handlers, every contract would have to rely on whatever production data happened to exist, which would make verification unreliable. After the run, the verifier publishes the result, passed or failed, back to the broker so both teams see the same status.
// provider/verify.ts
import { Verifier } from '@pact-foundation/pact';
async function runVerification(): Promise<void> {
const opts = {
provider: 'pricing-service',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: 'https://pact-broker.mironsoft.de',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
consumerVersionSelectors: [
{ mainBranch: true },
{ deployedOrReleased: true },
],
stateHandlers: {
'cart 4711 exists with two items': async () => {
await seedTestCart('4711', [
{ sku: 'MS-1001', qty: 2, price: 29.99 },
]);
},
},
};
await new Verifier(opts).verifyProvider();
}
runVerification().catch((err) => {
console.error('Provider verification failed', err);
process.exit(1);
});
6. Stopping breaking changes: contract verification in CI
Contract testing only delivers its real value once provider verification is a mandatory step in the CI pipeline. If a backend developer changes a response format, renames a field or removes a property a consumer relies on, the verification step fails and the pipeline turns red before the merge or the deployment happens. The error message shows exactly which interaction and which field are affected, which speeds up debugging considerably compared to a failed end-to-end test in a staging environment.
A typical example from practice: a pricing service changes grandTotal from a number to a string because a new library serializes monetary amounts differently. Without contract testing, that would only surface once customers see wrong or missing prices at checkout. With Pact, provider verification fails immediately, because the decimal matcher expects a numeric type. The developer finds out within seconds, directly in their own CI run, without the consumer having to do anything at all.
7. can-i-deploy, versioning and tagging of contracts
Even once contract and provider are verified, one question remains: does the version about to be deployed fit with every version currently running in the target environment? That is exactly what the CLI command can-i-deploy answers. It asks the broker whether a particular provider or consumer version has already been successfully verified against all relevant counterparts active in the target environment, such as "production". If not, the deployment step aborts in a controlled way instead of shipping an incompatible combination live.
For that to work, versions must be consistently tagged: every successful verification is linked to the application version, and every deployment is recorded in the broker via record-deployment with an environment tag such as "staging" or "production". That builds up a complete compatibility matrix across all environments, which can-i-deploy evaluates automatically before every deployment instead of relying on manual coordination between teams.
# .github/workflows/deploy-pricing-service.yml
name: Deploy pricing-service
on:
push:
branches: [main]
jobs:
verify-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run provider verification against latest contracts
run: npm run pact:verify
env:
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
- name: Check deployment safety
run: |
pact-broker can-i-deploy \
--pacticipant pricing-service \
--version "$(git rev-parse --short HEAD)" \
--to-environment production \
--broker-base-url https://pact-broker.mironsoft.de \
--broker-token "$PACT_BROKER_TOKEN"
- name: Deploy to production
if: success()
run: ./deploy.sh production
- name: Record deployment in Pact Broker
if: success()
run: |
pact-broker record-deployment \
--pacticipant pricing-service \
--version "$(git rev-parse --short HEAD)" \
--environment production
8. Practical example: a Hyva frontend as consumer of a checkout API
In a Magento setup with the Hyva theme, an Alpine.js component in the checkout renders the grand total, taxes and active discounts, usually via the Magento GraphQL API or a dedicated pricing microservice running alongside Magento. Without contract testing, you would typically secure this relationship with a Cypress E2E test that boots a full Magento instance with test data, adds a product to the cart and checks the rendered price in the DOM. That works, but it is slow and, with every backend change, a potential single point of failure for the entire frontend pipeline.
With Pact, the frontend team instead writes a consumer test that precisely specifies which fields it expects from the pricing response, including currency format and discount structure. This test runs in milliseconds, without Magento, without a database, without any network calls. The backend team verifies the same contract against the real pricing logic in its own pipeline, with realistic state handlers for discount campaigns or tax classes. Both teams work independently, and the contract is the single shared source of truth between frontend and backend.
9. Contract testing vs. full E2E tests compared
Contract testing is not a replacement for every kind of end-to-end test, but the right tool for a specific class of problems: many independent services, expensive or flaky cross-service E2E suites, and the need for fast feedback right inside the developer workflow. For critical user journeys, visible UI behavior and timing or race condition issues that only show up when real systems interact, a genuine E2E or integration test with Cypress or Playwright remains necessary; contract testing deliberately does not check that layer.
| Dimension | Contract Testing (Pact) | Full E2E Tests Across Service Boundaries | Practical Implication |
|---|---|---|---|
| Speed | Milliseconds per interaction, no real deployment needed | Seconds to minutes per scenario, real systems must be running | Contract tests run on every commit, E2E more like hourly or daily |
| Flakiness | Deterministic, no network, no timing dependency | Prone to network latency, concurrency and test data drift | Fewer red pipelines from environment issues instead of real bugs |
| Fault localization | Exact interaction and field are named | The fault can be anywhere in the whole stack | Significantly shorter diagnosis time for contract violations |
| CI cost | No shared test cluster, no seeding across multiple services | Elaborate test environments with coordinated test data | Contract testing scales linearly with the number of services |
| Coverage of UI behavior | Does not check rendering, timing or race condition effects | The only method that verifies real user journeys end to end | Critical checkout flows still need real E2E tests |
Mironsoft
API testing, CI/CD pipelines and quality assurance for Magento and Hyva shops
Want to introduce contract testing for your interfaces?
We analyze your service landscape, identify the interfaces with the greatest E2E risk, and introduce Pact with broker, CI integration and can-i-deploy gates in a production-ready way, tailored to Magento, Hyva and connected microservices.
Contract testing setup
Pact integration for consumer and provider, including matcher strategy
Pact Broker & CI gates
Broker operation, webhooks and can-i-deploy integrated into existing pipelines
E2E strategy
A sensible balance between contract tests and Cypress/Playwright E2E suites
10. Summary
Contract testing with Pact solves a concrete problem of microservice and API driven development: reliably securing interfaces between independently deployable teams without having to run a full, slow end-to-end suite for every change. The consumer defines its expectations as a contract against a mock, the provider verifies that same contract against its real implementation, and the Pact Broker connects both sides through versioning, tagging and can-i-deploy checks into a closed, automated workflow.
The key is to see contract testing as a complement, not a replacement. Critical user journeys, visible UI behavior and timing issues can only be covered with real E2E tests such as Cypress or Playwright. Pact's strength lies in finding exactly that class of bugs, incompatible API changes, early, cheaply and deterministically in your own CI run, before they surface in an expensive, flaky cross-service pipeline or, worse, in production.
Contract Testing with Pact - The Essentials at a Glance
Consumer-Driven Contracts
The consumer defines its expectations against a mock provider and automatically generates the pact file from that.
Provider verification as a CI gate
The provider replays every interaction against the real API. Deviations fail the pipeline before the deploy.
Pact Broker & can-i-deploy
Central versioning, per-environment tagging and automated compatibility checks before every deployment.
A complement, not a replacement for E2E
Critical user journeys, UI behavior and race conditions still need real Cypress or Playwright tests.