ESC

How to Test BullMQ Workers in Node.js

How to test BullMQ workers in Node.js: unit-test the handler, run integration tests against a real Redis, and cover retries, failures and duplicate jobs.

Short answer: To test BullMQ workers well, keep your job handler a plain function that knows nothing about BullMQ, and unit-test it with fakes. Then write a small number of integration tests against a real Redis to cover the queue behavior itself: retries, failures and duplicate job IDs. Use a unique queue name per test, wait on job.waitUntilFinished() instead of sleeping, and close everything afterwards.

Diagram of the test layers for BullMQ workers: unit tests on the handler, integration tests with real Redis, and staging checks

Queue code is awkward to test because the interesting behavior is spread across three places: your handler, Redis and the retry settings. A handler that passes its unit tests can still lose work if the retry configuration is wrong, and a retry configuration can look fine until a real failure exercises it. The approach below puts each concern in the cheapest layer that can actually test it. The examples use TypeScript and Vitest, but the structure works the same in Jest.

Split the handler from the worker

The single most useful change is to stop writing logic inside the Worker callback. Put the logic in a function that takes the job data and its dependencies as arguments, and let the worker be a thin wrapper around it.

// processors/sendInvoice.ts
export type InvoiceJob = { invoiceId: string; email: string };

export type Deps = {
  loadInvoice(id: string): Promise<{ id: string; total: number } | null>;
  sendEmail(to: string, subject: string, body: string): Promise<void>;
};

export async function sendInvoice(data: InvoiceJob, deps: Deps) {
  const invoice = await deps.loadInvoice(data.invoiceId);
  if (!invoice) {
    throw new Error('Invoice ' + data.invoiceId + ' not found');
  }
  await deps.sendEmail(data.email, 'Invoice ' + invoice.id, 'Total: ' + invoice.total);
  return { sent: true };
}

Because the function never touches Redis or the queue, you can call it directly in a test. It also keeps the worker file small enough that there is almost nothing in it left to get wrong.

Unit-test BullMQ workers with fakes

These tests are fast and need no infrastructure, so write plenty of them. Cover the happy path and every branch that throws.

// processors/sendInvoice.test.ts
import { describe, it, expect, vi } from 'vitest';
import { sendInvoice } from './sendInvoice';

describe('sendInvoice', () => {
  it('sends the email for an existing invoice', async () => {
    const deps = {
      loadInvoice: vi.fn().mockResolvedValue({ id: 'inv_1', total: 120 }),
      sendEmail: vi.fn().mockResolvedValue(undefined),
    };

    await expect(
      sendInvoice({ invoiceId: 'inv_1', email: 'a@example.com' }, deps),
    ).resolves.toEqual({ sent: true });
    expect(deps.sendEmail).toHaveBeenCalledTimes(1);
  });

  it('throws when the invoice does not exist', async () => {
    const deps = {
      loadInvoice: vi.fn().mockResolvedValue(null),
      sendEmail: vi.fn(),
    };

    await expect(
      sendInvoice({ invoiceId: 'missing', email: 'a@example.com' }, deps),
    ).rejects.toThrow('not found');
    expect(deps.sendEmail).not.toHaveBeenCalled();
  });
});

Throwing is how a handler tells BullMQ that an attempt failed, so asserting on the error is asserting on your retry behavior. If you are unsure which failures should throw, see Node.js error handling for queue systems and why unhandled promise rejections silently kill jobs.

Integration-test the queue against a real Redis

BullMQ keeps job state in Redis and moves jobs between states with Lua scripts that run inside Redis. A fake Redis does not exercise that behavior, so the tests that matter should talk to a real instance. Locally, docker run -p 6379:6379 redis:7 is enough.

Two details from the BullMQ documentation on connections are worth building into your setup. A Worker needs an ioredis connection with maxRetriesPerRequest set to null, and BullMQ throws if you pass it anything else. And a Queue and a Worker can share one connection, although the Worker still creates its own duplicate connection internally for blocking calls.

// queue.integration.test.ts
import { Queue, Worker, QueueEvents } from 'bullmq';
import IORedis from 'ioredis';
import { randomUUID } from 'node:crypto';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { sendInvoice } from './processors/sendInvoice';

const connection = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
  maxRetriesPerRequest: null,
});

describe('invoice queue', () => {
  let name: string;
  let queue: Queue;
  let events: QueueEvents;
  let worker: Worker | undefined;

  beforeEach(async () => {
    name = 'test-invoices-' + randomUUID();
    queue = new Queue(name, { connection });
    events = new QueueEvents(name, { connection });
    await events.waitUntilReady();
  });

  afterEach(async () => {
    await worker?.close();
    await events.close();
    await queue.obliterate({ force: true });
    await queue.close();
  });

  afterAll(async () => {
    await connection.quit();
  });

  it('processes a job and returns the handler result', async () => {
    const deps = {
      loadInvoice: async () => ({ id: 'inv_1', total: 120 }),
      sendEmail: async () => {},
    };
    worker = new Worker(name, (job) => sendInvoice(job.data, deps), { connection });

    const job = await queue.add('send', { invoiceId: 'inv_1', email: 'a@example.com' });

    await expect(job.waitUntilFinished(events)).resolves.toEqual({ sent: true });
  });
});

