Your invoice client ships in TypeScript, Python, and Go, and all three ports pass their Pact contracts. That proves the requests and responses match, but the Python port rounds a half cent down where the others round it up, because no contract calls the rounding function. A customer’s tax depends on the language that computed it.
What you get
You will end up with a decision table and a worked example that finds one bug each approach misses. The corpus finds a rounding bug, and Pact finds a renamed field. This is for you if you ship one client in several languages.
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
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.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Cucumber features shared across languages | 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 | 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 | 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, 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:
{
"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:
node corpus.mjs; python3 corpus.py released; python3 corpus.py; go run .
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() rounds a tie toward the even
choice, so 12.5 becomes 12, while the canonical client and Go’s
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
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:
node --test consumer.test.mjs && PYTHONPATH=pylib python3 consumer.py && ls pacts
# 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
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:
node verify.mjs
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:
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)
}
})
node --test decide.test.mjs
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.
node drift.mjs
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
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, and check each deploy with
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
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.
Footnotes
-
Cucumber’s installation page 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 aregocuke, which is semi-official, andgobdd, 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. ↩︎ Back to text -
The three languages in this client round a tie three different ways. Python’s
round()goes to the even choice, Go’smath.Roundgoes away from zero, and JavaScript’sMath.roundgoes toward positive infinity, soMath.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. ↩︎ Back to text -
Pact met the multi-language problem in its own code first. Its history page records a first implementation written in Ruby at realestate.com.au in 2013, seven years after Ian Robinson described consumer-driven contracts 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. ↩︎ Back to text