Ch. 22 · Software Testing

Contract Testing Across Independent Services

Consumer-driven contracts in practice: a shipping service records what it needs from orders, and a renamed field fails before deploy.

~7 min readintermediateupdated Oct 11, 2026

When two services are deployed independently, the API between them is a promise neither side can see the other keeping. The orders team refactors a response, their tests pass, they deploy, and the shipping service starts printing labels addressed to undefined. Nothing was wrong with either codebase on its own. The bug lived in the agreement between them.

The traditional answer is an end-to-end environment with every service running, where integration tests exercise real calls. That works, but it’s slow, it breaks for reasons unrelated to your change, and it tells you about a problem only after both sides have merged. Contract testing checks the agreement directly, in each service’s own pipeline, without running the other service at all.

What a contract records

A contract is a list of interactions. Each one says: when the provider is in this state, and the consumer sends this request, the response has this status and this shape. In consumer-driven contract testing, which is the most common style, the consumer writes the contract, and it lists only what the consumer actually uses.

That last part is the important design choice. If the orders API returns 30 fields and shipping uses five of them, the contract mentions five. The orders team can then rename or remove any of the other 25 without asking anyone, and the contract tells them precisely which changes would hurt shipping.

The examples below use a small hand-written helper on top of Node’s test runner so every moving part is visible. Real projects usually use Pact, which implements the same flow with more features; the concepts map across directly.

The consumer side: testing against the contract

The shipping service needs an order’s ID, the recipient’s name and postcode, and item weights to build a label. Its client code:

export async function fetchOrderForLabel(baseUrl, orderId) {
  const res = await fetch(`${baseUrl}/orders/${orderId}`);
  const body = await res.json();
  if (res.status === 404) return { found: false, reason: body.error.code };
  if (!res.ok) throw new Error(`orders API returned ${res.status}`);
  return {
    found: true,
    orderId: body.id,
    recipient: body.shippingAddress.name,
    postcode: body.shippingAddress.postcode,
    weightGrams: body.items.reduce((sum, item) => sum + item.weightGrams * item.qty, 0),
  };
}
JavaScript

The consumer’s tests define the interactions, start a local stub server that answers with them, and run the real client code against it. When the tests finish, the interactions are written to contracts/shipping-orders.json:

const interactions = [
  {
    description: 'an existing order',
    providerState: 'order A-1001 exists',
    request: { method: 'GET', path: '/orders/A-1001' },
    response: {
      status: 200,
      body: {
        id: 'A-1001',
        shippingAddress: { name: 'Ana Silva', postcode: 'SW1A 1AA' },
        items: [{ sku: 'MUG', qty: 2, weightGrams: 350 }],
      },
    },
  },
  {
    description: 'an unknown order',
    providerState: 'no order A-9999',
    request: { method: 'GET', path: '/orders/A-9999' },
    response: { status: 404, body: { error: { code: 'order_not_found' } } },
  },
];

test('builds label data from an order', async () => {
  assert.deepEqual(await fetchOrderForLabel(stub.url, 'A-1001'), {
    found: true, orderId: 'A-1001', recipient: 'Ana Silva', postcode: 'SW1A 1AA', weightGrams: 700,
  });
});

test('reports a missing order without throwing', async () => {
  assert.deepEqual(await fetchOrderForLabel(stub.url, 'A-9999'), { found: false, reason: 'order_not_found' });
});
JavaScript

Both pass. So far this looks like an ordinary test with a stub, and that’s the point: the consumer gets fast, reliable tests. The difference is that the stub isn’t something the consumer can make up in private. It’s published, and the provider has to prove it’s accurate.

The provider side: replaying the contract

The orders service reads the contract file and, for each interaction, sets up the named provider state, sends the recorded request to its real HTTP handler, and checks the response. The check is about shape, not exact values. Every field in the contract’s example must be present with the same JSON type; extra fields are fine.

const providerStates = {
  'order A-1001 exists': () => store.set('A-1001', {
    id: 'A-1001', status: 'paid', totalCents: 2400,
    address: { name: 'Ben Okafor', postcode: 'M1 1AE' },
    items: [{ sku: 'TEE', qty: 1, weightGrams: 180 }],
  }),
  'no order A-9999': () => store.delete('A-9999'),
};

for (const interaction of contract.interactions) {
  test(`${contract.consumer} needs: ${interaction.description}`, async () => {
    store.clear();
    providerStates[interaction.providerState]();

    const res = await fetch(base + interaction.request.path, { method: interaction.request.method });

    assert.equal(res.status, interaction.response.status);
    assert.deepEqual(shapeErrors(interaction.response.body, await res.json()), []);
  });
}
JavaScript
✔ shipping needs: an existing order (12.884541ms)
✔ shipping needs: an unknown order (2.342875ms)
Text

