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.

Three parts of a BullMQ job and how each is tested The processor function is tested directly with a job-shaped object, enqueueing code is tested against a fake queue that records added jobs, and the worker wiring is tested against a real Redis in a small number of integration tests. processor what the job does called directly most cases, milliseconds no Redis enqueueing name, data, options against a fake queue a few cases per producer no Redis worker wiring retries, completion, failure against real Redis a handful, seconds containerised
Only the third part needs Redis, and it needs very few tests.

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();
});
A retried job across its attempts A job fails on its first attempt after sending an email, waits for its backoff, and succeeds on the second attempt; the idempotency key on the email ensures the customer receives exactly one message across both attempts. attempt 1 email sent, then fails backoff 1s — fake time attempt 2 email deduplicated completed recorded assert: one email across both attempts not "the job completed"
The meaningful assertion is about the customer's inbox across attempts, not about the final job status.

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.

Retryable versus unrecoverable failures A transient error such as a timeout should be retried with backoff until attempts run out, while a permanent error such as a missing record should fail the job immediately so it does not waste attempts or delay the queue. transient — retry timeouts, rate limits, 503s backoff until attempts run out permanent — fail now missing record, invalid data UnrecoverableError, one attempt
Classifying errors correctly is job logic, and it is tested in the processor, not in Redis.

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.