QA How-To
Mock RabbitMQ Events in Integration Tests
Build mock RabbitMQ events integration tests with Vitest, amqplib, and Testcontainers, then verify payloads, acknowledgments, duplicate events, and retries.
19 min read | 2,342 words
TL;DR
Run RabbitMQ in a disposable Testcontainers instance, publish controlled events with amqplib, and inject mock business dependencies into the real consumer. Assert the resulting state plus acknowledgment or retry behavior, then isolate and clean up every test.
Key Takeaways
- Use a disposable real RabbitMQ broker for protocol behavior and mock only boundaries that your test needs to control.
- Give every test unique exchange and queue names so parallel runs cannot consume each other's messages.
- Publish persistent JSON messages and assert business outcomes instead of relying only on publish return values.
- Expose consumer lifecycle hooks so tests can start, stop, acknowledge, and reject messages deterministically.
- Verify success, malformed payload, duplicate delivery, and transient failure paths as separate scenarios.
- Collect broker state and application logs before cleanup so CI failures remain diagnosable.
To mock RabbitMQ events in integration tests, control the event payload and downstream application dependencies while keeping a real RabbitMQ broker in the test path. This approach verifies exchanges, routing keys, serialization, delivery, acknowledgments, and consumer behavior without relying on a shared environment.
This tutorial is a focused companion to the event-driven API testing complete guide. You will build a TypeScript test harness that starts RabbitMQ in Docker, publishes exact events, runs a real consumer, and replaces only the order service with a Vitest mock.
The word mock can be misleading here. A fully mocked amqplib channel is useful for a small unit test, but it cannot prove that RabbitMQ routes or redelivers a message. The practical integration boundary is a disposable broker plus controlled producers and injected application dependencies.
What You Will Build
You will create a small event-driven test project that can:
- Start RabbitMQ 4 in a disposable Docker container.
- Declare a topic exchange and an isolated queue for each test.
- Publish realistic
order.createdJSON events through amqplib. - Run the production-style consumer with a mocked order service.
- Verify successful acknowledgments, invalid-message rejection, idempotency, and retry behavior.
The final test uses real AMQP networking. Only the business service is mocked, which makes failures meaningful without making the suite dependent on databases or third-party APIs.
Prerequisites
Use Node.js 22 or newer, npm 10 or newer, and Docker Engine 27 or a current Docker Desktop release. The examples use TypeScript, Vitest 3, amqplib 0.10, and Testcontainers for Node.js 10. RabbitMQ runs from the official rabbitmq:4-management image.
Create the project and install the dependencies:
mkdir rabbitmq-event-tests
cd rabbitmq-event-tests
npm init -y
npm install amqplib
npm install --save-dev typescript vitest testcontainers @types/amqplib @types/node
Docker must be running before the suite starts. Confirm both runtimes:
node --version
docker version
Expected verification: Node prints version 22 or later, and docker version shows both Client and Server sections. If the Server section is missing, start Docker before continuing.
Step 1: Configure the TypeScript Test Project
Add scripts and module settings. The project uses native ECMAScript modules so imports behave the same in tests and production code.
Create package.json with this complete configuration:
{
"name": "rabbitmq-event-tests",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"amqplib": "^0.10.8"
},
"devDependencies": {
"@types/amqplib": "^0.10.7",
"@types/node": "^22.0.0",
"testcontainers": "^10.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "vitest/globals"]
},
"include": ["src", "tests"]
}
Run npm install after replacing the generated package file. Exact lockfile versions may be newer compatible releases, which is desirable for a maintained test project. Commit the lockfile in a real repository so local and CI installs match.
Verification: run npx tsc --noEmit. It should finish without output and return exit code zero. Do not use npm test as this step's check because Vitest normally returns a nonzero exit code when the project contains no test files yet.
Step 2: Define the Event Contract and Business Boundary
Create src/order-event.ts. Keep validation at the consumer boundary because an AMQP message is untrusted input, even when another internal service publishes it.
export type OrderCreated = {
eventId: string;
type: 'order.created';
occurredAt: string;
data: {
orderId: string;
customerId: string;
totalCents: number;
};
};
export interface OrderService {
createOrder(event: OrderCreated): Promise<void>;
}
export class InvalidEventError extends Error {}
export function parseOrderCreated(content: Buffer): OrderCreated {
let value: unknown;
try {
value = JSON.parse(content.toString('utf8'));
} catch {
throw new InvalidEventError('Event must contain valid JSON');
}
if (typeof value !== 'object' || value === null) {
throw new InvalidEventError('Event must be an object');
}
const event = value as Partial<OrderCreated>;
if (
event.type !== 'order.created' ||
typeof event.eventId !== 'string' ||
typeof event.occurredAt !== 'string' ||
typeof event.data?.orderId !== 'string' ||
typeof event.data.customerId !== 'string' ||
typeof event.data.totalCents !== 'number'
) {
throw new InvalidEventError('Invalid order.created event');
}
return event as OrderCreated;
}
This contract deliberately validates fields the consumer actually needs. In a larger system, use the same published JSON Schema or generated type shared by producer and consumer. If your organization uses binary contracts, the Avro schema compatibility CI tutorial shows how to detect breaking changes before deployment.
Verification: run npx tsc --noEmit. It should finish without output and return exit code zero. Temporarily change totalCents: number to an invalid type in a fixture later and confirm TypeScript catches it before runtime.
Step 3: Build a Testable RabbitMQ Consumer
Create src/order-consumer.ts. The consumer accepts an existing channel and an OrderService. This dependency injection keeps connection management outside business processing and lets the integration test observe service calls.
import type { Channel, ConsumeMessage } from 'amqplib';
import {
InvalidEventError,
parseOrderCreated,
type OrderService
} from './order-event.js';
type ConsumerOptions = {
exchange: string;
queue: string;
routingKey: string;
};
export async function startOrderConsumer(
channel: Channel,
service: OrderService,
options: ConsumerOptions
): Promise<() => Promise<void>> {
await channel.assertExchange(options.exchange, 'topic', { durable: false });
await channel.assertQueue(options.queue, { durable: false, autoDelete: true });
await channel.bindQueue(options.queue, options.exchange, options.routingKey);
await channel.prefetch(1);
const seen = new Set<string>();
const result = await channel.consume(options.queue, async (message) => {
if (!message) return;
await handleMessage(channel, service, message, seen);
}, { noAck: false });
return async () => {
await channel.cancel(result.consumerTag);
};
}
async function handleMessage(
channel: Channel,
service: OrderService,
message: ConsumeMessage,
seen: Set<string>
): Promise<void> {
try {
const event = parseOrderCreated(message.content);
if (!seen.has(event.eventId)) {
await service.createOrder(event);
seen.add(event.eventId);
}
channel.ack(message);
} catch (error) {
const malformed = error instanceof InvalidEventError;
channel.nack(message, false, !malformed);
}
}
The consumer acknowledges a valid event only after the service succeeds. Every parser failure becomes an InvalidEventError, so malformed input is rejected without requeueing. A transient service error is requeued. The in-memory seen set demonstrates idempotency inside one process, but production systems should store processed IDs transactionally because restarts clear memory. For broader service-boundary coverage, use the same observable-outcome principle from this API testing with Postman tutorial.
Verification: run npx tsc --noEmit again. Confirm noAck is false and that ack occurs after createOrder. Reversing those two operations can lose an order when the service fails after acknowledgment.
Step 4: Start an Isolated RabbitMQ Broker
Create tests/rabbitmq-harness.ts. Testcontainers maps RabbitMQ's internal AMQP port to a random host port, which avoids collisions on developer laptops and parallel CI agents.
import { GenericContainer, Wait, type StartedTestContainer } from 'testcontainers';
export type RabbitHarness = {
container: StartedTestContainer;
url: string;
};
export async function startRabbitMQ(): Promise<RabbitHarness> {
const container = await new GenericContainer('rabbitmq:4-management')
.withExposedPorts(5672)
.withWaitStrategy(Wait.forLogMessage('Server startup complete'))
.withStartupTimeout(120_000)
.start();
const host = container.getHost();
const port = container.getMappedPort(5672);
return { container, url: `amqp://guest:guest@${host}:${port}` };
}
Do not hardcode localhost:5672. Docker can expose a different host or random port, especially in remote CI. Use the values returned by the started container. The management image is convenient for debugging, although these tests require only AMQP port 5672.
Verification: add a temporary console.log(url) after startup, run the suite once after the test file exists, and confirm the mapped port is dynamic. Remove the log afterward. If startup times out, inspect Docker availability and container logs rather than increasing the timeout immediately.
Step 5: Add an Event Publisher and Observable Assertions
Create tests/helpers.ts. The publisher confirms that RabbitMQ accepted the publish operation, while waitFor polls an application condition with a hard deadline.
import type { ConfirmChannel } from 'amqplib';
import { expect } from 'vitest';
import type { OrderCreated } from '../src/order-event.js';
export async function publishOrder(
channel: ConfirmChannel,
exchange: string,
event: OrderCreated
): Promise<void> {
channel.publish(
exchange,
'orders.created',
Buffer.from(JSON.stringify(event)),
{
contentType: 'application/json',
deliveryMode: 2,
messageId: event.eventId,
type: event.type,
timestamp: Date.now()
}
);
await channel.waitForConfirms();
}
export async function waitFor(
assertion: () => void | Promise<void>,
timeoutMs = 5_000
): Promise<void> {
await expect.poll(assertion, { timeout: timeoutMs, interval: 50 }).not.toThrow();
}
A publisher confirm means the broker handled the publish according to its configuration. It does not mean a consumer processed the event. That is why the test also waits for a business assertion. Avoid fixed sleeps such as setTimeout(2000): they are slow when the system is fast and flaky when CI is slow.
Verification: npx tsc --noEmit should pass. In the finished success test, remove await channel.waitForConfirms() briefly. The business assertion may still pass, but you will have lost the producer-side signal that distinguishes publishing failures from consumer failures. Restore it.
Step 6: Mock RabbitMQ Events in Integration Tests
Create tests/order-consumer.integration.test.ts. The suite shares one container for speed but uses fresh connections, exchanges, and queues for test isolation.
import amqp, { type Channel, type ChannelModel, type ConfirmChannel } from 'amqplib';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { startOrderConsumer } from '../src/order-consumer.js';
import type { OrderCreated, OrderService } from '../src/order-event.js';
import { publishOrder, waitFor } from './helpers.js';
import { startRabbitMQ, type RabbitHarness } from './rabbitmq-harness.js';
let rabbit: RabbitHarness;
let connection: ChannelModel;
let consumerChannel: Channel;
let publisherChannel: ConfirmChannel;
let stopConsumer: (() => Promise<void>) | undefined;
let exchange: string;
let queue: string;
const createOrder = vi.fn<OrderService['createOrder']>();
const service: OrderService = { createOrder };
const event: OrderCreated = {
eventId: 'evt-1001',
type: 'order.created',
occurredAt: '2026-07-15T09:30:00.000Z',
data: { orderId: 'ord-42', customerId: 'cus-7', totalCents: 2599 }
};
beforeAll(async () => { rabbit = await startRabbitMQ(); }, 120_000);
afterAll(async () => { await rabbit.container.stop(); });
beforeEach(async (context) => {
createOrder.mockReset();
createOrder.mockResolvedValue(undefined);
connection = await amqp.connect(rabbit.url);
consumerChannel = await connection.createChannel();
publisherChannel = await connection.createConfirmChannel();
const id = context.task.id.replace(/[^a-zA-Z0-9]/g, '');
exchange = `orders.events.${id}`;
queue = `orders.test.${id}`;
stopConsumer = await startOrderConsumer(consumerChannel, service, {
exchange, queue, routingKey: 'orders.created'
});
});
afterEach(async () => {
await stopConsumer?.();
await publisherChannel.close();
await consumerChannel.close();
await connection.close();
});
describe('order event consumer', () => {
it('processes and acknowledges a valid event', async () => {
await publishOrder(publisherChannel, exchange, event);
await waitFor(() => {
expect(createOrder).toHaveBeenCalledOnce();
expect(createOrder).toHaveBeenCalledWith(event);
});
const state = await consumerChannel.checkQueue(queue);
expect(state.messageCount).toBe(0);
});
it('processes a duplicate event only once', async () => {
await publishOrder(publisherChannel, exchange, event);
await publishOrder(publisherChannel, exchange, event);
await waitFor(() => expect(createOrder).toHaveBeenCalledOnce());
await expect.poll(async () => (await consumerChannel.checkQueue(queue)).messageCount)
.toBe(0);
});
});
The event is mocked in the useful sense: its ID, timestamp, routing key, headers, and payload are deterministic. The consumer and AMQP broker remain real. The order service mock proves the exact domain call without requiring a database.
Verification: run npm test. Both tests should pass, and Docker should remove the container after the suite. Run the command twice to prove cleanup does not leave queue names or port bindings behind.
Step 7: Verify Invalid Events and Retry Behavior
Extend the describe block with two negative tests. RabbitMQ's redelivered flag and queue depth can help, but the strongest assertion is still the consumer's observable business behavior.
it('rejects malformed JSON without calling the service', async () => {
publisherChannel.publish(
exchange,
'orders.created',
Buffer.from('{not-json'),
{ contentType: 'application/json' }
);
await publisherChannel.waitForConfirms();
await expect.poll(async () => (await consumerChannel.checkQueue(queue)).messageCount)
.toBe(0);
expect(createOrder).not.toHaveBeenCalled();
});
it('requeues after a transient service failure and then succeeds', async () => {
createOrder
.mockRejectedValueOnce(new Error('database unavailable'))
.mockResolvedValueOnce(undefined);
await publishOrder(publisherChannel, exchange, { ...event, eventId: 'evt-retry-1' });
await waitFor(() => {
expect(createOrder).toHaveBeenCalledTimes(2);
});
await expect.poll(async () => (await consumerChannel.checkQueue(queue)).messageCount)
.toBe(0);
});
These tests expose an important limitation of immediate requeue: a permanently failing message can spin indefinitely. Production consumers should use bounded retries, delayed queues, or a dead-letter exchange. Follow the dead-letter queue retry testing tutorial to test retry counts and final quarantine explicitly.
Verification: run npm test -- --reporter=verbose. You should see four passing tests. Change the transient mock to always reject and observe that the test times out while deliveries repeat. Stop the run, restore the two-result mock, and never commit the infinite-failure experiment.
Step 8: Make the Suite Reliable in CI
Run integration tests on a worker that can start Docker containers. Do not point tests at a shared staging RabbitMQ instance because unrelated deployments, queue policies, and consumers destroy isolation. A minimal GitHub Actions job is:
name: RabbitMQ integration tests
on:
pull_request:
push:
branches: [main]
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
Testcontainers detects the Docker daemon provided by the hosted Linux runner. Set job-level timeouts so infrastructure failures terminate clearly. Keep Vitest timeouts shorter than the job timeout, and use the 120-second allowance only for initial image startup.
Use unique topology names even when Vitest currently runs the file serially. A future configuration change can enable parallel files. Also close consumers before channels, channels before connections, and the container last. Cleanup order prevents misleading socket errors at the end of an otherwise passing run.
When the RabbitMQ consumer is one part of a larger distributed workflow, add tests at the service boundaries instead of stretching this suite into a full environment test. The microservices testing interview questions guide explains the isolation and observability tradeoffs that teams often discuss during test-strategy reviews.
Verification: push the branch and inspect the first CI run. Confirm the image pull, four passing tests, and successful cleanup. Rerun the job to check that it does not depend on warm images or state from the first attempt.
Mock Channel or Real Broker: Which Test Should You Write?
Use both levels, but assign each one a different responsibility.
| Test approach | What is real | Best assertion | What it cannot prove |
|---|---|---|---|
Unit test with mocked Channel |
Parser and handler logic | ack, nack, and service calls |
Routing, network, queue settings |
| Integration test with Testcontainers | AMQP client, broker, topology, consumer | Business outcome and queue state | Production broker policies unless reproduced |
| System test in an environment | Deployed services and managed broker | End-to-end user or domain outcome | Fast, isolated feedback |
A mocked channel test is fast and precise for branches. For example, pass a fake ConsumeMessage to an exported handler and verify nack(message, false, false). Keep the Testcontainers suite for behavior that only RabbitMQ can provide.
Do not assert internal library calls when an observable outcome is available. publish() returning true describes write-buffer pressure, not successful processing. Publisher confirms, service calls, queue counts, database state, and emitted follow-up events answer different questions, so choose the signal that matches the requirement.
Troubleshooting
Problem: Could not find a working container runtime strategy -> Start Docker and verify docker version can reach the server from the same user and shell that runs Vitest. On CI, choose a runner with Docker access.
Problem: RabbitMQ startup exceeds 120 seconds -> Inspect await container.logs() output and available disk space. A first image pull can be slow, but authentication errors, rate limits, and an unhealthy daemon require direct fixes.
Problem: the service mock is never called -> Confirm the publisher and consumer use the same exchange and routing key. Ensure the queue binding completes before publish; startOrderConsumer awaits declarations and consume, so do not remove those awaits.
Problem: a negative test hangs or calls the mock thousands of times -> The consumer is requeueing a permanent failure. Reject malformed messages without requeueing and apply a bounded dead-letter retry policy for operational failures.
Problem: tests pass alone but fail in parallel -> Generate exchange and queue names from a unique test ID. Avoid module-level fixed names, shared queues, and fixed host ports.
Problem: teardown reports Channel closed -> Stop the consumer before closing its channel, and close the connection after all channels. Wrap cleanup more defensively only after confirming the main test did not unexpectedly close the channel.
Best Practices for Mock RabbitMQ Events Integration Tests
- Mock payloads and business boundaries, not the protocol behavior you intend to verify.
- Use a real disposable broker for bindings, delivery, acknowledgments, and redelivery.
- Give every event a stable
eventId,type, and timestamp. - Validate content type and payload schema at the consumer boundary.
- Acknowledge only after durable business work succeeds.
- Make consumers idempotent because at-least-once delivery permits duplicates.
- Replace fixed sleeps with bounded polling of meaningful outcomes.
- Use publisher confirms, but never mistake them for consumer completion.
- Isolate topology per test and delete it through connection and container cleanup.
- Capture application errors, container logs, and queue state before tearing down a failed CI run.
Interview Questions and Answers
Q: Should RabbitMQ be mocked in an integration test?
Keep a real RabbitMQ broker when routing, serialization, acknowledgments, or redelivery are within scope. Mock downstream business dependencies to make scenarios deterministic. A completely mocked channel is a unit test because no broker integration is exercised.
Q: What does a publisher confirm prove?
It proves the broker accepted responsibility for the published message according to the channel and broker configuration. It does not prove that the intended queue received it or that a consumer completed business processing. Assert the consumer outcome separately.
Q: How do you test at-least-once delivery safely?
Publish the same event ID more than once and verify the durable business effect occurs once. The idempotency record and business mutation should share a transaction where possible. In-memory deduplication is insufficient across restarts.
Q: How do you avoid flaky asynchronous assertions?
Poll a meaningful condition with a deadline instead of sleeping for a fixed interval. Start the consumer before publishing, await topology declarations, use isolated names, and report the final observed state when the deadline expires.
Q: When should a consumer reject instead of requeue?
Reject invalid JSON, incompatible schemas, and other permanent input errors without requeueing. Requeue only failures that are likely transient, and bound attempts with retry and dead-letter policies so poison messages cannot loop forever.
Q: Why use separate publishing and consuming channels?
Channels are lightweight AMQP sessions with different responsibilities. A confirm channel gives producer acknowledgments, while a consumer channel carries deliveries and consumer acknowledgments. Separation also makes lifecycle and failure diagnosis clearer.
Where To Go Next
You now have a repeatable pattern to mock RabbitMQ events in integration tests without mocking away RabbitMQ itself. Expand the suite from a single consumer to contract, retry, and cross-service scenarios:
- Return to the complete event-driven API testing guide for the full test strategy and test pyramid.
- Apply the same consumer-focused structure to Kafka consumer contract testing step by step.
- Add compatibility gates with the Avro schema compatibility in CI tutorial.
- Prove bounded failure handling with the dead-letter queue and retry testing guide.
Conclusion
The most useful RabbitMQ integration test does not replace the broker with a mock. It starts an isolated broker, publishes a controlled event, runs the real consumer, and mocks only the dependencies needed to observe business behavior. That balance catches routing and acknowledgment defects while keeping the suite fast, deterministic, and suitable for pull requests.
Start with the valid-event test, then add malformed input, duplicate delivery, transient failure, and dead-letter scenarios. Together, those cases provide strong evidence that the consumer behaves correctly under both normal and failure conditions.
Interview Questions and Answers
Would you mock RabbitMQ in an integration test?
I keep a real disposable broker when the test covers routing, serialization, delivery, acknowledgments, or redelivery. I mock downstream business dependencies to control success and failure. If I mock the AMQP channel itself, I classify that as a unit test.
What is the difference between a publisher confirm and a consumer acknowledgment?
A publisher confirm flows from the broker to the producer and indicates that the broker handled the publish. A consumer acknowledgment flows from the consumer to the broker after delivery. Neither signal alone proves the complete business outcome, so the test should assert application state too.
How would you test an idempotent RabbitMQ consumer?
I publish the same event ID multiple times and verify only one durable business effect. I also test concurrent or redelivered duplicates where practical. The processed-event record should be committed atomically with the business mutation.
How do you prevent RabbitMQ tests from interfering with each other?
I generate unique exchange, queue, and consumer names per test or worker. I avoid fixed host ports, close consumers and channels in order, and use a disposable broker with known configuration. This permits safe parallel execution.
When should a failed RabbitMQ message be requeued?
I requeue only when the failure is transient and another attempt has a reasonable chance of success. Invalid payloads and unsupported schemas are permanent failures and should be rejected. Every retry path needs a limit and a dead-letter destination.
Why are fixed sleeps a poor choice in event-driven tests?
A short sleep fails on slow runners, while a long sleep wastes time on fast ones. I poll a meaningful application condition with a strict deadline and useful failure output. That makes the test faster and more diagnosable.
Frequently Asked Questions
Can I mock RabbitMQ without Docker?
Yes, you can mock the amqplib channel for unit tests of parsing and acknowledgment branches. That test cannot verify exchanges, bindings, delivery, or broker redelivery, so use a disposable broker when those behaviors matter.
Should RabbitMQ integration tests use a shared test broker?
Prefer a disposable broker per suite because it gives known configuration and clean state. Shared brokers introduce collisions, policy drift, competing consumers, and failures caused by other teams.
How do I know a RabbitMQ message was processed?
Assert an observable business outcome such as a service call, database row, or emitted follow-up event. A successful publish or publisher confirm alone does not prove consumer processing completed.
How should I test RabbitMQ acknowledgments?
Make the business dependency succeed or fail deterministically, then assert the resulting queue and business state. Use a mocked channel unit test when you need a direct assertion on exact `ack` or `nack` arguments.
Why does my RabbitMQ integration test sometimes time out?
Common causes are publishing before the binding exists, fixed sleeps, shared queue names, Docker startup delays, and messages requeued forever. Await setup operations, use unique topology, poll outcomes with deadlines, and inspect broker logs.
How do I test duplicate RabbitMQ events?
Publish the same stable event ID twice and assert the business mutation occurs once. Store processed IDs durably in production because an in-memory set cannot protect against duplicates after a restart.
Related Guides
- How to Run tests in headed mode in Cypress (2026)
- How to Run tests in headed mode in Playwright (2026)
- How to Run tests in headed mode in Selenium (2026)
- Mock React Context in Playwright Component Tests
- How to Debug a failing test in VS Code in Cypress (2026)
- How to Debug a failing test in VS Code in Playwright (2026)