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

Source: https://voxgig.com/howto/contract-test-messages-between-services

- Audience: platform-team
- Level: advanced
- Languages: typescript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](/howto/choose-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](https://microcks.io/documentation/references/test-endpoints/) | 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](https://docs.pact.io/implementation_guides/javascript/docs/messages) | 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](https://github.com/senecajs/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.

```ts title="pact.test.ts"
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](https://docs.pact.io/implementation_guides/javascript/docs/messages)
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.

```ts title="verify-provider.ts"
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.

```bash
node demo.ts
```

```text output
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](https://www.asyncapi.com/docs/reference/specification/v3.0.0) document names the
channel, its `address`, and the message payload schema.[^2] Microcks'
[ASYNC_API_SCHEMA runner](https://microcks.io/documentation/references/test-endpoints/) 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](https://ajv.js.org/). 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.

```ts title="schema.ts"
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](https://senecajs.org/docs/) 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](https://github.com/senecajs/seneca-msg-test) spec asserts it on every line
without being asked.

```ts title="spec.ts"
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.

```bash
node --test pact.test.ts asyncapi.test.ts seneca.test.ts
```

```text output
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.

```bash
node pitfall.ts
```

```text output
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

- [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)

- [Describe a Kafka topic with AsyncAPI](/howto/describe-a-kafka-topic-with-asyncapi)

- [Document Seneca plugin message patterns inside the repo](/howto/document-seneca-plugin-message-patterns)

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

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

[^1]: The version 3 branch of the
[pact specification](https://github.com/pact-foundation/pact-specification/tree/version-3)
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.

[^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](https://www.asyncapi.com/docs/reference/specification/v3.0.0) 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.

[^3]: The Microcks
[test endpoints reference](https://microcks.io/documentation/references/test-endpoints/) 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.