How-to › Test and mock integrations

How to contract test messages between services#

Pin the body and the routing key of an event in a consumer contract, verify the publisher in CI, and see the three tools catch, or miss, a renamed topic.

Audience
Platform team
Level
advanced
Topic
Run contract and spec tests
Languages
TypeScript
Verified

Read first: Choose consumer-driven or spec-driven contract tests

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Microcks ASYNC_API_SCHEMA runnerAn AsyncAPI document already describes the channel, and you want messages on a live broker checked against itA Microcks deployment and a broker it can consume from, for a check of the payload alone against the endpoint you nameNobody keeps the AsyncAPI document current, or the subscriber reads less than the document promises
Pact message pactsThe subscriber team states what it reads, body and metadata, and the publisher verifies it in its own buildA pact file per consumer to publish and verify, matching rules to keep loose, and a broker or a repository to exchange themOne document must serve subscribers you cannot reach, or the message format has no Pact library
seneca-msg-testBoth services are Seneca plugins, and the pattern is the routing keyA spec per plugin inside one toolkit, with nothing to show a consumer written in another framework or languageThe 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.

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

  1. 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 it metadata, 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

  2. AsyncAPI 3.0 separates a channel’s key from its address. The key is an identifier for the described channel. address is 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

  3. 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

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 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.