Testing Background Jobs Queued With BullMQ
Background jobs are where applications put the work that is slow, unreliable or both — sending email, generating reports, calling rate-limited APIs. That makes their failure behaviour the whole point: retries, backoff, attempt limits and what happens to a job that exhausts them. Yet most job code is tested either not at all or by starting Redis, enqueueing a job and sleeping until something happens, which is slow and flaky in equal measure. This guide splits BullMQ code into three testable parts — the processor as a plain function, the enqueueing code against a fake queue, and the real worker against Redis for the wiring — and covers retries and backoff without real waits. It targets BullMQ 5.x with Vitest, and sits under event-driven and queue mocking.
Root Cause Analysis
The typical job file mixes three concerns: what the job does, how it is enqueued, and how the worker is configured. Testing any one of them therefore seems to require all three, which means Redis, which means a slow suite. Teams respond by testing only the happy path through a real queue, or by not testing jobs at all.
The second difficulty is time. A job configured to retry five times with exponential backoff starting at one second takes more than thirty seconds to exhaust its attempts. Testing that with real timers is impractical, so the retry behaviour — arguably the most important thing about a background job — goes untested.
Third, the interesting failures are about side effects across attempts. A job that sends an email and then updates a database row, and fails on the update, will send the email again on retry. That is only visible if the test runs the job more than once and counts what happened, which a single happy-path run never does.
Reproducible Setup
Separate the processor from the worker so it can be imported and called on its own.
// src/jobs/send-receipt.ts — the processor, no BullMQ imports
export type SendReceiptData = { orderId: string; email: string };
type Deps = { orders: OrderRepo; mailer: Mailer; receipts: ReceiptLog };
export async function sendReceipt(data: SendReceiptData, deps: Deps, attempt = 1) {
if (await deps.receipts.wasSent(data.orderId)) return { skipped: 'already_sent' };
const order = await deps.orders.get(data.orderId);
if (!order) throw new UnrecoverableJobError(`order ${data.orderId} not found`);
await deps.mailer.send({ to: data.email, template: 'receipt', idempotencyKey: `receipt:${order.id}` });
await deps.receipts.markSent(order.id);
return { sentOnAttempt: attempt };
}
// src/jobs/worker.ts — the thin BullMQ wiring
import { Worker, UnrecoverableError } from 'bullmq';
import { sendReceipt } from './send-receipt';
export function startReceiptWorker(connection: ConnectionOptions, deps: Deps) {
return new Worker('receipts', async (job) => {
try {
return await sendReceipt(job.data, deps, job.attemptsMade + 1);
} catch (err) {
if (err instanceof UnrecoverableJobError) throw new UnrecoverableError(err.message);
throw err;
}
}, { connection, concurrency: 5 });
}
Implementation
Step 1 — Test the processor as a function. Every business case — the normal send, the already-sent skip, the missing order — runs without a queue.
// src/jobs/send-receipt.test.ts
import { test, expect } from 'vitest';
import { sendReceipt } from './send-receipt';
import { memoryJobDeps } from '../../test/fakes/job-deps';
test('sends the receipt and records it', async () => {
const deps = memoryJobDeps({ orders: [{ id: 'o1' }] });
expect(await sendReceipt({ orderId: 'o1', email: 'a@example.test' }, deps)).toEqual({ sentOnAttempt: 1 });
expect(deps.mailer.sent()).toHaveLength(1);
});
test('treats a missing order as unrecoverable rather than retrying', async () => {
const deps = memoryJobDeps({ orders: [] });
await expect(sendReceipt({ orderId: 'gone', email: 'a@example.test' }, deps)).rejects.toThrow(UnrecoverableJobError);
});
Step 2 — Test retries by calling the processor repeatedly. A retry is just another invocation with the same data; simulate the failure on the first attempt and count side effects across both.
test('a failure after sending does not send twice on retry', async () => {
const deps = memoryJobDeps({ orders: [{ id: 'o1' }] });
deps.receipts.failNextMark(); // fails after the email went out
await expect(sendReceipt({ orderId: 'o1', email: 'a@example.test' }, deps, 1)).rejects.toThrow();
await sendReceipt({ orderId: 'o1', email: 'a@example.test' }, deps, 2);
expect(deps.mailer.sent()).toHaveLength(1); // the idempotency key held
});
Step 3 — Test enqueueing against a fake queue. The producer’s job is to add the right job with the right options; a small fake records exactly that.
// test/fakes/memory-queue.ts
export function memoryQueue() {
const added: Array<{ name: string; data: unknown; opts: Record<string, unknown> }> = [];
return {
added,
async add(name: string, data: unknown, opts: Record<string, unknown> = {}) {
added.push({ name, data, opts });
return { id: String(added.length) };
},
};
}
test('placing an order enqueues a receipt with retry and dedupe options', async () => {
const queue = memoryQueue();
await placeOrder({ basketId: 'b1', email: 'a@example.test' }, { receiptQueue: queue });
expect(queue.added).toEqual([{
name: 'send-receipt',
data: { orderId: expect.any(String), email: 'a@example.test' },
opts: expect.objectContaining({ attempts: 5, backoff: { type: 'exponential', delay: 1000 }, jobId: expect.stringMatching(/^receipt:/) }),
}]);
});
Step 4 — Compute backoff with fake time instead of waiting. The schedule is arithmetic; assert on it directly, and advance fake timers where your own code implements delays.
import { vi } from 'vitest';
test('exponential backoff doubles the delay each attempt', () => {
const delays = [1, 2, 3, 4].map((attempt) => 1000 * 2 ** (attempt - 1));
expect(delays).toEqual([1000, 2000, 4000, 8000]);
});
test('a delayed follow-up runs after the configured interval', async () => {
vi.useFakeTimers();
const run = vi.fn();
scheduleFollowUp(run, { delayMs: 60_000 });
await vi.advanceTimersByTimeAsync(59_999);
expect(run).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(run).toHaveBeenCalledOnce();
vi.useRealTimers();
});
Step 5 — Verify the worker wiring against real Redis, briefly. The mapping from BullMQ’s job to the processor, the translation of unrecoverable errors, and the completion and failure events need the real library.
// test/integration/receipt-worker.test.ts
import { Queue, QueueEvents } from 'bullmq';
import { RedisContainer } from '@testcontainers/redis';
let redis: StartedRedisContainer;
beforeAll(async () => { redis = await new RedisContainer('redis:7-alpine').start(); }, 60_000);
afterAll(() => redis.stop());
test('an unrecoverable error fails the job without further attempts', async () => {
const connection = { host: redis.getHost(), port: redis.getPort() };
const queue = new Queue(`receipts-${process.pid}`, { connection });
const events = new QueueEvents(queue.name, { connection });
const worker = startReceiptWorker(connection, memoryJobDeps({ orders: [] }));
const job = await queue.add('send-receipt', { orderId: 'missing', email: 'x@example.test' }, { attempts: 5 });
await expect(job.waitUntilFinished(events, 10_000)).rejects.toThrow(/not found/);
expect((await queue.getJob(job.id!))!.attemptsMade).toBe(1);
await worker.close(); await events.close(); await queue.close();
});
Step 6 — Name queues per run. Parallel CI jobs sharing a Redis instance will otherwise process each other’s jobs.
Verification
Confirm the split by timing each tier. Processor and enqueue tests should run in well under a second; the Redis tests in a few seconds.
npx vitest run src/jobs --reporter=dot # 18 tests, 0.2s
npx vitest run test/integration --reporter=dot # 3 tests, 4.1s
Then prove the retry test is meaningful by removing the idempotency key from the mailer call. The duplicate-send test must fail — if it passes, the fake mailer is not honouring idempotency keys and the test is not modelling the real provider.
Troubleshooting
Symptom: integration tests hang after finishing. Diagnosis: a worker, queue or queue-events connection is still open. Fix: close all three in afterEach or afterAll, in the order worker, events, queue — open Redis connections keep the process alive.
Symptom: a job is retried although it can never succeed. Diagnosis: the processor throws an ordinary error for a permanent condition. Fix: throw BullMQ’s UnrecoverableError for those, as the wiring does, and test the classification at the processor level where it is cheap.
Symptom: fake timers do not advance BullMQ’s own delays. Diagnosis: BullMQ schedules delayed jobs in Redis, not with JavaScript timers. Fix: test delay values as configuration at the unit tier, and use real but short delays in the handful of integration tests that need them.
Symptom: jobs from another test run appear in the queue. Diagnosis: queue names are shared across concurrent CI jobs using the same Redis. Fix: include the run identifier and process id in queue names, and obliterate the queue in teardown.
FAQ
Can I test BullMQ without Redis at all?
For the processor and the enqueueing code, yes — which is most of the tests. The worker wiring genuinely needs Redis, because it is the library’s interaction with Redis you are verifying. ioredis-mock works for some BullMQ operations but not reliably for the Lua scripts BullMQ relies on, so a containerised Redis is the dependable choice there.
How do I test repeatable or cron-scheduled jobs?
Test the schedule expression and the job’s behaviour separately. The expression is configuration — assert on it. The behaviour is the processor — call it. Verifying that BullMQ fires the job at the right wall-clock time is testing BullMQ itself, which its maintainers already do.
Should the processor receive the BullMQ Job object?
Prefer plain data plus an attempt number, as in the example. A processor that takes the full Job object is harder to call in tests and tends to reach for methods like updateProgress that then need faking. Keep the job object at the wiring layer and pass the processor only what it needs.
What about job progress and events?
Progress updates are a worker concern; if they matter to users, test that the wiring calls updateProgress with the values the processor reports, using a real queue in one integration test. The processor itself can return or yield progress values that a unit test asserts on directly.
Related
- Back to Event-Driven & Queue Mocking
- Stubbing Redis with ioredis-mock — when an in-memory Redis is and is not enough.
- Testing debounce and throttle with fake timers — controlling time in your own scheduling code.
- Testing webhook handlers with signed payloads — a common source of jobs.