Skip to main content
Back to Blog
API Testing
2026-07-10

RabbitMQ Contract Testing Guide

Create RabbitMQ contract testing for exchanges, routing keys, headers, payload schemas, acknowledgements, and consumer compatibility in CI pipelines.

RabbitMQ Contract Testing Guide

RabbitMQ contract failures are rarely limited to a JSON field. A producer can publish to the right exchange with the wrong routing key. A consumer can bind to a pattern that no longer matches. A message can be valid JSON but missing the header that drives retries or tenant isolation. Contract testing for RabbitMQ has to cover broker topology and message semantics together.

This guide shows how to test exchange declarations, routing keys, headers, payload schemas, and consumer compatibility using real AMQP behavior. For Node integration setup with disposable brokers, see Testcontainers RabbitMQ Node integration testing. For the broader event-driven QA strategy around producers and consumers, read Event-driven architecture testing guide.

RabbitMQ contracts include topology

In HTTP APIs, the endpoint path and payload are usually enough to start a contract discussion. In RabbitMQ, the exchange type, queue binding, routing key, delivery mode, content type, headers, and acknowledgement behavior can all be part of the contract. If any one of them drifts, the consumer may never see the message or may process it incorrectly.

Write the contract in a form the team can review. It can be AsyncAPI, JSON files, TypeScript constants, or another internal schema. The important point is that tests derive expectations from it and block accidental changes.

Contract elementRabbitMQ conceptTest assertionBreakage example
Exchange nameassertExchangeorders.events exists as topic exchangeProducer publishes to order.events and no queue receives it.
Exchange typedirect, topic, fanout, headersDeclared type matches contractTopic wildcard binding stops working after direct exchange change.
Routing keypublish routingKeyProducer sends order.created.v1Consumer binding order.*.v1 misses order-created-v1.
Queue bindingbindQueueQueue is bound to exchange with expected patternConsumer queue exists but is never routed messages.
Payload schemaMessage bodyAjv or schema validator accepts bodytotalCents becomes string and billing consumer fails.
HeadersAMQP properties.headersRequired tenant, event type, version presentRetry or tenant routing cannot classify message.
Acknowledgement modeack, nack, rejectConsumer acks success and nacks retryable failurePoison message loops or silently disappears.

Verifying topology with amqplib

The amqplib package gives direct access to RabbitMQ channel operations. A topology contract test should connect to a disposable broker, declare the exchange and queue exactly as production code expects, bind the queue, publish a probe message, and assert that it is routed. This catches mismatched exchange type and routing key patterns.

import amqp from 'amqplib';

test('orders created messages route to the billing queue', async () => {
  const connection = await amqp.connect(process.env.RABBITMQ_URL || 'amqp://localhost:5672');
  const channel = await connection.createChannel();

  try {
    await channel.assertExchange('orders.events', 'topic', { durable: true });
    const queue = await channel.assertQueue('contract.billing.orders', {
      durable: false,
      exclusive: true,
      autoDelete: true,
    });
    await channel.bindQueue(queue.queue, 'orders.events', 'order.created.v1');

    channel.publish(
      'orders.events',
      'order.created.v1',
      Buffer.from(JSON.stringify({ orderId: 'ord_123', totalCents: 1299 })),
      {
        contentType: 'application/json',
        headers: { eventType: 'OrderCreated', eventVersion: '1' },
      },
    );

    const message = await channel.get(queue.queue, { noAck: true });

    expect(message).not.toBe(false);
    expect(message && message.fields.routingKey).toBe('order.created.v1');
    expect(message && message.properties.headers).toMatchObject({
      eventType: 'OrderCreated',
      eventVersion: '1',
    });
  } finally {
    await channel.close();
    await connection.close();
  }
});

This test is intentionally small. It does not run the whole billing service. It proves that the declared exchange, binding, routing key, and headers are coherent. Consumer behavior gets its own tests.

Payload validation is necessary but not sufficient

Message body validation catches shape drift. It does not catch a missing header, nonpersistent delivery mode, wrong exchange, or incorrect routing key. Keep payload validation as one layer in the contract suite.

Use the same schema format your consumers rely on. If messages are JSON, Ajv with JSON Schema is a good fit. If messages use Avro, Protobuf, or another binary format, use that schema tooling. The test should validate both producer fixtures and messages captured from producer code.

import Ajv from 'ajv';

const orderCreatedSchema = {
  type: 'object',
  additionalProperties: false,
  required: ['orderId', 'customerId', 'totalCents', 'currency'],
  properties: {
    orderId: { type: 'string', minLength: 1 },
    customerId: { type: 'string', minLength: 1 },
    totalCents: { type: 'integer', minimum: 0 },
    currency: { type: 'string', pattern: '^[A-Z]{3}$' },
  },
};

test('producer order.created.v1 payload satisfies consumer schema', () => {
  const message = buildOrderCreatedMessage({
    orderId: 'ord_123',
    customerId: 'cus_456',
    totalCents: 1299,
    currency: 'USD',
  });

  const ajv = new Ajv({ allErrors: true });
  const validate = ajv.compile(orderCreatedSchema);

  expect(message.exchange).toBe('orders.events');
  expect(message.routingKey).toBe('order.created.v1');
  expect(message.properties.contentType).toBe('application/json');
  expect(message.properties.headers).toMatchObject({
    eventType: 'OrderCreated',
    eventVersion: '1',
  });
  expect(validate(JSON.parse(message.body.toString('utf8')))).toBe(true);
  expect(validate.errors).toBeNull();
});

The producer helper returns the exact exchange, routing key, properties, and body passed to channel.publish. If your application constructs those inline, extract the construction. It makes contract drift visible in code review.

Routing key compatibility

RabbitMQ topic routing keys are string APIs. Changing separators, word order, pluralization, or version placement can break bindings. A consumer bound to order.*.v1 will not receive order.created.us.v1 unless that pattern is updated. A consumer bound to order.# may receive more than intended.

Test the routing matrix explicitly. The contract should state which keys route to which queues and which keys must not. This is especially important when teams use broad wildcard bindings during early development and later discover a consumer is processing unrelated events.

Routing keyBilling queueSearch index queueAudit queueContract note
order.created.v1YesYesYesNew order affects all three consumers.
order.cancelled.v1YesYesYesBilling reverses pending charge, search removes availability.
order.payment_failed.v1YesNoYesSearch should not receive payment-only events.
inventory.adjusted.v1NoYesYesBilling must not receive inventory-only event.
customer.updated.v1NoYesYesBilling receives this only through a separate customer contract.
order.created.v2No until migratedNo until migratedYesAudit may observe new versions before business consumers opt in.

Consumer compatibility tests

Consumer contract tests should verify that the consumer accepts all messages it claims to support and rejects or dead-letters messages it cannot process. Do not stop at handler unit tests that pass a parsed object. Test the message envelope too, including headers and content type.

A useful consumer test calls the consumer handler with a real amqplib-like message object. It asserts that success leads to ack, retryable failure leads to nack with requeue policy, and invalid contract leads to reject or dead-letter behavior depending on your design.

test('billing consumer acks a valid order created message', async () => {
  const channel = {
    ack: vi.fn(),
    nack: vi.fn(),
    reject: vi.fn(),
  };

  const message = {
    content: Buffer.from(
      JSON.stringify({
        orderId: 'ord_123',
        customerId: 'cus_456',
        totalCents: 1299,
        currency: 'USD',
      }),
    ),
    properties: {
      contentType: 'application/json',
      headers: { eventType: 'OrderCreated', eventVersion: '1' },
    },
    fields: { routingKey: 'order.created.v1' },
  };

  await handleBillingMessage(message as any, channel as any);

  expect(channel.ack).toHaveBeenCalledWith(message);
  expect(channel.nack).not.toHaveBeenCalled();
  expect(channel.reject).not.toHaveBeenCalled();
});

