Contract Testing skill for Codex

Catch cross-service breakage before either side deploys. Codex sets up consumer-driven contracts that exercise real client code, keep pacts limited to fields consumers read, and verify providers with declarative state handlers. It wires publishing, branch and deployment selectors, can-i-deploy and record-deployment into CI, and adds schema diffing for public APIs where consumers are unknown.

  • Testing & QA
  • $contract-testing
  • 206 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- contract-testing

Installs to ~/.agents/skills/contract-testing/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $contract-testing, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Sets up consumer-driven contract tests with Pact (consumer tests against a mock provider, provider verification with state handlers, broker publishing, can-i-deploy and record-deployment) and schema-level breaking-change checks with oasdiff for OpenAPI, buf breaking for Protobuf and GraphQL schema diffs. Use when services owned by different teams break each other on deploy, when replacing slow end-to-end integration environments, when a PR changes an API schema, or when wiring can-i-deploy into CI.

Use it when

  • Services owned by different teams break each other on deploy
  • Replacing a slow shared integration environment
  • A PR changes an OpenAPI, Protobuf or GraphQL schema
  • Adding deployment gates based on verified contracts

Use something else when

  • You are designing the API description itself: use openapi-first
  • You need to test one service's endpoints end to end: use api-testing

How to check the result

Removing a field the consumer reads from the provider response makes provider verification fail, and a deliberate breaking schema change fails the PR check.

The mistake it prevents

Writing consumer tests that call the mock server with hand-written requests instead of the real client, so the contract documents the test rather than the application.

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

--- name: contract-testing description: Sets up consumer-driven contract tests with Pact (consumer tests against a mock provider, provider verification with state handlers, broker… license: MIT ---

Contracts are working when a provider cannot merge a change that breaks a known consumer, a consumer cannot deploy expecting something the provider does not offer, and both sides find out in their own CI within minutes, without a shared staging environment.

Inspect first

  1. Read AGENTS.md and identify the integration: HTTP JSON, gRPC, GraphQL, or messages.
  2. List who consumes the API and who owns each side. Consumer-driven contracts need the consumer team to write tests; a public API with unknown consumers needs schema diffing instead.
  3. Check for an existing Pact setup (pacts/ folder, @pact-foundation/pact, pact-python, au.com.dius.pact dependencies) and a broker URL (self-hosted Pact Broker or PactFlow). Broker calls need network and a token.
  4. Find the API description (OpenAPI, .proto, GraphQL SDL) and where it lives in git.

Choose the approach

SituationApproach
Internal services, known consumers, teams can coordinatePact consumer-driven contracts
Public or partner API, many unknown consumersSchema diffing in CI (oasdiff, buf, GraphQL diff) plus a deprecation policy
Provider already has a reliable OpenAPI spec and tests itBi-directional contracts (PactFlow) or schema diff plus consumer Pact
gRPCbuf breaking on every PR; Pact plugin only if semantic expectations matter
Async messages (Kafka, SNS)Pact message pacts, or schema registry compatibility rules

Contract tests check the shape and meaning of interactions. They do not replace provider functional tests, and they are not end-to-end tests.

Pact workflow

- [ ] 1. Consumer writes tests for each interaction it relies on
- [ ] 2. Consumer CI publishes the pact with version = git SHA and branch
- [ ] 3. Provider CI verifies pacts from main and deployed consumer versions
- [ ] 4. Provider publishes verification results
- [ ] 5. Both sides run can-i-deploy before deploying
- [ ] 6. Both sides record-deployment after deploying

1. Consumer test (JavaScript)

import path from "node:path";
import { Pact, Matchers, SpecificationVersion } from "@pact-foundation/pact";
import { OrdersClient } from "../src/orders-client";

const { like, integer, regex, eachLike } = Matchers;

const provider = new Pact({
  consumer: "web-checkout",
  provider: "orders-api",
  dir: path.resolve(process.cwd(), "pacts"),
  spec: SpecificationVersion.SPECIFICATION_VERSION_V4,
});

it("fetches an existing order", async () => {
  await provider
    .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.headers({ "Content-Type": "application/json" });
      b.jsonBody({
        id: integer(42),
        status: regex("pending|paid|shipped", "paid"),
        items: eachLike({ sku: like("LAMP-01"), quantity: integer(1) }),
      });
    })
    .executeTest(async (mockServer) => {
      const order = await new OrdersClient(mockServer.url).get(42);
      expect(order.status).toBe("paid");
    });
});