Notice the provider’s data differs from the consumer’s example (Ben Okafor instead of Ana Silva, one T-shirt instead of two mugs). That’s deliberate. Exact-value matching would couple the two test suites’ data and break constantly. The provider also returns status, totalCents and an error message that aren’t in the contract, which is allowed. Shipping doesn’t read them.

A “harmless” cleanup that breaks a consumer

Now the orders team ships version 2 of their response as part of a tidy-up: shippingAddress.name becomes shippingAddress.fullName to match their new address model, and the 404 body is simplified from { "error": { "code": "order_not_found", "message": "..." } } to { "error": "order_not_found" }. They update their own tests to match, and those pass. Then contract verification runs in their pipeline:

✖ shipping needs: an existing order (14.426459ms)
  + [
  +   '$.shippingAddress.name: missing'
  + ]
  - []
✖ shipping needs: an unknown order (2.773792ms)
  + [
  +   '$.error: expected object, got string'
  + ]
  - []
Text

Both breaks are caught before anything is deployed, in the orders team’s own build, with messages that say exactly which consumer needs which field. Without the contract, nothing would have crashed. Pointing the real shipping client at version 2 returns recipient: undefined for an existing order and { found: false, reason: undefined } for a missing one (reading .code on a string just gives undefined). The first symptom would have been labels printed with no recipient name, discovered by a courier.

The fix is a conversation, and the contract makes it a short one. The orders team can keep name alongside fullName for a while, ask shipping to move to the new field and publish an updated contract, then remove name once verification shows no consumer depends on it. If they had instead removed totalCents, verification would have stayed green, because no consumer’s contract mentions it.

Doing this for real

The hand-written helper shows the mechanism. A production setup adds a few pieces that matter:

  • Matchers. Pact lets the consumer say “any string”, “an integer”, “a date in this format” or “an array with at least one item like this”, instead of relying only on the example’s types.
  • A broker. Contracts are published to a Pact Broker (or PactFlow) rather than shared as files. The broker records which consumer versions are deployed where and which provider versions verified them.
  • can-i-deploy. Before deploying either side, the pipeline asks the broker whether this version has a verified contract with every version currently in the target environment. That’s what turns contract tests from “nice to know” into a deployment gate.
  • Message contracts. The same idea works for events on Kafka or a queue: the consumer states which fields of OrderShipped it reads, and the publisher verifies it emits them.

A provider-driven alternative is to publish an OpenAPI schema and validate both sides against it. That catches responses that violate the schema, but not which fields specific consumers depend on, so it can’t tell the provider which optional fields are safe to remove.

Mistakes that make contract tests painful

Putting the provider’s entire response in the contract recreates the coupling contracts are meant to remove: every field becomes frozen. Matching exact values makes the provider’s verification depend on test data it doesn’t control. Using contract tests to check the provider’s business rules (“a cancelled order has a refund date”) turns them into a slow, indirect functional test suite; those rules belong in the provider’s own tests. And verifying contracts only in the consumer’s pipeline misses the point, since breaking changes come from the provider.

Interview practice

Your company has 12 services and a shared staging environment where end-to-end tests run before each release. The tests fail about a third of the time, usually because some other service is mid-deploy or has bad data. A colleague suggests replacing them with contract tests. What would you say?

Answer

I’d agree with moving most of the cross-service checking to contract tests, but not with dropping end-to-end tests entirely. Contract tests answer “do these two services agree on the shape of their API?” for each pair, and they run in each team’s own pipeline, without staging, so they don’t fail because someone else is deploying. Using a broker with can-i-deploy, they can also block a release that would break a consumer currently in production, which the staging suite can’t do reliably.

What contract tests don’t check is whether a multi-service workflow produces the right business outcome, whether configuration and networking are right in a real environment, or whether the services behave correctly together over time (retries, timeouts, eventual consistency). So I’d keep a small number of end-to-end tests for the most important journeys, such as placing and shipping an order, and invest in making staging stable enough for those few. The bulk of the “did someone break the API” coverage would move to contracts, where it’s faster and the failures point at a specific consumer, provider and field.

More in Software Testing

read ✓Software Testing · hard

Security Tests for Rejected Operations

Test what each user must not do: a table-driven access matrix for an invoice API catches a missing ownership check that happy paths miss.

~7 min readread →
read ✓Software Testing · hard

Testing Time with an Injected Clock

Pass a clock into time-dependent code, test expiry boundaries to the millisecond with node:test, and see why sleep-based tests flake.

~7 min readread →
read ✓Software Testing · mid

Code Coverage and Its Limits

See a 100% covered function ship a boundary bug, read line and branch numbers from Node's coverage report, and set thresholds that help.

~6 min readread →
esc