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
- 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". - The contract is published to a broker.
- 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, 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, 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:
# 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 productionIt 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 ↗- Java
java/src/test/java/testinglevels/contracttesting
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Integration Testing with StubsTesting against a dependency you control — WireMock, MSW and in-process servers — so error paths, timeouts and rate limits become testable instead of theoretical.
- Component TestingTesting one deployable in isolation through its real interface, with its own dependencies containerised and everything beyond its boundary stubbed.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- API TestingTesting HTTP and message interfaces directly — the level most teams under-invest in relative to its value, and how it replaces most slow end-to-end tests.
- Testing in GitHub ActionsA complete pipeline — unit tests, integration with containers, sharded Playwright, coverage and artefacts — plus the caching and concurrency settings that make it fast.
- Integration TestingTesting your code against real dependencies — databases, HTTP clients, message brokers — with Testcontainers, and what belongs at this level rather than above or below it.
- Unit Testing with MocksThe solitarist approach — isolate the unit behind test doubles. What it buys, the coupling it creates, and the three rules that keep a mock-heavy suite maintainable.
- Mocking FrameworksMoq, NSubstitute, Mockito, unittest.mock, Sinon and the rest — what each ecosystem's mocking library does well, and the failure modes they share.