Three choices in that file keep it reliable. Each test gets its own queue name, so tests cannot see each other’s jobs. The test waits for the job through QueueEvents rather than sleeping for a guessed number of milliseconds. And afterEach closes the worker and events and then calls queue.obliterate({ force: true }), which removes the queue and its contents even if a job is still active.

Test BullMQ workers: retries and failures

This is where integration tests earn their cost. Whether a job retries, how long it waits and what happens after the last attempt all come from the options you pass to queue.add, and a unit test cannot see any of that. Use a short fixed backoff so the tests stay quick.

it('retries a transient failure and then succeeds', async () => {
  let calls = 0;
  worker = new Worker(
    name,
    async () => {
      calls += 1;
      if (calls === 1) throw new Error('temporary');
      return 'ok';
    },
    { connection },
  );

  const job = await queue.add('flaky', {}, {
    attempts: 3,
    backoff: { type: 'fixed', delay: 50 },
  });

  await expect(job.waitUntilFinished(events)).resolves.toBe('ok');
  expect(calls).toBe(2);
});

it('marks the job as failed after the last attempt', async () => {
  worker = new Worker(
    name,
    async () => {
      throw new Error('always fails');
    },
    { connection },
  );

  const job = await queue.add('doomed', {}, {
    attempts: 2,
    backoff: { type: 'fixed', delay: 50 },
  });

  await expect(job.waitUntilFinished(events)).rejects.toThrow('always fails');
  expect(await job.getState()).toBe('failed');
});

Together these tests pin down the two outcomes that matter in production: a transient error is retried, and a permanent one ends up in the failed set where you can see it instead of disappearing.

Test that BullMQ workers ignore duplicate jobs

If you rely on a custom jobId to avoid doing the same work twice, test it. Adding a job whose ID already exists in the queue does not create a second job, so a handler guarded this way runs once.

it('ignores a second job with the same jobId', async () => {
  const handler = vi.fn(async () => 'done');
  worker = new Worker(name, handler, { connection });

  const first = await queue.add('charge', { orderId: 42 }, { jobId: 'charge-order-42' });
  await queue.add('charge', { orderId: 42 }, { jobId: 'charge-order-42' });

  await first.waitUntilFinished(events);
  expect(handler).toHaveBeenCalledTimes(1);
});

The assertion holds whether the first job is still waiting or has already finished, as long as you have not configured removeOnComplete on it. A job ID only protects you for as long as the earlier job still exists, so if you remove completed jobs, keep a database-level guard as well.

What to test where

LayerWhat it coversNeeds RedisSpeed
Unit tests on the handlerBusiness logic, branches, which errors are thrownNoMilliseconds
Integration tests on the queueRetries, backoff, failed state, duplicate job IDs, job optionsYesFast, but each test waits on a real job
End-to-end or staging checksReal providers, real credentials, real latencyYes, plus the outside servicesSlowest, run sparingly

Run it in CI

Most CI systems can start Redis as a service container next to your tests. In GitHub Actions that looks like this:

services:
  redis:
    image: redis:7
    ports:
      - 6379:6379
    options: >-
      --health-cmd "redis-cli ping"
      --health-interval 5s
      --health-timeout 3s
      --health-retries 5

Point REDIS_URL at redis://127.0.0.1:6379 and the same tests run locally and in CI without changes.

Mistakes that make BullMQ workers hard to test

  • Sharing one queue name across tests. Leftover jobs from one test show up in the next. Use a unique name per test.
  • Sleeping instead of waiting. A fixed setTimeout is either too short and flaky or too long and slow. Wait on job.waitUntilFinished().
  • Forgetting to close connections. An open worker or Redis connection keeps the test process alive, so the run appears to hang after the tests pass.
  • Mocking BullMQ or Redis itself. The test then checks your mock, not the retry and state behavior you depend on.
  • Asserting on exact timing. Assert on outcomes such as call counts and final job state, not on how many milliseconds a backoff took.

Frequently Asked Questions

Should I mock BullMQ workers in unit tests?

Usually not. If the handler is a plain function you can test it without BullMQ at all, and for queue behavior a real Redis gives you the answer you actually need.

How many integration tests do I need?

A handful. One for the happy path, one for a retried failure, one for a permanent failure and one for each queue-level guarantee you depend on, such as duplicate job IDs or rate limits.

Why does my test run keep hanging after the tests pass?

Something is still open. Close the worker, the QueueEvents instance, the queue and finally the Redis connection in your teardown hooks.

If you are choosing between a queue library and a message broker, read BullMQ vs RabbitMQ for Node.js, and for the differences between the two BullMQ generations see Bull vs BullMQ. If you would like a second pair of eyes on a queue system that is losing jobs, see hiring a Node.js developer.

Faisal Nadeem

Written by Faisal Nadeem

Full-Stack & AI Integration Engineer — 6+ years of experience, 50+ projects delivered in Laravel, Vue.js, Node.js, ASP.NET Core, and production RAG/LLM integrations for SaaS products.

LinkedIn · GitHub

Leave a Reply

Your email address will not be published. Required fields are marked *

Join the Engineering Newsletter

Get deep dives into system design and scalability delivered to your inbox.

We respect your privacy. Unsubscribe at any time.