Testing No. 127

Contract Testing skill for Claude Code

Catch breaking API changes before either side deploys. Claude picks between consumer-driven Pact contracts and provider-owned OpenAPI checks based on who the consumers are, then wires the full loop: consumer tests with type matchers, provider verification with named states, a broker, can-i-deploy before every deploy and record-deployment after. Breaking API changes fail a pipeline instead of a production release.

  • namecontract-testing
  • size182 lines · ≈1,850 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Sets up contract tests between services so API changes that break a consumer fail in CI before deploy. Covers consumer-driven contracts with Pact (consumer tests, provider verification, provider states, broker, can-i-deploy) and schema-based contracts with OpenAPI (response validation, breaking-change diffs with oasdiff, Schemathesis). Use when two services or a frontend and API deploy independently, when a provider change broke a consumer in production, when replacing a shared staging environment as the integration gate, or when choosing between Pact and OpenAPI validation.

Try asking

  • Add Pact contract tests between our React app and the orders API
  • A backend rename broke the mobile app last week. How do we stop that in CI?
  • Fail our PRs when someone makes a breaking change to openapi.yaml

Use it when

  • Services or a frontend and API deploy independently
  • A provider change broke a consumer in production
  • Replacing a shared staging environment as the integration gate
  • Deciding between Pact and OpenAPI-based validation

Skip it when

  • Both sides ship in one deploy unit: integration tests are enough
  • You are designing the API resources and errors themselves: use rest-api-design

How to check the result

Renaming a field a consumer reads makes provider verification and can-i-deploy fail, while adding an unused field passes everywhere.

The common mistake it prevents

Skipping record-deployment, so the broker never knows what is in production and the deploy gate silently checks nothing.

~/.claude/skills/contract-testing/SKILL.md

--- name: contract-testing description: Sets up contract tests between services so API changes that break a consumer fail in CI before deploy. Covers consumer-driven contracts… license: MIT ---

A contract test proves that what a consumer expects and what a provider delivers still agree, without deploying both together. Done well, a provider cannot ship a change that breaks a deployed consumer, and each side tests in isolation in seconds.

Pick the approach

SituationApproach
You own both sides; several internal consumers with different needsConsumer-driven contracts (Pact)
Public or partner API with unknown consumersProvider-owned OpenAPI spec + breaking-change diff + spec-conformance tests
Provider already has an accurate OpenAPI spec, consumers use PactBi-directional (PactFlow) or validate consumer pacts against the spec
Async messages (Kafka, SQS events)Pact message contracts, or a schema registry with compatibility rules
One team, one deploy unitNot needed; use integration tests

Contracts test the shape and semantics of the interface, not business logic. Keep provider business rules in the provider’s own tests.

Workflow (Pact)

- [ ] 1. Identify consumer/provider pairs and who deploys independently
- [ ] 2. Write consumer tests against the Pact mock server
- [ ] 3. Publish pacts to a broker tagged with version and branch
- [ ] 4. Provider verifies pacts with state handlers
- [ ] 5. Gate deploys with can-i-deploy; record deployments
- [ ] 6. Add webhook so pact changes trigger provider verification

2. Consumer test (pact-js, V4 API)

import path from 'node:path';
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
import { OrdersClient } from '../src/orders-client';

const { integer, string, eachLike, datetime } = MatchersV3;
const pact = new PactV4({ consumer: 'web-app', provider: 'orders-api', dir: path.resolve('pacts') });

describe('OrdersClient', () => {
  it('fetches an existing order', async () => {
    await pact
      .addInteraction()
      .given('order 42 exists')
      .uponReceiving('a request for order 42')
      .withRequest('GET', '/orders/42', (b) => b.headers({ Accept: 'application/json' }))
      .willRespondWith(200, (b) =>
        b.jsonBody({
          id: integer(42),
          status: string('paid'),
          createdAt: datetime("yyyy-MM-dd'T'HH:mm:ssX", '2026-01-05T10:00:00Z'),
          items: eachLike({ sku: string('A-1'), qty: integer(1) }),
        }))
      .executeTest(async (mock) => {
        const order = await new OrdersClient(mock.url).getOrder(42);
        expect(order.status).toBe('paid');
      });
  });
});

Consumer rules:

  • Exercise the real client code, not raw fetch, so the pact reflects actual usage.
  • Use type matchers (integer, string, eachLike) rather than exact values, so the provider can return any valid data.
  • Include only fields the consumer actually reads. Extra provider fields are fine; a pact that lists every field blocks harmless provider changes.
  • Cover the error responses the consumer handles (404, 422), each with its own state.
  • Provider state names are a shared vocabulary: order 42 exists, not setup1.

Other languages have equivalent DSLs (pact-jvm, pact-python, pact-go, pact-net); follow the same structure.

3. Publish

pact-broker publish ./pacts \
  --consumer-app-version "$GIT_SHA" \
  --branch "$GIT_BRANCH" \
  --broker-base-url "$PACT_BROKER_BASE_URL" \
  --broker-token "$PACT_BROKER_TOKEN"

The CLI ships in the pactfoundation/pact-cli Docker image and the @pact-foundation/pact-cli npm package. Always version by commit SHA, never by a semver that repeats across commits.

4. Provider verification

import { Verifier } from '@pact-foundation/pact';

