DEV Community

137Foundry
137Foundry

Posted on

How to Set Up Contract Testing Between Two Services

Integration tests that spin up both services and hit a real endpoint catch real bugs, but they're slow, flaky in CI, and tell you nothing about a change until both services are deployed to the same environment. Consumer-driven contract testing solves a narrower but much more useful problem: does the provider still satisfy exactly what the consumer said it needs, checked independently on each side, without either service running the other's code.

Step 0: Pick a relationship worth the setup cost

Contract testing has a real setup cost, both teams need to agree on tooling and a broker, and it pays off fastest on a specific consumer and provider pair, not applied uniformly across every integration a company has on day one. The best starting candidate is a relationship that's already broken more than once, where both sides have felt the pain of a surprise change directly, because that shared history makes the setup conversation much shorter.

Avoid starting with the most complex integration in the system just because it feels like the highest-value target. A simpler relationship with a clean, well-understood API surface gets both teams comfortable with the workflow, the broker, and the CI wiring before tackling something with a dozen endpoints and years of accumulated edge cases.

Step 1: Write the consumer expectation first

The consumer team writes a test that describes, in code, the exact request it will send and the exact response shape it expects back. This isn't a mock for unit testing convenience, it's a formal expectation that gets exported as a contract file and shared with the provider. The consumer never talks to the real provider during this step; it talks to a mock server that simply confirms the consumer's own client code produces the request it claims to.

provider
  .given('an order with id 42 exists')
  .uponReceiving('a request for order 42')
  .withRequest({ method: 'GET', path: '/orders/42' })
  .willRespondWith({
    status: 200,
    body: { id: 42, status: 'shipped', total_cents: 4999 }
  });
Enter fullscreen mode Exit fullscreen mode

That expectation, once verified against the mock, gets published as a contract artifact, typically to a shared broker both teams can reach. The provider team doesn't write this step. They receive it.

Step 2: Verify the provider actually satisfies it

The provider team pulls the published contract and replays it against their real, running service, not a mock of their own. Pact handles this replay directly: it takes the consumer's recorded expectations and fires them at the actual provider code in CI, then reports whether the real response matches what the consumer said it needs.

const { Verifier } = require('@pact-foundation/pact');

new Verifier({
  providerBaseUrl: 'http://localhost:8080',
  pactUrls: ['./pacts/consumer-provider.json'],
}).verifyProvider();
Enter fullscreen mode Exit fullscreen mode

If a schema change on the provider side, a renamed field or a changed type, no longer satisfies the consumer's recorded expectation, this step fails the provider's build. That's the entire point: the break gets caught in the provider's own CI pipeline, before the change reaches a shared environment, not after a downstream service starts erroring in production.

Step 3: Wire the broker into both pipelines

A contract broker sits between the two teams and does two things: it stores the published consumer expectations, and it tracks which provider versions have successfully verified which consumer contracts. This "can I deploy" matrix is what turns contract testing from a nice practice into an actual deployment gate.

Before the provider deploys a new version, it checks the broker: has this version been verified against every active consumer contract. Before a consumer deploys a new version, same check in reverse. Neither side needs a shared staging environment or synchronized deploy schedule to get this guarantee, which is the main advantage over full end-to-end integration testing across services.

This matters most in organizations where the consumer and provider deploy on genuinely independent schedules, which is most organizations past a handful of engineers. Without the broker's deployment gate, a provider has no reliable way to know whether it's safe to ship, short of manually asking every consuming team, which is exactly the kind of manual coordination step that gets skipped under a deadline.

Step 4: Treat contract changes like schema changes, because they are

Whenever a consumer's expectation changes, that change goes through the same review as any other schema-affecting pull request. A consumer adding a new expected field, or loosening a strict type check, is a real change to what the contract requires, and it deserves the same scrutiny as the provider changing what it sends. Treating contract files as generated artifacts nobody reviews defeats the purpose.

This is also the point where a shared vocabulary for versioning matters. Semantic versioning applied to the contract itself, separate from either service's own release version, gives both teams a shared answer to "did this just become a breaking change" without needing a meeting to agree on it case by case.

In practice this means adding the contract file to the same code review process as everything else, with a required reviewer from the other team on any change that alters an existing expectation rather than adding a new one. Skipping this review because "it's just a test file" is the fastest way to let contract testing quietly stop meaning anything, because a consumer can loosen its own expectations without anyone on the provider side noticing the safety net just got thinner.

Step 5: Extend the pattern past pure request and response

The same core idea, publish an expectation and verify it independently, extends to async boundaries too, even though the tooling looks different. For event-driven integrations, AsyncAPI gives you a comparable way to document channel and message shapes so a consumer reading from a queue can validate incoming events the same way a Pact verification validates an HTTP response.

The mechanics differ between synchronous and asynchronous integration points, but the underlying discipline doesn't: a formal, versioned, machine-checkable expectation that gets verified automatically, rather than an assumption that lives in a design doc nobody re-reads after the first sprint.

Where this fits into a broader contract strategy

Contract testing answers "does the current code satisfy the current expectation," which is necessary but not sufficient on its own. It still needs to sit inside a larger practice: versioning the schema deliberately, giving breaking changes a real deprecation window, and making sure the review catches renamed or removed fields before they ship. 137Foundry's breakdown of designing a data contract that survives API changes covers that larger structure in more depth, including how to handle the deprecation window once a breaking change is unavoidable.

If you're integrating two services today with nothing but a shared understanding of "the API probably still works the same way," contract testing is the smallest change that turns that assumption into something CI actually checks on every commit, on both sides, independently. 137Foundry builds this kind of integration safety net directly into client pipelines when a project has more than one team shipping against the same API.

Expect the first setup to take longer than the tenth. The first time a team wires up a broker, agrees on a naming convention for contracts, and gets both pipelines checking against it, most of the time goes into decisions that only need to be made once. Every additional consumer or provider added to an existing broker after that first setup is a much smaller lift, because the conventions and the plumbing are already in place.

Top comments (0)