There’s a failure mode that affects almost every team running a microservices architecture: you deploy a change to a provider service, all the provider’s tests pass, the CI pipeline goes green, and then something breaks in production because a consumer was relying on a field the provider just renamed. No one’s tests caught it because the provider tests don’t know about the consumer’s expectations, and the consumer tests don’t know about the actual provider behaviour.
This is the integration compatibility problem, and it’s where contract testing earns its place in the toolchain.
What Contract Testing Actually Does
Contract testing is the practice of capturing the expectations a consumer service has about a provider’s API, and then verifying that the provider actually meets those expectations. The consumer owns the contract — it says “I expect this endpoint to return a response with these fields in this format.” The provider runs against the contract and either passes or fails.
The key difference from integration testing is that you don’t need both services running simultaneously. The consumer tests generate a contract (a Pact file), that contract gets published somewhere both parties can access, and the provider verifies against it in its own test suite. Both sides test independently.
Pact is the most widely adopted tool for this. It’s been around since 2013, has implementations for most languages (JavaScript, Java, Python, Ruby, Go, .NET, Rust), and has a hosted service called PactFlow for managing contracts at scale. There’s also a self-hosted Pact Broker you can run if you’d rather keep things on-premises.
Setting Up a Basic Consumer Test
The consumer test is where you specify what you expect. In a JavaScript consumer using @pact-foundation/pact:
const { Pact } = require('@pact-foundation/pact');
const provider = new Pact({
consumer: 'OrderService',
provider: 'ProductCatalogue',
port: 8080,
});
describe('Product Catalogue integration', () => {
before(() => provider.setup());
after(() => provider.finalize());
it('retrieves product details', async () => {
await provider.addInteraction({
state: 'product 42 exists',
uponReceiving: 'a request for product 42',
withRequest: {
method: 'GET',
path: '/products/42',
},
willRespondWith: {
status: 200,
body: {
id: like(42),
name: like('Widget Pro'),
price: like(9.99),
},
},
});
const product = await getProduct(42);
expect(product.id).toEqual(42);
});
});
The like() matcher says “I expect a value of this type and shape, not necessarily this exact value.” That distinction matters: you’re testing that the structure is compatible, not that the data matches a specific fixture.
When this test runs, Pact starts a mock provider, your consumer code hits it, Pact records the interaction, and a Pact file gets written to disk. That file is the contract.
Provider Verification
On the provider side, you run a verification test that reads the contract and replays the interactions against the real provider:
const { Verifier } = require('@pact-foundation/pact');
describe('Pact Verification', () => {
it('validates the expectations of OrderService', () => {
return new Verifier({
provider: 'ProductCatalogue',
providerBaseUrl: 'http://localhost:3000',
pactUrls: ['./pacts/OrderService-ProductCatalogue.json'],
stateHandlers: {
'product 42 exists': () => {
// seed the test database with product 42
return seedDatabase({ id: 42, name: 'Widget Pro', price: 9.99 });
},
},
}).verifyProvider();
});
});
The provider states (stateHandlers) are how you ensure the provider database is in the right condition for each test scenario. This is the fiddly part — you need to keep the state handlers in sync with the consumer’s declared states, which takes discipline to maintain over time.
Where Pact Fits in the Pipeline
The standard workflow is:
- Consumer writes tests, generates Pact file
- CI pushes the Pact file to Pact Broker (or PactFlow)
- Provider CI pulls the Pact file and runs verification
- If verification passes, the deployment is safe
The can-i-deploy CLI command is particularly useful: it queries PactFlow to tell you whether a specific consumer version is compatible with a specific provider version before you deploy. This gives you a pre-deployment gate that’s based on actual compatibility evidence rather than guessing.
What Pact Doesn’t Cover
Contract testing verifies interface compatibility — it doesn’t replace integration testing or end-to-end testing entirely. If the logic of how two services interact needs to be tested (not just whether the API shapes match), you still need some form of integration test. Pact is also limited to request/response APIs in the traditional sense; testing streaming APIs or event-driven interactions requires the async Pact extensions, which are less mature.
The other honest limitation is maintenance burden. Consumer tests need to accurately reflect what the consumer actually does, and provider state handlers need to be kept up to date as the domain model evolves. Teams that neglect this end up with contracts that don’t represent real behaviour, which defeats the purpose.
AI-Generated Code and Contract Testing
One reason contract testing is getting renewed attention in 2026 is the increase in AI-assisted development. When a coding assistant generates integration code, it often makes assumptions about API shapes that don’t match the actual provider. Contract tests catch these mismatches immediately — if the generated consumer code expects a field that doesn’t exist, the contract test fails before the code reaches staging.
This is genuinely useful. The alternative is discovering the mismatch in a staging environment, which is slower and more disruptive, or worse, in production.
For teams doing significant AI-assisted development, establishing contract tests as a required step before any generated integration code goes to review is worth the upfront toolchain investment.