The billing service renames its paid-invoice topic from invoice.paid to invoices.paid in a
tidy-up release, and the message contract stays green because it compares the body alone. The
ledger is subscribed to the old name, so it records nothing from that evening on. The first sign
is a finance report that stops matching the bank.
What you get
You will end up with a message contract pinning the fields the subscriber reads and the topic it listens on, verified against the publisher, and the same check made two other ways. This is for you if your services talk through a broker.
Short answer
Write the subscriber’s expectation as a Pact message contract with MessageConsumerPact: the
fields it reads under type matching rules, and the topic and content type as metadata. Verify the
publisher with MessageProviderPact and providerWithMetadata, so a renamed topic fails the
build as surely as a renamed field. A schema runner over an AsyncAPI document checks the
payload alone, and seneca-msg-test asserts the pattern on every Seneca message, inside that
toolkit.
You will need
A publisher and a subscriber you can run in CI, the event they share written down once, and
Node 22.18 or later, which runs the TypeScript sample without a build step. Verified 2026-10-21
against Node 22.22.0, @pact-foundation/pact 17.1.4, ajv 8.20.0, seneca 3.38.0,
seneca-entity 28.1.0, seneca-msg-test 4.1.0, seneca-promisify 3.7.2, and yaml 2.9.1.
The broker is an in-memory bus with topics and headers. Microcks was not run; its runner is
described from its documentation. Which style of contract to adopt at all is the question of
choosing consumer-driven or spec-driven contract tests.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Microcks ASYNC_API_SCHEMA runner | An AsyncAPI document already describes the channel, and you want messages on a live broker checked against it | A Microcks deployment and a broker it can consume from, for a check of the payload alone against the endpoint you name | Nobody keeps the AsyncAPI document current, or the subscriber reads less than the document promises |
| Pact message pacts | The subscriber team states what it reads, body and metadata, and the publisher verifies it in its own build | A pact file per consumer to publish and verify, matching rules to keep loose, and a broker or a repository to exchange them | One document must serve subscribers you cannot reach, or the message format has no Pact library |
| seneca-msg-test | Both services are Seneca plugins, and the pattern is the routing key | A spec per plugin inside one toolkit, with nothing to show a consumer written in another framework or language | The publisher or the subscriber lives outside Seneca, or the message crosses a broker Seneca does not route |
Voxgig maintains Seneca and seneca-msg-test. This page compares them with Pact message pacts and the Microcks ASYNC_API_SCHEMA runner.
Pact puts the subscriber’s reading of the event in a file the publisher’s build verifies, at the cost of a pact to publish and matching rules to keep loose. A schema runner needs no consumer at all, and in exchange checks payloads against a document that is only as current as the last person who edited it. seneca-msg-test asserts the pattern on every call, which the other two have to be told to do, and it reaches only the messages Seneca routes.
Write the subscriber’s expectation, metadata included
The ledger reads three fields from an invoice.paid event and listens on one topic. Its contract
says exactly that and no more. like matching rules pin the type of each field it reads, and
withMetadata carries the topic and the content type, the two things about a message that are
not its body.
test('the ledger records what it reads from an invoice.paid event, body and topic together', async () => {
const pact = new MessageConsumerPact({ consumer: 'ledger', provider: 'billing', dir: pactsDir, logLevel: 'error' })
await pact
.expectsToReceive('an invoice.paid event')
.withContent({ id: like('in_1002'), amount: like(450), currency: like('EUR') })
.withMetadata({ contentType: 'application/json', topic: 'invoice.paid' })
.verify(async (message) => {
// The real subscriber code handles the example message, so the contract is what the
// ledger can consume, not what someone believes it consumes.
const entry = recordPayment(message.contents)
assert.equal(entry.invoiceId, 'in_1002')
})
The handler passed to verify is the ledger’s own recordPayment, so a contract the ledger
cannot consume is never written. The file that comes out, pacts/ledger-billing.json, is a
version 3 pact with a messages array instead of HTTP interactions, as the
pact-js message documentation
describes,1 and each message carries its metadata beside its contents. The customer and
paidAt fields the event also carries are absent from the contract, so billing can rename or
drop them without a red build on the ledger’s side.
Verify the publisher with the metadata on
The billing pipeline verifies every pact it has been given against its real producer function.
providerWithMetadata wraps that function with the topic and content type the service would
send, and the verifier compares both against the pact.
const billing = createBilling({ topic })
const produce = () => billing.event(invoice).body
const provider = flag === '--no-metadata'
? produce
: providerWithMetadata(produce, { contentType: 'application/json', topic: billing.topic })
// The native core reports every verification to Google Analytics unless this is exactly "true";
// "1" and "TRUE" do not count. Set it in the CI job's environment as well.
process.env.PACT_DO_NOT_TRACK = 'true'
const verifier = new MessageProviderPact({
messageProviders: { 'an invoice.paid event': provider },
provider: 'billing',
providerVersion: '3f9c2d1',
pactUrls: [pactFile],
logLevel: 'error',
})
The verifier prints a report to stdout with timings on every line, so the sample runs it in a
child process and keeps the verdict and the reasons; verify.ts is that wrapper. Pact’s native
core reports each verification to Google Analytics and pact-js reports each install to Scarf, so a
CI job should set PACT_DO_NOT_TRACK=true and SCARF_ANALYTICS=false. The demo sends
one event over the bus, then puts the same event through the three checks.
node demo.ts
bus: billing published on invoice.paid and the ledger recorded {"invoiceId":"in_1002","amount":450,"currency":"EUR"}
pact: the ledger expects {"contentType":"application/json","topic":"invoice.paid"} with $.amount, $.currency, $.id
pact: billing verification passed
asyncapi: channel address and payload match the document
Three matching rules, one per field the ledger reads, and two metadata values. The verification
passes because billing produces that body on that topic. A --no-metadata flag in the script
drops the wrapper, which is the shape of the pitfall below.
Make the same check from an AsyncAPI document and from a Seneca spec
An AsyncAPI document names the
channel, its address, and the message payload schema.2 Microcks’
ASYNC_API_SCHEMA runner connects
to the broker endpoint you give it, consumes messages, and checks each payload against that
schema. The sample makes the payload check with Ajv. It adds the two
checks a payload runner cannot make: that the message went out on the channel’s address, with
the content type the document declares.
export function checkAgainstDocument(m: Message): string[] {
const problems: string[] = []
if (m.topic !== channel.address) problems.push(`topic ${m.topic} is not the channel address ${channel.address}`)
if (m.contentType !== message.contentType) problems.push(`content type ${m.contentType} is not ${message.contentType}`)
if (!validPayload(m.body)) {
for (const e of validPayload.errors ?? []) problems.push(`payload${e.instancePath} ${e.message}`)
}
return problems
}
With Seneca there is no topic. A message is routed by its pattern,
sys:ledger,record:payment, and the pattern is on every reply, so a
seneca-msg-test spec asserts it on every line
without being asked.
export const spec = {
pattern: 'sys:billing',
calls: [
{
pattern: 'mark:paid',
params: { id: 'in_1002', amount: 450, currency: 'EUR' },
out: {
ok: true,
invoice: 'in_1002',
recorded: { ok: true, entry: { invoiceId: 'in_1002', amount: 450, currency: 'EUR' } },
},
},
],
}
The spec is data: the messages to send, in order, and what each reply must contain. out is
matched as a subset, so a reply may carry more than the spec names, in the same spirit as the
like rules. What the spec covers ends at Seneca’s edge: an event that leaves the process
through a broker is outside it.
Check it worked
The three test files run together.
node --test pact.test.ts asyncapi.test.ts seneca.test.ts
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1726.849282
Three tests write and verify the pact, including a verification against billing with the topic
renamed, which must fail, and one that checks the sample opts out of both reports. Three run the document check against a matching event, a renamed
topic, and a payload with a decimal amount and a lowercase currency. Two run the Seneca spec
against the system as released and against a ledger that renamed its pattern. The summary line
to compare against is # pass 8.
When it goes wrong
The contract is green, but the ledger has stopped recording payments. Billing renamed the topic,
and the consumer contract was written with withContent alone, so the verification compared the
body and found it unchanged. The pitfall script releases that rename and runs every check against
it.
node pitfall.ts
integration: billing published on invoices.paid, the ledger recorded 0 entries, 1 message went unheard
pact, body only: verified
pact, body and topic: failed
1.1) has matching metadata
Expected message metadata 'topic' to have value '"invoice.paid"' but was '"invoices.paid"'
asyncapi: topic invoices.paid is not the channel address invoice.paid
seneca-msg-test: failed, Error not expected for: {id:in_1002,amount:450,currency:EUR,sys:billing,mark:paid}, err: Error: seneca: No matching action pattern found for { id: 'in
The first line is the integration: one message published, nobody listening on that name, zero entries. The body-only pact verifies it anyway. The pact with metadata fails on the topic and names both values. The document check fails on the channel address while the payload still validates. The Seneca spec fails because the renamed ledger pattern no longer matches the message billing sends, and the error carries the message it could not route. Put the routing key in the contract, and a rename is a red build instead of a quiet broker.3
The second failure is the reverse: billing adds a customerEmail field and the ledger’s build
goes red. The contract pinned the whole body with exact values, or the schema set
additionalProperties to false. Pin the fields the subscriber reads with like, and leave
the schema open to fields it does not.
The third is a Seneca spec that passes while the broker integration is broken. The spec reaches the messages Seneca routes in process; an event that leaves through a broker needs one of the other two checks as well.
When not to do this
Do not write a consumer contract for an event the subscriber only logs. A contract is a promise the publisher has to keep, and a subscriber that reads nothing from the body is asking for a promise it does not need.
Do not let the AsyncAPI document stand in for a consumer contract when subscribers read different subsets of it. A document check proves the payload matches the document; it says nothing about which fields any subscriber depends on, so a field can be removed with the document still valid.
Do not use seneca-msg-test as the contract between a Seneca service and one written in another language or framework. It asserts patterns and replies inside Seneca, and a Go consumer on the other side of a broker never sees the spec.
Do not pin exact values in a message contract. like('EUR') is a type; 'EUR' is a value the
publisher now has to send forever.
Do not verify against a message built by hand in the test. The provider function in the verification must be the one the service calls when it publishes, or the contract proves the test author’s belief rather than the service.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, @pact-foundation/pact 17.1.4, ajv 8.20.0, seneca
3.38.0, seneca-entity 28.1.0, seneca-msg-test 4.1.0, seneca-promisify 3.7.2, and yaml
2.9.1. Every output block is what the command preceding it printed, run in the page’s code
directory after npm ci. The broker is an in-memory bus in the sample. Microcks was not run; its
runner is described from its documentation, and the sample’s Ajv check stands in for it.
Footnotes
-
The version 3 branch of the pact specification introduces messages as a non-request/response interaction for queues where the flow may be one way. Its example spells the field
metaData, with a capital D. The file pact-js writes spells itmetadata, and the verifier on this page accepted that spelling. The difference is one capital letter, which is the size of drift a contract exists to catch. ↩︎ Back to text -
AsyncAPI 3.0 separates a channel’s key from its address. The key is an identifier for the described channel.
addressis an optional string, typically the topic name, routing key, event type, or path, and the specification allows it to be absent, in which case the address is unknown. A document may therefore describe a channel without saying where it is. That is a useful property for a specification and an awkward one for a check that asks whether a message went to the right place. ↩︎ Back to text -
The Microcks test endpoints reference adds that a test on an event-based API with the ASYNC_API_SCHEMA strategy has to choose one and only one operation at a time. A document with six channels is six test runs, each pointed at one endpoint, and the endpoint, not the document, says which topic is consumed. ↩︎ Back to text