What is contract testing between services (e.g. using Pact), and how does it avoid the need for a full integration environment?
Consumer-driven contract testing lets two independently deployed services prove they agree on an interaction using nothing but a shared file — no shared environment, no both-services-running requirement.
LEARN BY DOINGPut your intuition to work3 steps
What is contract testing between services (e.g. using Pact), and how does it avoid the need for a full integration environment?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The Problem: Integration Environments Don't Scale With Team Count
Once a system is split into more than a handful of independently deployed services, "just spin up everything and run integration tests against it" stops working. Someone has to keep a shared staging environment in sync with a dozen services deployed by different teams on different schedules, seed it with compatible test data, and accept that any one service being down or half-deployed makes every other team's tests fail for reasons that have nothing to do with their change. The environment becomes a shared bottleneck: slow, flaky, and a source of "not my bug" finger-pointing.
Contract testing solves a narrower but much more tractable problem: instead of asking "does the whole system work together right now," it asks "does this provider still honor the exact interactions this consumer depends on," and it answers that question without either service needing the other to be running.
#Consumer-Driven Contracts, in Outline
The consumer team writes a test against a mock of the provider, describing exactly the requests it will send and the responses it needs back. Running that test does two things: it verifies the consumer's own code handles the response correctly, and it produces a contract file, usually JSON, recording every interaction that was exercised. That contract is the artifact of record. The provider team then runs a separate verification step that replays every recorded request against their real implementation and checks the real response matches what the consumer expects. Neither side ever needs the other's service running to test against — the mock stands in during consumer testing, and the recorded contract stands in during provider testing.
#A Real Consumer Test
Here's a consumer-side contract test for a BillingService that depends on an OrderService endpoint, using Pact:
// consumer.pact.test.jsconst path = require('path');const { PactV3, MatchersV3 } = require('@pact-foundation/pact');const { getOrder } = require('./orderClient');const { like, eachLike, integer } = MatchersV3;const provider = new PactV3({consumer: 'BillingService',provider: 'OrderService',dir: path.resolve(process.cwd(), 'pacts'),});describe('OrderService contract', () => {it('returns an order by id', async () => {provider.given('order 42 exists').uponReceiving('a request for order 42').withRequest({method: 'GET',path: '/orders/42',headers: { Accept: 'application/json' },}).willRespondWith({status: 200,headers: { 'Content-Type': 'application/json' },body: {id: integer(42),status: like('SHIPPED'),items: eachLike({ sku: like('SKU-1'), qty: integer(2) }),},});await provider.executeTest(async (mockServer) => {const order = await getOrder(mockServer.url, 42);expect(order.id).toBe(42);expect(order.status).toBe('SHIPPED');});});});
Notice the matchers: like('SHIPPED') doesn't lock the provider into always returning exactly that string, it asserts "a string shaped like this," so the provider can return "DELIVERED" tomorrow without breaking the contract, as long as it's still a string. integer(42) and eachLike(...) similarly assert shape and type rather than exact values. This is the detail that keeps contracts from becoming as brittle as a snapshot test.
#The Provider Side Verifies Against Its Real Implementation
// provider.pact.test.jsconst path = require('path');const { Verifier } = require('@pact-foundation/pact');const { startServer } = require('./server');const { seedOrder } = require('./testSupport');describe('OrderService - Pact provider verification', () => {let server;beforeAll(async () => {server = await startServer(8080);});afterAll(async () => {await server.close();});it('satisfies the BillingService contract', () => {return new Verifier({provider: 'OrderService',providerBaseUrl: 'http://localhost:8080',pactUrls: [path.resolve(process.cwd(), 'pacts/billingservice-orderservice.json')],stateHandlers: {'order 42 exists': async () => {await seedOrder({ id: 42, status: 'SHIPPED', items: [{ sku: 'SKU-1', qty: 2 }] });},},}).verifyProvider();});});
The stateHandlers block is what makes this work against a real server instead of a mock: before replaying the "a request for order 42" interaction, Pact calls the handler registered for the state 'order 42 exists', which seeds a real order into the provider's real database. The provider is genuinely running, genuinely handling the HTTP request, and genuinely returning real JSON — the only thing that isn't "real" is that the consumer isn't present.
#Why This Avoids a Full Integration Environment
Neither test required the other service to be deployed anywhere. The consumer test ran against an in-process mock server that Pact spins up and tears down automatically. The provider test ran against a locally started instance of the provider talking to a local or in-memory database. Nothing crossed a network boundary between two independently deployed services, and yet both sides now have strong evidence that they agree on the shape of their interaction.
#How It Compares to Full Integration Testing
| Contract test | Full integration test | End-to-end test | |
|---|---|---|---|
| Needs the other service deployed? | No — mock (consumer side) or contract replay (provider side) | Usually yes, or a close local double | Yes, the whole deployed system |
| Speed | Seconds | Seconds to minutes | Minutes or more |
| What it catches | Interface/schema mismatches between two specific services | Real behavior of a real dependency (a real DB query working) | Whether a full user journey works end-to-end |
| What it misses | Infra concerns: auth, network policy, multi-service chains, timing | Cross-service chains beyond the one dependency under test | Root cause when something fails |
#Versioning and the Deploy Gate: Where a Broker Comes In
In practice, contracts are published to a Pact Broker (or an equivalent registry) rather than passed around as local files. This gives you two things a local-only setup can't: a record of which consumer version depends on which provider version, and a can-i-deploy check that a CD pipeline can call before releasing — "has every consumer's contract that depends on my API been verified against this new version?" That question, answerable in seconds against the broker, replaces a huge amount of what a shared staging environment used to be needed for.
#When It Actually Pays Off
Contract testing earns its cost specifically when: services are owned by different teams with independent deploy cadences, the interaction is synchronous HTTP/gRPC (or an event schema, for the async variant), and integration environment flakiness or slowness is already a measured pain point. For a monolith, or for two services always deployed together by the same team, the coordination cost this solves doesn't exist yet, and a normal integration test suite is simpler and just as effective.