How-to › Test and mock integrations

How to contract test an API you do not own#

Write down the fields your integration reads, check a recorded response against them, and run the same check against the live API on a schedule.

Audience
API consumer
Level
intermediate
Topic
Run contract and spec tests
Languages
TypeScript and JavaScript
Verified

A payment provider changes an amount from a number to a decimal string. Your tests pass, because they run against fixtures you recorded in March. Production breaks at the first webhook, and the first sign is a support ticket rather than a build failure. Nothing you own changed, and nothing you own noticed.

What you get

You will end up with a written contract for the fields your code reads, checked in your suite and runnable against the live API. This is for you if your integration tests only ever see fixtures.

Short answer

List the paths your code reads and the type each one must have, and check that list against a recorded response in your test suite. Run the same check against the live API in a scheduled job rather than in the build. Upstream changes to fields you never read pass, and a change to one you depend on names the field that moved.

You will need

Node 22 or later, and one recorded response from the API. A sandbox account is better than production, and the recording matters more than where it came from: it is the thing your contract is first written against.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A field-level contractAny upstream, and you can name the fields you readYou maintain the list, and a field added to your code without the contract goes uncheckedThe provider publishes a schema you can validate against directly
Consumer-driven contractsThe provider is another team who will run your contract in their buildA broker to operate, and a provider willing to take partThe upstream is a vendor who will never run your tests
Recorded fixtures aloneFast, offline unit tests of your own logicThe recording ages, and nothing tells you when the live API stopped matching itYou need to know that the upstream still behaves this way
Schema validationThe provider publishes an OpenAPI or JSON Schema document you trustTheir document can disagree with their API, so you test the document rather than the serviceNo published schema, or one that is out of date

The distinction that matters is between checking your assumptions and checking their whole API. Pact needs the provider’s cooperation, which a vendor will not give you. Schema validation needs a document that matches reality. A field-level contract needs neither, and its weakness is that it only covers what you thought to write down.

Write down what you read

The contract is a list of paths, not a copy of the response.

/** What this integration reads from the upstream invoice endpoint, and nothing else. */
export const INVOICE_CONTRACT = {
  id: 'string',
  'amount.value': 'number',
  'amount.currency': 'string',
  'customer.email': 'string',
  lines: 'array',
}

Keeping the list this short is the point of writing one at all. The recorded response has a dozen more fields, and every one you add to the contract is a field that can fail your build without breaking your code. A vendor adding auto_advance to every invoice should cost you nothing at all, and with a short contract it does.

Put the contract next to the code that reads those paths, and treat adding a read as a change to both. That is the discipline the approach depends on, and it is the one that decays: a new feature reads customer.name, nobody adds it, and the contract silently stops covering the integration.

Run it in two places

The same function runs against a fixture and against the live API, and the two runs answer different questions. Against the fixture it asks whether your parsing still matches the recording, and it runs in your build in milliseconds. Against the live API it asks whether the recording is still true, and it needs credentials, network and a tolerance for the upstream being down.

Keep the second one out of the build. A scheduled job that runs nightly and opens an issue tells you about drift within a day. A build that calls a vendor fails whenever that vendor has a bad afternoon.

Where the vendor publishes a sandbox, run the scheduled check there rather than against production. This usually takes one of two shapes. Stripe’s test mode is a parallel environment returning the same response shapes. GitHub’s REST API is a documented service you can call read-only with a low-privilege token. Either gives you a live answer without a live side effect.

Check it worked

The check has to pass on the recording and fail on a drifted one, naming what moved.

node demo.mjs
fixture.json: contract holds
drifted.json: 2 failures
  amount.value: expected number, found string
  customer.email: missing

Those two lines are the failure from the opening paragraph, caught by name. The drifted fixture also adds a field, and nothing reports it, which is the behavior a contract for one consumer should have.

node --test contract.test.mjs
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 113.137809

When it goes wrong

The contract passes and production still breaks. Types are the cheap half of a contract, and meaning is the expensive half. An amount that stays a number while changing from cents to units passes every check on this page, and a currency code that starts arriving lowercase does too. Where a value has semantics, assert on the semantics: a range, a set of allowed values, or a known identifier.

The second failure is a fixture that nobody refreshes. A recording made once and never renewed drifts from the live API, and the scheduled run is what catches that. Re-record against the sandbox on the same schedule, and treat a difference between the new recording and the old one as the thing to read rather than as noise to overwrite.

When not to do this

Do not write a contract covering every field in the response. That is schema validation with extra steps, and it fails your build every time the vendor ships anything.

Do not run the live check on every pull request. A vendor’s rate limit, sandbox outage, or slow afternoon becomes your build failure, and the signal you wanted arrives just as noisily a few hours later from a scheduled run.

Do not put credentials for the live check in the same place as the build’s. The scheduled job needs a read-only sandbox credential, and giving the build a token that can reach production is a much larger change than adding a test.

Do not treat a passing contract as permission to skip error handling. It tells you the shape was right on the last run, and says nothing about the upstream returning a 500 at the moment you call it.

Last verified

Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed, against two checked-in fixtures rather than a live vendor API.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.