await new Verifier({
  provider: 'orders-api',
  providerBaseUrl: 'http://localhost:8080',
  pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  providerVersion: process.env.GIT_SHA,
  providerVersionBranch: process.env.GIT_BRANCH,
  publishVerificationResult: process.env.CI === 'true',
  consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }, { matchingBranch: true }],
  enablePending: true,
  stateHandlers: {
    'order 42 exists': async () => { await db.orders.insert(orderFixture({ id: 42, status: 'paid' })); },
  },
}).verifyProvider();
  • Run the real provider app with real routing and serialization; stub only its own downstream dependencies.
  • State handlers set up data directly (DB insert or repository fake), not via other APIs.
  • enablePending: true lets new consumer expectations fail without breaking the provider build until the provider has passed them once.
  • Only publish verification results from CI.

5. Deploy gate

pact-broker can-i-deploy --pacticipant web-app --version "$GIT_SHA" \
  --to-environment production --retry-while-unknown 6 --retry-interval 10
# after a successful deploy:
pact-broker record-deployment --pacticipant web-app --version "$GIT_SHA" --environment production

Both consumer and provider pipelines run can-i-deploy before deploying and record-deployment after. Without record-deployment, the broker cannot know what is in production and the deployedOrReleased selector verifies nothing.

Broker setup, webhooks, and the full CI flow are in references/pact-broker-flow.md; read it when wiring pipelines for the first time.

Workflow (OpenAPI schema contracts)

- [ ] 1. Make the spec the source of truth (generated from code or code generated from spec)
- [ ] 2. Lint the spec (Spectral or Redocly)
- [ ] 3. Validate real responses against the spec in provider tests
- [ ] 4. Fail PRs on breaking spec changes with oasdiff
- [ ] 5. Fuzz the running API against the spec with Schemathesis
# breaking-change gate in CI (base spec from main, head spec from the PR)
oasdiff breaking main-openapi.yaml openapi.yaml --fail-on ERR

# property-based conformance against a running instance
schemathesis run http://localhost:8080/openapi.json

In provider tests, assert each response conforms to the spec, e.g. with jest-openapi (expect(res).toSatisfyApiSpec()) in JS or openapi-core in Python. A spec that is not checked against real responses drifts within weeks.

Gotchas

  • Contracts written by the provider team “for” consumers are not consumer-driven; they test what the provider thinks is used.
  • Exact-value matching in pacts creates failures on any data change. Use matchers.
  • Sharing one provider state that sets up everything makes states order-dependent and slow.
  • Pact verifies HTTP shape, not auth flows; stub auth in verification with a known token or a request filter, and test auth separately.
  • Breaking changes that are schema-compatible still break: changing the meaning of a field, new enum values the consumer does not handle. Treat new enum values as breaking unless consumers are documented to ignore unknowns.
  • can-i-deploy without --to-environment (or with stale tags) checks the wrong matrix.

Verification

  1. Introduce a deliberate break in the provider (rename a field a consumer reads) and confirm provider verification fails and can-i-deploy returns non-zero.
  2. Add an unused field in the provider and confirm nothing fails.
  3. For OpenAPI, delete a required response property in the spec on a branch and confirm oasdiff breaking fails the PR.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/pact-broker-flow.md 68 lines · 3.2 KB
# Pact broker and CI flow

Read this when wiring Pact into pipelines for the first time or debugging why
`can-i-deploy` says no.

## Broker options

- Self-hosted Pact Broker: Docker image `pactfoundation/pact-broker` with Postgres.
  Protect it with basic auth or put it behind SSO; it holds API shapes, not secrets, but
  still should not be public.
- Hosted: PactFlow (adds bi-directional contracts and a UI for teams).

## The full loop

```
Consumer PR                           Provider PR
-----------                           -----------
unit + pact tests                     unit tests
publish pacts (sha, branch)           verify pacts:
can-i-deploy --to-environment           selectors mainBranch, deployedOrReleased,
  (waits for provider verification)     matchingBranch; publish results (CI only)
deploy                                can-i-deploy --to-environment
record-deployment                     deploy
                                      record-deployment
```

Webhook: configure a broker webhook on the `contract_requiring_verification_published`
event that triggers the provider's verification job with the pact URL and the provider
version/branch to verify. This lets a consumer PR get its verification result without
waiting for an unrelated provider build.

## Consumer version selectors, explained

| Selector | Verifies |
|---|---|
| `{ mainBranch: true }` | Latest pact from each consumer's main branch |
| `{ deployedOrReleased: true }` | Pacts for consumer versions currently recorded in any environment |
| `{ matchingBranch: true }` | Consumer pact from a branch with the same name as the provider branch (coordinated feature work) |

Use all three on provider builds. Add `includeWipPactsSince: '<date>'` with
`enablePending: true` to verify work-in-progress consumer pacts without failing the build.

## Reading a can-i-deploy failure

The output is a matrix of consumer version x provider version with a verification
result. Common causes:

| Output says | Meaning | Fix |
|---|---|---|
| No verification result | Provider has not verified this pact yet | Trigger provider verification (webhook) or use `--retry-while-unknown` |
| Verification failed | Provider breaks the contract | Fix provider, or negotiate a consumer change |
| No version in environment | `record-deployment` was never called for the counterpart | Add `record-deployment` to the counterpart's pipeline and backfill the current prod version |
| Unknown pacticipant/version | Wrong name or version string | Use the exact pacticipant name and the commit SHA |

## Message (async) contracts

For event payloads, consumers write message pacts describing the event they can process;
the provider verification produces the message from real code (a function returning the
payload) instead of an HTTP call. Use the same broker, selectors, and deploy gate.

## Rolling out to an existing system

1. Start with one high-traffic consumer/provider pair and one interaction.
2. Run provider verification as non-blocking for a week; fix noise (exact matchers,
   missing states).
3. Make `can-i-deploy` blocking for that pair, then expand pair by pair.
4. Backfill `record-deployment` for versions already in each environment before turning
   on the gate, or the first deploy will be blocked.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc