Overview
Per github.com/taskforcesh/bullmq (opens in new window):
"The fastest, most reliable, Redis-based distributed queue for Node. Carefully written for rock solid stability and atomicity."
BullMQ's testing model differs from Sidekiq / Celery: there is no "fake mode" or in-memory queue substitute. Tests use real Redis (Docker / Testcontainers) or stub it via ioredis-mock. Tests typically validate: queue add → worker processor → completion event.
When to use
How to use
Install
Per bm-gh (opens in new window):
yarn add bullmq
# or
npm install bullmqFor tests, add ioredis-mock for in-memory Redis simulation:
npm install --save-dev ioredis-mockBasic Queue + Worker pattern
Per bm-gh (opens in new window) (verbatim):
import { Queue } from 'bullmq';
const queue = new Queue('Paint');
queue.add('cars', { color: 'blue' });import { Worker } from 'bullmq';
const worker = new Worker('Paint', async job => {
if (job.name === 'cars') {
await paintCar(job.data.color);
}
});The Queue produces; the Worker consumes. Tests typically import and invoke both within the test process.
Worked example
An order flow: assert that placing an order enqueues a job, then that the processor ships it.
First, test the producer - enqueue, then read the queue by state:
import { Queue } from 'bullmq';
import { redisConfig } from './test-config'; // Docker Redis or ioredis-mock
describe('order producer', () => {
let queue: Queue;
beforeAll(() => { queue = new Queue('orders', { connection: redisConfig }); });
beforeEach(async () => { await queue.drain(); }); // clear queue between tests
afterAll(async () => { await queue.close(); });
it('enqueues an order job', async () => {
await placeOrder({ customerId: 1, items: [...] });
const jobs = await queue.getJobs(['waiting']);
expect(jobs).toHaveLength(1);
expect(jobs[0].data.customerId).toBe(1);
});
});queue.getJobs(['waiting']) retrieves jobs in a specific state. Other states: 'active', 'completed', 'failed', 'delayed', 'paused'.
Then test the processor. For unit tests, call the processor function directly (avoid spinning up a real Worker):
const processOrder = async (job: Job<OrderData>) => {
await chargeCard(job.data);
await sendConfirmationEmail(job.data);
return { status: 'shipped' };
};
it('processes an order successfully', async () => {
const job = { data: { customerId: 1, total: 100 } } as Job<OrderData>;
const result = await processOrder(job);
expect(result.status).toBe('shipped');
});For integration tests, instantiate a real Worker and assert via QueueEvents:
import { Queue, Worker, QueueEvents } from 'bullmq';
it('processes via real worker', async () => {
const queue = new Queue('orders', { connection: redisConfig });
const worker = new Worker('orders', processOrder, { connection: redisConfig });
const events = new QueueEvents('orders', { connection: redisConfig });
await new Promise<void>((resolve) => {
events.on('completed', ({ jobId, returnvalue }) => {
expect(JSON.parse(returnvalue).status).toBe('shipped');
resolve();
});
queue.add('order', { customerId: 1 });
});
await worker.close(); await queue.close(); await events.close();
});Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Skip await queue.drain() between tests | Stale jobs leak across tests; flaky | Drain in beforeEach (Worked example) |
| Spin up Worker for every unit test | Slow + Redis-coupled | Test processor function directly (Worked example) |
queue.add() without await | Race: test exits before job is enqueued | Always await queue ops |
Skip worker.close() / queue.close() in afterAll | Hangs CI; Redis connections leaked | Close in afterAll (Worked example) |
ioredis-mock for QueueEvents tests | Mock has gaps in pub/sub command emulation | Use real Redis for events |
Limitations
References
BullMQ advanced patterns and CI wiring
View source (opens in a new tab) (opens in new window)Deep reference for the bullmq-tests SKILL.md. Consult when a test asserts retry / backoff, repeat-job registration, or FlowProducer parent-child semantics, or when wiring BullMQ tests into CI.
Retry + backoff
Configure attempts and backoff when enqueuing:
await queue.add('flaky', { id: 1 }, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
});To test that a worker actually retries:
let attempts = 0;
const flakyProcessor = async (job: Job) => {
attempts++;
if (attempts < 3) throw new Error('transient');
return 'success';
};
const worker = new Worker('flaky', flakyProcessor, { connection: redisConfig });
// ... await events.completed → assert attempts === 3Repeat-job (cron / interval)
await queue.add('hourly-cleanup', {}, {
repeat: { pattern: '0 * * * *' }, // cron syntax
});For tests, assert the repeat job is registered:
const repeatJobs = await queue.getRepeatableJobs();
expect(repeatJobs).toHaveLength(1);
expect(repeatJobs[0].pattern).toBe('0 * * * *');Cross-ref cron-job-test-author for cron-expression validation patterns.
FlowProducer for parent-child jobs
Per bm-gh (opens in new window) the README references parent-child relationships via FlowProducer:
import { FlowProducer } from 'bullmq';
const flow = new FlowProducer({ connection: redisConfig });
const tree = await flow.add({
name: 'parent-job',
queueName: 'parents',
data: {},
children: [
{ name: 'child-1', queueName: 'children', data: { idx: 1 } },
{ name: 'child-2', queueName: 'children', data: { idx: 2 } },
],
});
// Test: parent only completes after all children completeCI integration
For tests that need real Redis:
services:
redis:
image: redis:7
ports: [6379:6379]For tests using ioredis-mock only, no service needed:
import IORedisMock from 'ioredis-mock';
const connection = new IORedisMock();
const queue = new Queue('test', { connection });ioredis-mock doesn't perfectly emulate every Redis command BullMQ uses - for full integration, use real Redis. For pure unit tests of producer logic, ioredis-mock is faster.