Older pact-js releases expose the same builder as PactV4 with MatchersV3; follow the version installed.

Rules for consumer tests:

  • Exercise the real client code (OrdersClient), not raw fetch calls in the test. Otherwise the pact describes what the test does, not what the app does.
  • Include only fields the consumer actually reads. Every extra field becomes a constraint the provider cannot change.
  • Use type and regex matchers, not literal values, except where the exact value matters.
  • One interaction per distinct behavior: found, not found, unauthorized, validation error.
  • Provider state names (given) are a shared vocabulary. Agree them with the provider team and keep them declarative (“order 42 exists”), not procedural.

3. 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,
  consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
  enablePending: true,
  publishVerificationResult: process.env.CI === "true",
  providerVersion: process.env.GIT_SHA,
  providerVersionBranch: process.env.GIT_BRANCH,
  stateHandlers: {
    "order 42 exists": async () => {
      await db.orders.insert({ id: 42, status: "paid", items: [{ sku: "LAMP-01", quantity: 1 }] });
    },
  },
}).verifyProvider();
  • Run verification against the provider started locally with a test database; stub the provider’s own downstream dependencies, not its handlers.
  • enablePending: true stops a brand-new consumer expectation from breaking the provider build before the provider has implemented it.
  • Publish results only from CI, never from a developer machine.

5 and 6. Gate deployments

pact-broker can-i-deploy --pacticipant web-checkout --version "$GIT_SHA" --to-environment production
# deploy only if the command exits 0, then:
pact-broker record-deployment --pacticipant web-checkout --version "$GIT_SHA" --environment production

can-i-deploy and record-deployment change what other teams’ pipelines see. Run them from CI, and ask before running record-deployment by hand.

For the full broker setup, webhooks that trigger provider verification when a pact changes, and a GitHub Actions layout for both sides, read references/pact-broker-ci.md.

Schema diffing

Run on every PR that touches an API description, comparing against the main branch.

# OpenAPI: fail on breaking changes
oasdiff breaking origin-main-openapi.yaml openapi.yaml --fail-on ERR

# Protobuf with buf
buf breaking --against '.git#branch=main'

# GraphQL (graphql-inspector)
npx @graphql-inspector/cli diff old-schema.graphql schema.graphql

Extract the main-branch file with git show origin/main:openapi.yaml > /tmp/base.yaml. Tools need installing once (network). Check oasdiff breaking --help for the flag spelling in the installed version.

Breaking for clients, in short:

ChangeBreaking?
Remove or rename a response field, endpoint, enum valueyes
Add a required request field or parameteryes
Narrow a type or tighten validation (max length, pattern)yes
Add an optional request field, new endpoint, new response fieldno
Add an enum value to a responsebreaking for clients with exhaustive switches; flag it
Change error status codes or error body formatyes

Gotchas

  • Pacts generated from tests that mock the consumer’s own client are worthless; the mock server must be hit by production client code.
  • A provider that verifies against latest pacts instead of selectors breaks on consumers’ unmerged branches. Use branch and deployment selectors.
  • Provider states that depend on test execution order fail when run alone. Each handler sets up everything it needs, and verification resets data between interactions.
  • Contracts that pin exact timestamps, ids or ordering cause false failures; use matchers.
  • Without record-deployment, can-i-deploy --to-environment has nothing to compare against and either blocks everything or passes everything.

Verification

  1. Consumer test passes and writes a pact file; inspect it and confirm it contains only fields the consumer reads.
  2. Provider verification passes locally against that file (pactUrls: ["./pacts/web-checkout-orders-api.json"]).
  3. Remove a field the consumer uses from the provider response: verification fails.
  4. For schema diffing, introduce a deliberate breaking change on a branch: CI fails.

Output

Report as:

**Contracts:** web-checkout -> orders-api (4 interactions: found, not found, 401, 422)
**Provider states:** "order 42 exists", "no orders exist"
**CI:** consumer publishes on every push; provider verifies main + deployed; can-i-deploy gates both
**Schema checks:** oasdiff breaking on PRs touching openapi.yaml
**Open items:** broker webhook to trigger provider verification on pact change (needs admin)

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Contract Testing"
  short_description: "Catch cross-service breakage before either side deploys"
  default_prompt: "$contract-testing Add Pact consumer tests for how the web app calls the orders API"
