# How to choose between a shared test corpus and Pact contracts

> Test each port's own logic with a shared corpus and its HTTP calls with Pact, and know which bug each one cannot see.

Source: https://voxgig.com/howto/choose-between-a-shared-corpus-and-pact-contracts

- Audience: library-maintainer
- Level: beginner
- Languages: typescript, javascript, python, go
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Test a multi-language client's own logic with a shared JSON corpus and its HTTP calls with Pact, so each kind of bug meets a test that can see it. A corpus is one file of inputs and outputs that every port runs, so it proves the ports agree. A Pact contract records what a client sends and reads, and the provider replays it. A stored server response in a corpus is a fixture, not a contract.

---
## You will need

Node 22 or later, Python 3.11 or later, and Go 1.24 or later, plus a client with ports in at least
two languages. Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, Go 1.24.7,
`@pact-foundation/pact` 17.1.4, and `pact-python` 4.1.0. In the sample directory, `npm ci`
installs pact-js and puts pact-python into `pylib/`.

The sample is an invoices client with one pure function, `taxCents`, and one HTTP call,
`getInvoice`, in a canonical JavaScript version, a Python port, and a Go port. The Go port runs
the corpus but not Pact, for the reason the Pact step gives. Pact's native core
[reports each verification to Google Analytics](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_matching/src/metrics.rs)
unless `PACT_DO_NOT_TRACK` is exactly `true`, so every Pact script sets it, and `package.json`
stops pact-js reporting each install to [Scarf](https://github.com/scarf-sh/scarf-js).

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Cucumber features shared across languages](https://cucumber.io/docs/installation/) | People outside engineering read the scenarios, and every port's language has a maintained implementation | Step definitions written once per language, and support that varies by language | Only engineers read the tests, or one port has no maintained runner |
| [Pact consumer-driven contracts with a Pact Broker](https://docs.pact.io/) | The client calls a provider whose team will replay your contracts in their build | A consumer test per port, provider verification, and a broker you deploy, administer, and host | The behavior under test never leaves the library, or the provider is a public API |
| [Shared JSON corpus run in every port](https://github.com/json-schema-org/JSON-Schema-Test-Suite) | Pure functions whose output every port must reproduce exactly | A runner per language, and no evidence that the real server still answers as a stored response says | The behavior lives on the server rather than in the library |

Pact checks the
[contents and format of requests and responses](https://docs.pact.io/getting_started/what_is_pact_good_for),
in its documentation's words, so a pure function that differs between ports is outside its reach.
A corpus proves the ports agree, but it cannot see the server. Cucumber adds scenarios that people
outside engineering can read, at the price of step definitions in every language and
implementations of uneven standing.[^1] Most multi-language clients need the first two together: a
corpus for logic, and Pact for the wire.

## Put the pure function in a shared corpus

A corpus case is an input and the output every port must produce, written once in JSON. The cases
worth writing are the ones where ports disagree, and money rounding is a reliable source of them:

```json title="corpus.json"
{
  "taxCents": [
    { "id": "whole-cent", "doc": "Ten percent of ten euros is exactly one euro.", "in": [1000, 1000], "out": 100 },
    { "id": "below-half", "doc": "12.4 cents rounds down.", "in": [124, 1000], "out": 12 },
    { "id": "half-cent-rounds-up", "doc": "12.5 cents rounds away from zero, as the canonical client does.", "in": [125, 1000], "out": 13 },
    { "id": "credit-half-cent", "doc": "A credit note's -12.5 cents also rounds away from zero.", "in": [-125, 1000], "out": -13 },
    { "id": "zero-rate", "doc": "No rate, no tax.", "in": [999, 0], "out": 0 }
  ],
```

Run the corpus against every port, including the Python port as it was first released:

```bash
node corpus.mjs; python3 corpus.py released; python3 corpus.py; go run .
```

```text output
node    invoices.mjs  6 of 6 cases pass
python  released.py   4 of 6 cases pass
  half-cent-rounds-up: expected 13, got 12
  credit-half-cent: expected -13, got -12
python  invoices.py   6 of 6 cases pass
go      invoices.go   6 of 6 cases pass
```

The released Python port fails both half-cent cases. Python's
[`round()`](https://docs.python.org/3/builtins/functions.html#round) rounds a tie toward the even
choice, so 12.5 becomes 12, while the canonical client and Go's
[`math.Round`](https://pkg.go.dev/math#Round) round away from zero.[^2] The fixed `invoices.py`
uses `Decimal` with `ROUND_HALF_UP` and passes. No HTTP call happens anywhere in that run, which is
why no contract could have reported the bug.

## Put the HTTP call in a Pact contract

A Pact consumer test runs the client against a mock provider and records the request it sent and
the fields it read. Each port writes its own, in its own Pact library:[^3]

```js title="consumer.test.mjs"
test('getInvoice reads id, total_cents and currency from GET /invoices/42', async () => {
  const pact = new PactV4({ consumer: 'invoices-client-js', provider: 'invoices-api', dir, logLevel: 'error' })
  await pact
    .addInteraction()
    .given('invoice 42 exists')
    .uponReceiving('a request for invoice 42')
    .withRequest('GET', '/invoices/42', (req) => req.headers({ accept: 'application/json' }))
    .willRespondWith(200, (res) =>
      res.headers({ 'content-type': 'application/json' })
        .jsonBody({ id: integer(42), total_cents: integer(1250), currency: string('EUR') }))
    .executeTest(async (mock) => {
      assert.deepEqual(await getInvoice(mock.url, 42), { id: 42, totalCents: 1250, currency: 'EUR' })
    })
})
```

`consumer.py` declares the same interaction in pact-python and drives it through `released.py`,
rounding bug included. Run both consumers:

```bash
node --test consumer.test.mjs && PYTHONPATH=pylib python3 consumer.py && ls pacts
```

```text output
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1047.577199
pact written for invoices-client-py, from released.py
invoices-client-js-invoices-api.json
invoices-client-py-invoices-api.json
```

Each client wrote one pact file. A Go port needs a third from pact-go, which
[relies on C shared libraries compiled from Rust](https://docs.pact.io/implementation_guides/go/readme)
and on `cgo`, installed by default into `/usr/local/lib` with `sudo`. That makes a native library
a dependency of every machine that runs the Go port's contract test.

The provider replays each pact against its own code. `verify.mjs` does it for the release as
deployed and for a release that renames `total_cents`:

```bash
node verify.mjs
```

```text output
summary
  current  invoices-client-js-invoices-api.json verified
  current  invoices-client-py-invoices-api.json verified
  renamed  invoices-client-js-invoices-api.json FAILED: Actual map is missing the following keys: total_cents
  renamed  invoices-client-py-invoices-api.json FAILED: Actual map is missing the following keys: total_cents
```

Both pacts verify against the deployed release, including the one the released Python port wrote
while it rounded wrong. Both fail against the rename, and the failure names the missing field. The
verifier prints its own report first, and the summary below it is the part to read.

## Check it worked

The decision rests on one bug each approach cannot see, so `decide.test.mjs` asserts both. The
corpus fails the released Python port while its pact verifies, and Pact fails the rename while the
corpus fixture still passes:

```js title="decide.test.mjs"
test('the pact written by the released Python port verifies, rounding bug and all', () => {
  assert.match(verified, /current\s+invoices-client-py-invoices-api\.json\s+verified/)
})

test('Pact catches the renamed field in both clients\' contracts', () => {
  for (const client of ['js', 'py']) {
    const line = new RegExp(`renamed\\s+invoices-client-${client}-invoices-api\\.json\\s+FAILED: .*missing the following keys: total_cents`)
    assert.match(verified, line)
  }
})
```

```bash
node --test decide.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1620.206465
```

Read `# pass 6` and `# fail 0`. The last two tests check that `verify.mjs` exits 1 when the
deployed release breaks a pact, and that no verification tries to reach Google Analytics. A passing
pact beside a failing corpus run is the whole argument for keeping both.

## When it goes wrong

The corpus passes after the provider changed its response. A stored response in the corpus is a
fixture: it checks the client against a copy of what the provider once sent. Delete such cases, or
generate them from the pact files the provider has verified, so they describe only responses the
provider still sends.

```bash
node drift.mjs
```

```text output
corpus case stored-response:  total 1250
renamed release, live body:   {"id":42,"currency":"EUR","amount_cents":1250}
invoiceTotal on that body:    invoices: response has no integer total_cents
```

Verification answers 502 with `upstream request failed`. pact-js
[sends verification requests through `HTTPS_PROXY` or `HTTP_PROXY`](https://github.com/pact-foundation/pact-js/blob/master/src/dsl/verifier/proxy/proxyRequest.ts)
whenever either is set, without reading `NO_PROXY`, so a provider on loopback is unreachable behind
a corporate proxy. Clear both variables for the verification process, as `verify.mjs` does.

Consumer tests pass and the provider never fails a build. The pacts were written but never
published, so no provider build replays them. Publish them to a
[Pact Broker](https://docs.pact.io/pact_broker), and check each deploy with
[`can-i-deploy`](https://docs.pact.io/pact_broker/can_i_deploy) before it goes out.

## When not to do this

Do not adopt Pact to keep ports at parity. A contract checks what crosses the wire, so a pure
function that differs between ports passes every pact, as the released Python port did here.

Do not store server responses in the corpus and call them a contract. They keep passing after the
provider has moved on, and the client learns about the change from its users.

Do not add Pact for a provider that will never replay your pacts, such as a public API. Pact's own
guide lists APIs whose consumers cannot be individually identified among the things it is not good
for. A contract that no provider build runs is a fixture with a file format.

Do not reach for Cucumber unless people outside engineering read the scenarios. Otherwise its step
definitions are a second implementation of every case, written once per language.

## Related how-tos

- [Design the case shape of a language-neutral test corpus](/howto/design-the-case-shape-of-a-test-corpus)

- [Choose consumer-driven or spec-driven contract tests](/howto/choose-consumer-driven-or-spec-driven-contract-tests)

- [Contract test an API you do not own](/howto/contract-test-an-api-you-do-not-own)

- [Publish and tag pacts in a Pact Broker](/howto/publish-and-tag-pacts-in-a-broker)

- [Contract test messages between services](/howto/contract-test-messages-between-services)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, Go 1.24.7, `@pact-foundation/pact`
17.1.4, and `pact-python` 4.1.0. Every output block is what the command preceding it printed. The
provider ran locally, with pact files read from disk rather than from a Pact Broker. pact-go and
Cucumber were read from their documentation, not run.

[^1]: [Cucumber's installation page](https://cucumber.io/docs/installation/) sorts its
implementations into official, semi-official, unofficial, and unmaintained, the last defined as
official implementations in need of new maintainers. The official Go implementation, `godog`, is
filed under unmaintained, so the maintained Go entries are `gocuke`, which is semi-official, and
`gobdd`, which is unofficial. Behave and pytest-bdd, two of the three Python entries, are
semi-official: hosted elsewhere, but built on components from the Cucumber project.

[^2]: The three languages in this client round a tie three different ways. Python's `round()` goes
to the even choice, Go's `math.Round` goes away from zero, and JavaScript's
[`Math.round`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round)
goes toward positive infinity, so `Math.round(-12.5)` is -12. The canonical client rounds the
absolute value for that reason. MDN observes that many languages round half-increments away from
zero instead, which describes Go here and neither of the other two.

[^3]: Pact met the multi-language problem in its own code first. Its
[history page](https://docs.pact.io/history) records a first implementation written in Ruby at
realestate.com.au in 2013, seven years after Ian Robinson described
[consumer-driven contracts](https://martinfowler.com/articles/consumerDrivenContracts.html) as a
service evolution pattern. When JavaScript needed Pact, the team wrapped the Ruby implementation
rather than write it again, to avoid the maintenance burden and the potential for implementation
mismatches. The wrapper shipped a whole Ruby runtime, about nine megabytes of it. The project then
chose a reference implementation in Rust, wrapped by each language through FFI, and both pact
files on this page record its version under `pactRust`.