This checks the acknowledgement contract. A consumer that processes the message but forgets to ack will create duplicate delivery under manual acknowledgement mode. That is a production behavior, not a test detail.

Dead-letter and retry contracts

Retries are part of the consumer contract. A retryable downstream timeout and a permanently invalid payload should not behave the same way. RabbitMQ dead-letter exchanges, retry queues, and message headers make this explicit, but tests need to cover them.

FailureConsumer actionRouting expectationTest assertion
JSON parse errorReject without requeueDead-letter as invalid contractreject called with requeue false, error metric emitted.
Missing required headerReject without requeueDead-letter as invalid contractConsumer does not call business handler.
Downstream 503Nack with requeue or publish to retry exchangeMessage retried under policynack or retry publish includes retry count.
Duplicate event idAck after idempotency checkNo retryBusiness state unchanged, ack called.
Unknown eventVersionReject or route to compatibility handlerVersion migration path usedNo silent success on unsupported version.

The exact retry design varies by team. The test should match the design and make it visible. Silent catch-and-ack on every error is easy to write and expensive to operate.

Contract review for exchange changes

Changing an exchange type is a breaking change unless every binding is reviewed. A topic exchange and direct exchange can share the same name in code review, but they do not route the same way. RabbitMQ will also reject redeclaring an existing exchange with a different type in the same broker. That failure may appear during deployment rather than unit tests if you do not test topology.

Treat exchange changes like database migrations. Plan creation, binding migration, dual publish if needed, consumer rollout, and old exchange removal. Contract tests should cover both old and new paths during the migration window if consumers run side by side.

Publisher confirms and durability expectations

For business-critical messages, the producer contract may include publisher confirms and persistent delivery. A test that only checks channel.publish return value does not prove the broker accepted the message durably. RabbitMQ confirm channels let the producer wait for broker acknowledgement. If the contract says order events must not be fire-and-forget, the producer should use a confirm channel or an equivalent reliability pattern.

You do not need to test RabbitMQ itself. Test that your publisher uses the agreed mode and message properties. If the message should survive broker restart, deliveryMode or persistent settings matter. If the message is transient telemetry, durable persistence may be unnecessary overhead. The contract should distinguish those cases.

Message typeReliability expectationProducer contract check
Order createdPersistent, broker confirm requiredConfirm channel used and persistent property set.
Password reset email requestPersistent or retry-backedMessage id and idempotency key present.
Search indexing hintRetryable but not user-blockingDead-letter path or replay source documented.
Analytics eventOften transientLoss tolerance documented, no false durability claim.
Audit eventPersistent and immutableConfirm required, audit routing key tested.

Message identifiers and idempotency

RabbitMQ can redeliver messages. Consumers should assume at-least-once delivery unless the architecture proves otherwise. Contract tests should verify idempotency fields for events that cause side effects. A billing consumer that charges a card needs an event id or business id it can use to prevent duplicate work.

Put the idempotency key in a stable place: messageId property, eventId header, or payload field. Do not let every producer invent a different location. The consumer contract should state which one it reads. Then test duplicate delivery by calling the handler twice with the same message and asserting the side effect occurs once while both deliveries are acked or handled according to policy.

Idempotency fieldWhere it livesConsumer use
messageIdAMQP basic propertiesGeneric deduplication across event types.
eventIdHeader or payloadDomain event tracking and audit.
orderId plus eventTypePayload and headerEntity-specific idempotency when event ids are absent.
causationIdHeaderTrace workflow that produced the event.
correlationIdAMQP propertyConnect request, event, and logs across services.

Poison messages and parking queues

A malformed message should not block a queue forever. RabbitMQ consumers need a poison-message policy: reject to dead-letter, park after retry count, or alert and stop consumption for severe contract violations. Contract tests should simulate invalid JSON, unsupported version, missing header, and handler exception separately. They are not the same failure.