references/pact-broker-ci.md113 lines, 4.7 KB
# Pact Broker and CI layout

Read this when wiring Pact into pipelines for the first time, adding a new consumer or
provider, or debugging why can-i-deploy blocks or passes unexpectedly.

## Concepts the commands rely on

- **Pacticipant**: an application name registered in the broker (consumer or provider).
- **Version**: one build of a pacticipant. Use the git SHA, never a semver that repeats.
- **Branch**: the git branch the version was built from. Set it on publish and verify.
- **Environment**: a deployment target (`staging`, `production`) created once in the broker.
- **Deployment record**: says "version X of app Y is now in environment Z". Without it,
  can-i-deploy cannot reason about what is live.

Create environments once (admin task, ask before running):

```bash
pact-broker create-environment --name staging
pact-broker create-environment --name production --production
```

The CLI reads `PACT_BROKER_BASE_URL` and `PACT_BROKER_TOKEN` (or username and password)
from the environment. It ships as a standalone package, a Docker image
(`pactfoundation/pact-cli`) and via language wrappers; use whichever the repo already has.

## Consumer pipeline

```yaml
# .github/workflows/consumer.yml (sketch)
jobs:
  contract:
    runs-on: ubuntu-latest
    env:
      PACT_BROKER_BASE_URL: ${{ vars.PACT_BROKER_BASE_URL }}
      PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:pact                     # writes ./pacts/*.json
      - run: >
          npx pact-broker publish ./pacts
          --consumer-app-version ${{ github.sha }}
          --branch ${{ github.head_ref || github.ref_name }}

  deploy:
    needs: contract
    if: github.ref == 'refs/heads/main'
    steps:
      - run: >
          npx pact-broker can-i-deploy --pacticipant web-checkout
          --version ${{ github.sha }} --to-environment production
          --retry-while-unknown 30 --retry-interval 10
      - run: ./deploy.sh production
      - run: >
          npx pact-broker record-deployment --pacticipant web-checkout
          --version ${{ github.sha }} --environment production
```

`--retry-while-unknown` waits for a provider verification triggered by a webhook to finish
instead of failing immediately.

## Provider pipeline

Two triggers:

1. **Provider code changed** (normal push): verify pacts selected by
   `{ mainBranch: true }` and `{ deployedOrReleased: true }`, publish results with the
   provider's SHA and branch.
2. **A pact changed** (broker webhook `contract_requiring_verification_published`):
   the webhook calls the CI API with the pact URL and the provider version to check out.
   Verify only that pact (`pactUrls: [process.env.PACT_URL]`) and publish the result.

Then gate deploys the same way as the consumer:

```bash
pact-broker can-i-deploy --pacticipant orders-api --version "$GIT_SHA" --to-environment production
```

## Selectors cheat sheet

| Selector | Verifies |
|---|---|
| `{ mainBranch: true }` | latest pact from each consumer's main branch |
| `{ deployedOrReleased: true }` | pacts from consumer versions currently in any environment |
| `{ matchingBranch: true }` | consumer branch with the same name as the provider branch (coordinated feature work) |
| `{ branch: "feat-x", consumer: "web-checkout" }` | one specific branch, for debugging |

Pending and WIP pacts:

- `enablePending: true`: failures from pacts the provider has never successfully verified
  are reported but do not fail the build.
- `includeWipPactsSince: "<date>"`: also verifies new consumer branches' pacts as pending,
  so providers see upcoming expectations early.

## Why can-i-deploy says no

| Message pattern | Cause | Fix |
|---|---|---|
| no verification results for the pact | provider never verified this consumer version | trigger provider verification (webhook or manual run) |
| verification failed | provider breaks the expectation | fix provider or negotiate the contract change |
| no version with that number | publish step skipped or used a different version string | publish with the same SHA used in can-i-deploy |
| unknown environment | environment not created or name mismatch | create environment; check spelling |
| passes but production broke | deployments were never recorded | add record-deployment after every deploy, including rollbacks |

## Coordinated breaking change

1. Provider adds the new field or endpoint alongside the old one; deploys.
2. Consumer switches to the new field; its pact drops the old one; deploys.
3. Broker shows no deployed consumer version needs the old field.
4. Provider removes the old field; can-i-deploy confirms; deploys.

Never remove a field in one step because "the consumer team said they stopped using it";
the broker's deployed versions are the evidence.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc