Expertise hub / Technical
Technical Microservices 6 min read

Less E2E. More confidence. Faster releases.

Testing each service independently, with no shared test environment and no orchestration problem. That's what contract testing delivers in a microservices architecture, and it works differently than most teams think.

At many teams the integration test still runs on the same old recipe: spin up the whole stack, hope everything works together, and wait three hours for a failing pipeline. Contract testing breaks that pattern.

With Pact you're not testing whether everything runs together, but whether each service honours what the others expect from it. In seconds, not minutes.

How consumer-driven contracts work

The principle comes down to three steps.

1. The consumer writes a contract

Service A, the consumer, records which request it sends and which response it expects.

2. The contract goes to the Pact Broker

That contract gets published to a central store, the Pact Broker.

3. The provider runs the contracts as a test

Service B, the provider, runs the contracts as tests, without service A having to be active at all.

The result: every service gets tested independently, with no shared test environment and no orchestration problem.

A concrete example with Pact-JS

Say the checkout service (consumer) calls the user service (provider) for customer data. Here's what that contract looks like in practice.

consumer-test.jscheckout-service
// records the request/response checkout-service expects
const { PactV3 } = require('@pact-foundation/pact');

const provider = new PactV3({ consumer: 'checkout-service', provider: 'user-service' });

provider
  .given('user 123 exists')
  .uponReceiving('a request for user 123')
  .withRequest({ method: 'GET', path: '/users/123' })
  .willRespondWith({
    status: 200,
    body: { id: 123, name: 'Jelle', email: 'jelle@qadvice.be' }
  });

it('gets the user', () =>
  provider.executeTest(async (mockserver) => {
    const res = await fetch(`${mockserver.url}/users/123`);
    expect(res.status).toBe(200);
  })
);

This test automatically generates the contract and publishes it to the Pact Broker:

pact-brokercheckout-service ↔ user-service
// abbreviated contract, stored in the Pact Broker
{
  "consumer": { "name": "checkout-service" },
  "provider": { "name": "user-service" },
  "interactions": [{
    "description": "a request for user 123",
    "request": { "method": "GET", "path": "/users/123" },
    "response": {
      "status": 200,
      "body": { "id": 123, "name": "Jelle", "email": "jelle@qadvice.be" }
    }
  }]
}

The provider verifies that contract without checkout-service ever having to run:

provider-test.jsuser-service
// replays every published contract against the real provider
const { Verifier } = require('@pact-foundation/pact');

new Verifier({
  provider: 'user-service',
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: 'https://pact.qadvice.be',
  publishVerificationResult: true,
  providerVersion: '1.4.2'
}).verifyProvider();

If user-service stops returning the email field, verification fails immediately, and you know exactly which service and which field are the problem, without checkout-service ever having to spin up.

What it delivers in the pipeline

Seconds
no full stack needed
Precise
fault location per service
Independent
deploys per team

When a contract test fails, it's immediately clear which service no longer delivers which field, and who needs to fix it. The Pact Broker also offers a can-i-deploy check: is it safe to release this version, given every contract linked to it? The result is fewer flaky tests, less distrust in the suite, and a faster release cycle.

When you use contract testing

Contract testing works well for REST APIs, so GET and POST interfaces between services, and also works for async messaging such as Kafka, RabbitMQ or SNS/SQS. For complex, stateful multi-step interactions it's less suited. And for critical user flows you still need end-to-end testing, contract testing doesn't replace that.

Rule of thumb: contract testing for interfaces, end-to-end for user scenarios.

Three pitfalls to avoid

Outdated contracts

Contracts that don't evolve with the code create a false sense of security. A contract nobody updates anymore isn't testing anything meaningful.

Over-specifying contracts

Over-specifying makes the provider unnecessarily fragile. Only test what the consumer actually uses, not every field that happens to sit in the response.

Throwing out all end-to-end tests

Contract testing validates interfaces, not business logic. Keep a lean end-to-end suite for the paths that genuinely matter.

Contract testing works best as an addition to a well-thought-out test strategy, not as a replacement for it. Less end-to-end, more confidence, faster releases: that's what it delivers when you use it well.

Carousel: contract testing with Pact
// Also as a carousel

This article first appeared as a carousel on LinkedIn.

View on LinkedIn
// Let's talk

Sounds familiar?

We'll review your test approach and tell you honestly where the gains are. And where they aren't.

Book a call
// Read next