Dead-letter tests should assert routing, not only method calls. In an integration test with a disposable broker, publish an invalid message, let the consumer process it, and assert it appears on the dead-letter queue with enough metadata to investigate. That metadata may include original exchange, routing key, error reason, and retry count. Without it, dead-letter queues become warehouses of mystery bytes.

Topology migration tests

Topology migration deserves a temporary contract state. Suppose a direct exchange is being replaced by a topic exchange. During migration, the producer may dual publish, consumers may bind to both exchanges, and the old exchange may be removed later. Tests should encode that stage so a cleanup commit does not happen before every consumer has moved.

Use migration-specific test names. A test called routes order.created.v2 during dual publish is clearer than a generic routing still works. When migration ends, delete the old-path tests in the same pull request that removes the old binding. Leaving stale migration tests around encourages accidental permanent complexity.

Migration stageProducer testConsumer test
Before migrationPublishes only old exchange and keyConsumes only old binding.
Dual publishPublishes old and new messages from one domain eventConsumer accepts old while new consumer is introduced.
Consumer migratedNew message accepted, old still tolerated if backlog existsOld queue drains without new writes.
CleanupOld publish path removedOld binding test removed with explicit review.

Observability for contract failures

RabbitMQ contract failures need logs and metrics with broker vocabulary. A message rejected for missing eventVersion should log exchange, routing key, consumer name, message id, and reason. It should not log sensitive payloads by default. Metrics should separate invalid contract, retryable downstream failure, duplicate ignored, and successful ack.

These dimensions make production incidents easier to triage. If invalid_contract_count rises after a producer deploy, the consumer is doing its job by refusing bad messages. If retry_count rises with downstream_timeout, the issue is likely dependency health. If duplicate_ignored rises, redelivery or publisher retry behavior needs investigation.

Contract fixtures from captured messages

Production captures can improve RabbitMQ contracts, but they need cleaning before they become fixtures. Remove secrets, customer personal data, and environment-specific identifiers. Keep the structural parts that matter: exchange, routing key, headers, content type, message id, and a representative payload shape. Then add the sanitized fixture to the producer and consumer contract suites.

Captured fixtures are especially useful for optional fields. Documentation often shows the ideal message, while production shows combinations created by older clients, partial workflows, or regional rules. A consumer that only accepts the ideal fixture is not compatible with the stream it actually receives.

Treat captured fixtures as evidence, not as a dumping ground. Each one should have a reason: old version replay, missing optional field, maximum line item count, duplicate delivery, unsupported version, or dead-letter case. If nobody can explain the fixture, it will become noise and slow the suite without protecting a real behavior.

Rotate fixtures when the contract version retires. Keeping every historical message forever makes failures harder to interpret and can imply compatibility promises the team no longer offers. Archive retired fixtures with the migration record instead, including the owning consumer, retirement date, and broker version used.

Frequently Asked Questions

Is validating the JSON payload enough for RabbitMQ contracts?

No. Payload validation is only one layer. RabbitMQ contracts also include exchange name, exchange type, routing key, queue binding, headers, content type, and acknowledgement behavior.

Should contract tests declare real exchanges and queues?

Yes, in an isolated broker. Declaring topology in a disposable RabbitMQ instance catches exchange type and binding mistakes that pure unit tests miss. Do not run those tests against a shared production broker.

How should routing key changes be versioned?

If consumers need to opt in, use a new versioned key or binding and run both paths during migration. Avoid changing a routing key in place unless every consumer binding is updated in the same coordinated release.

What should happen to invalid messages?

Invalid contract messages should usually be rejected without requeue and sent to a dead-letter path for inspection. Retry loops are for temporary failures, not permanently malformed payloads.

How do I test acknowledgement behavior?

Pass a realistic message object to the consumer and mock the channel ack, nack, and reject methods. Assert the exact method and requeue choice for success, retryable failure, and invalid contract cases.

RabbitMQ Contract Testing Guide | QASkills.sh