Skip to content
End To End Tester

Contract Testing

Proving two services still agree without deploying both — consumer-driven contracts with Pact, provider verification in CI, and where contract testing beats end-to-end.

3 min read · updated 19 September 2026

Two services, deployed independently. The consumer believes the provider returns { "total_cents": 1999 }. Last Tuesday the provider renamed it to totalCents. Nobody noticed until production, because each service's tests pass perfectly — against its own assumptions.

The obvious fix is an end-to-end test with both services deployed. It works, and it costs you an environment, a deployment coordination problem, and a test that takes minutes and fails for nine unrelated reasons.

Contract testing gets the same guarantee without deploying anything together.

#How it works

  1. The consumer writes tests against a local stub, and the stub records every interaction it was asked for. The output is a contract: a file saying "when I GET /orders/1 with this header, I expect a 200 with a body containing a numeric total_cents".
  2. The contract is published to a broker.
  3. The provider, in its own pipeline, replays every recorded interaction against its real implementation and asserts the responses match.

If the provider's change breaks a consumer's expectation, the provider's build fails — before the deploy. That inversion is the entire value: the team making the change finds out, rather than the team that gets broken.

#Consumer side

typescript
// TypeScript, Pact. The stub is Pact's; the client under test is real.
import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const { like, integer } = MatchersV3;

const provider = new PactV3({
  consumer: 'checkout-web',
  provider: 'orders-api'
});

describe('orders-api', () => {
  it('returns an order with a numeric total', async () => {
    await provider
      .given('order 1 exists')            // a provider state, by name
      .uponReceiving('a request for order 1')
      .withRequest({
        method: 'GET',
        path: '/orders/1',
        headers: { Accept: 'application/json' }
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        // Matchers, not literals: the contract is about shape and type.
        body: { id: integer(1), total_cents: integer(1999), currency: like('GBP') }
      })
      .executeTest(async (mockServer) => {
        const order = await new OrdersClient(mockServer.url).get(1);

        expect(order.totalCents).toBe(1999);
      });
  });
});

Two details do most of the work.

Matchers rather than literals. integer(1999) says "a number here", not "exactly 1999". A contract full of literal values breaks whenever the provider's test data changes, which teaches people to ignore it.

Provider states. given('order 1 exists') is a named precondition the provider agrees to set up however it likes. It is the seam that lets the two sides share expectations without sharing a database.

#Provider side

java
// Java, Pact JUnit 5. Runs in the PROVIDER's pipeline.
@Provider("orders-api")
@PactBroker(url = "${PACT_BROKER_URL}")
@VerificationReports({"console"})
class OrdersApiContractTest {

    @LocalServerPort int port;

    @BeforeEach
    void target(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }

    // One method per named state the consumers asked for.
    @State("order 1 exists")
    void orderOneExists() {
        orderRepository.save(new Order(1L, 1999, "GBP"));
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifiesEveryPublishedExpectation(PactVerificationContext context) {
        context.verifyInteraction();
    }
}

Rename total_cents now and this test fails in the provider's build, naming the consumer that depended on it.

#The piece that makes it operational

The broker's can-i-deploy check is what turns contracts from a test into a gate:

yaml
# GitHub Actions — the provider asks before it ships
- name: Can I deploy?
  run: |
    pact-broker can-i-deploy \
      --pacticipant orders-api \
      --version "$GITHUB_SHA" \
      --to-environment production

It answers: for every consumer currently in production, has this version of me been verified against their contract? A no stops the deploy. That is the guarantee an end-to-end environment was providing, obtained without one.

#Where it fits, and where it does not

Good for: independently deployed HTTP or message-based services inside one organisation, where both sides can run tests in their own pipelines. Especially good when there are many consumers of one provider, because the provider learns what is actually depended on — and can safely change everything else.

Not for: third-party APIs you do not control (they will not verify your contracts — use stubs plus schema validation instead); monoliths, where the compiler already does this; or teams with two services and one deploy pipeline, where the ceremony outweighs the benefit.

Not a substitute for: business-level journey tests. A contract proves the pieces fit. It does not prove that clicking Buy results in an order, and you still want a small number of end-to-end tests that do.

#The honest cost

Contract testing needs both teams to participate, a broker to run, and provider states to be maintained. It pays for itself somewhere around the fourth or fifth independently deployed service and is overhead below that. If your services are not independently deployable — if they all ship together in one release — you do not have the problem this solves, and component tests plus a few end-to-end journeys will serve you better.

Common questions

Does contract testing replace end-to-end testing?
It replaces most of the integration-checking that end-to-end tests are used for between services, which is usually the bulk of them. It does not replace the handful of journey tests that prove the whole system does something a user wants — nothing does.
What is a consumer-driven contract?
A machine-readable record of what one consumer actually relies on from a provider, generated by running the consumer's own tests against a stub. The provider then replays those expectations against its real implementation. The contract describes usage, not the full API surface, which is what makes it useful — a provider can change anything nobody depends on.
Do I need contract testing for a monolith?
No. Contract testing solves a deployment-coordination problem that only exists when services are released independently. In a monolith the compiler and the integration tests already give you the same guarantee, faster.

Runnable samples for this page

last test results ↗
  • Javajava/src/test/java/testinglevels/contracttesting

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?