Contract Testing with Pact: Preventing API Integration Failures
A backend team ships a release on Tuesday. A small cleanup renames one field on an API response—customer_id becomes customerId to match the rest of the schema. The change passes every test in the backend pipeline. The deploy goes green. Thirty minutes later, the mobile checkout flow starts returning blank screens because the frontend is still reading customer_id, and nobody noticed.
This pattern is so common that most teams have stopped being surprised by it. Integration between a frontend and a backend is one of the thinnest, most fragile interfaces in modern software, and the usual defenses—end-to-end tests, staging environments, manual QA—catch failures late, if at all. Contract testing, and specifically the Pact framework, exists to close that gap. Rather than running the whole stack to verify integrations, it encodes the exact expectations two services have of each other and runs them as fast, isolated tests on both sides.
This post walks through what contract testing actually is, where it fits in relation to the testing tools you already use, and how to set it up practically in a PHP/Symfony and Next.js environment. It is aimed at teams who have felt the pain of production API breakage and want a structural fix rather than more process.
Why End-to-End Testing Is Not the Answer
The instinct when a frontend and backend fall out of sync is to add more end-to-end tests. The theory is sound: run both services, make real requests, check the outputs. In practice, this approach fails predictably.
End-to-end tests are slow. A suite that spins up databases, seeds fixtures, boots the backend, renders the frontend, and exercises key flows can easily take twenty minutes to run. Teams respond by running it nightly instead of on every commit, which means integration breakage is caught hours or days after it is introduced—long after the developer has moved on and context is expensive to rebuild.
End-to-end tests are brittle. They fail for reasons unrelated to the code under test: a slow database, a network hiccup, a timing issue in the frontend render, a shared test database mutated by a parallel run. Teams start marking failures as flaky, retrying them automatically, and eventually stop trusting the suite entirely.
Most importantly, end-to-end tests do not isolate the failure. When the suite goes red, you know something is wrong across two services, a database, a queue, and a browser. Pinpointing whether it was a backend regression, a frontend bug, or an infrastructure flake takes real investigation. The signal-to-noise ratio is low.
End-to-end coverage still has its place—for smoke-testing critical user flows—but it is the wrong tool for validating API compatibility. The right tool is contract testing.
What Contract Testing Actually Verifies
A contract is a precise specification of one interaction between two services: the consumer (typically the frontend, or an upstream service) and the provider (typically the backend API). A single contract captures the exact request the consumer will make—method, path, headers, body shape—and the exact response it expects, including status code, headers, and body structure.
Contract tests run that specification against both sides independently. On the consumer side, the framework starts a mock server that responds according to the contract, and the real consumer code is exercised against it. If the frontend sends a malformed request or breaks when a documented response shape is returned, the test fails. On the provider side, the framework replays the recorded requests against the real provider and verifies that responses match the contract. If the backend has renamed a field, removed a required property, or changed a status code, the test fails.
The critical property is that these two sides run in isolation. The consumer does not need the backend to be running. The provider does not need the frontend. The contract file—a JSON document—is the single source of truth that travels between them, typically via a broker or artifact store.
This is why contract testing is sometimes called consumer-driven contract testing: the contract is generated by the consumer's test suite, capturing what the consumer actually needs, not what the provider happens to return. The provider is then held accountable to deliver exactly that. If a backend team wants to change a response shape, the contract test on the provider side fails immediately, and the change becomes a deliberate, coordinated decision rather than an accidental breaking change discovered in production.
How Pact Works End-to-End
Pact is the most widely adopted open-source framework for contract testing. It has mature client libraries for PHP, JavaScript/TypeScript, Go, Java, .NET, Python, and Ruby, which makes it realistic to use on polyglot teams.
The workflow has four stages. First, the consumer team writes Pact tests that describe the interactions they depend on and runs them against a local Pact mock server. These tests produce a contract file—a .json pact document that lists every interaction verified. Second, the consumer pipeline publishes the contract to a Pact Broker, a lightweight shared server that stores contracts keyed by consumer and provider names and versions. Third, the provider pipeline fetches the latest contracts relevant to it from the broker and runs verification: replaying the recorded requests and comparing responses. Fourth, the broker records the verification result, producing a compatibility matrix showing which consumer versions work with which provider versions.
The practical effect is that the provider team gets a pull-request-time signal: "if you merge this change, you will break consumer X in environment Y." That signal is the entire point. The cost of fixing a breaking API change is trivial before merge. It is painful once it is in staging, and catastrophic in production.
A PHP Provider, a Next.js Consumer
In a typical Wolf-Tech engagement, the split is a Symfony backend exposing a JSON API and a Next.js frontend consuming it. Both sides can use Pact, and the setup is not exotic.
On the consumer side—a Next.js application using @pact-foundation/pact for Node—a contract test describes an interaction the frontend depends on:
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { fetchCustomer } from './customer-client';
const provider = new PactV3({
consumer: 'web-frontend',
provider: 'customer-api',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('Customer API client', () => {
it('returns customer details by id', async () => {
provider
.given('a customer with id 42 exists')
.uponReceiving('a request for customer 42')
.withRequest({
method: 'GET',
path: '/api/customers/42',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: MatchersV3.integer(42),
displayName: MatchersV3.string('Ada Lovelace'),
email: MatchersV3.email('ada@example.com'),
createdAt: MatchersV3.iso8601DateTime(),
},
});
await provider.executeTest(async (mock) => {
const customer = await fetchCustomer(mock.url, 42);
expect(customer.displayName).toBe('Ada Lovelace');
});
});
});
Notice the use of matchers rather than literal values for id, displayName, email, and createdAt. This is deliberate. The contract must capture the shape the consumer depends on, not specific fixture values. Matchers document that the frontend needs an integer, a non-empty string, a valid email, and an ISO 8601 timestamp—nothing more. If the provider returns a different type, verification fails. If the provider returns different specific values, verification passes.
On the provider side—a Symfony application using the pact-php package—verification looks like this:
<?php
use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfig;
use PhpPact\Standalone\ProviderVerifier\Verifier;
use PhpPact\Standalone\ProviderVerifier\Model\Source\BrokerSource;
$config = (new VerifierConfig())
->setProviderInfo((new \PhpPact\Consumer\Model\ProviderInfo())
->setName('customer-api')
->setHost('localhost')
->setPort(8080))
->setProviderVersion(getenv('GIT_SHA'))
->setProviderBranch(getenv('GIT_BRANCH'))
->setPublishResults(true);
$verifier = new Verifier($config);
$verifier->addSource(
new BrokerSource(
new \Psr\Http\Message\UriInterface('https://pact.internal'),
'ci-token'
)
);
$verifier->verify();
Before verifying, the provider must be running in a known state. The given('a customer with id 42 exists') line in the consumer test becomes a provider state—a hook the Symfony application registers to seed the database so the expected interaction can succeed:
#[Route('/_pact/provider-states', methods: ['POST'])]
public function provideState(Request $request): JsonResponse
{
$state = $request->toArray()['state'] ?? null;
match ($state) {
'a customer with id 42 exists' => $this->seedCustomer(42, 'Ada Lovelace'),
default => null,
};
return new JsonResponse(['result' => 'ok']);
}
This is the piece that often surprises teams new to Pact: provider states are not fixtures. They are callable setup functions, and they are part of the contract. Both sides must agree that "a customer with id 42 exists" has a consistent meaning, which is usually enforced by keeping the state names in a shared registry or reviewing them together during interface design.
Wiring It Into CI
Contract tests only deliver their value if they run on every change and block merges on failure. The practical CI setup has three moving parts.
On the consumer side, the Pact tests run as part of the standard test suite. When they pass, the pipeline publishes the resulting contract to the broker, tagged with the consumer's branch and version. The can-i-deploy tool then asks the broker whether this specific consumer version is compatible with the currently deployed provider in each target environment. If the answer is no—because the provider has not yet been updated to support a new interaction—the deploy is blocked.
On the provider side, every pull request triggers a job that pulls all consumer contracts currently deployed in production (and staging, depending on policy) and runs verification against the new code. If the change would break any deployed consumer, the PR is blocked with a precise error message identifying the consumer, the interaction, and the field that changed.
Done correctly, this eliminates the class of bug that motivated this post. A field rename cannot reach production without the consumer team knowing about it, because their contract verification fails on the provider PR. A new required query parameter cannot be added silently, because the consumer's existing contract does not include it.
When Contract Testing Is Not the Right Fit
Contract testing is powerful, but it is not a universal replacement for other test types. It validates interfaces, not business logic. A provider can pass every contract test and still compute the wrong total, because the contract only verifies that the response shape is correct, not that the value is right. Unit tests and integration tests on the provider's business logic remain essential.
Contract testing is also less useful for public APIs with many unknown consumers. The consumer-driven model works because you know who your consumers are and they participate in the contract. A public REST API with thousands of external users is better served by strict versioning, deprecation policies, and OpenAPI schema validation.
For internal APIs between teams you own, though, contract testing is close to a no-brainer. The cost is modest—a few days of setup, then ongoing test maintenance similar to any other suite—and the payoff is the elimination of an entire category of production incidents.
Making It Part of How You Ship
Introducing Pact into an existing codebase is usually a gradual process. Start with one high-traffic interaction between one consumer and one provider, prove the value, and expand from there. A common Wolf-Tech recommendation is to pair contract test adoption with a broader code quality audit or a custom software development engagement, because the places where contracts are most valuable—thin, fragile, frequently-changing API boundaries—are also the places where other architectural issues tend to cluster.
If your team is dealing with frequent frontend-backend integration breakage, unreliable end-to-end tests, or painful coordination between teams shipping at different cadences, contract testing is almost certainly part of the solution. Reach us at hello@wolf-tech.io or visit wolf-tech.io for a free consultation on how to introduce Pact into your stack without slowing down current delivery.

