Testing and Development Workflows for BullMQ vs BeeQueue
Every developer who builds a Redis-backed job queue eventually faces the same gap: code examples for setting up queues and workers are plentiful, but practical guidance on testing queue-based code is rare. Testing a job queue involves more than mocking a Redis client — you need to verify job enqueueing, simulate worker failures, test retry behavior, and ensure graceful shutdown, all without polluting your production Redis instance.
This post compares the testing and development workflow experience for BullMQ and BeeQueue, the two dominant Redis-backed queue libraries for Node.js. We'll cover test harness setup, producer and worker testing patterns, local development workflows, and CI integration — with complete, copy-pasteable TypeScript examples for each section.
Test Infrastructure: Setting Up Harnesses for Both Libraries
The foundation of any queue test suite is the test harness — the setup and teardown code that creates isolated queues and workers for each test case. Both BullMQ and BeeQueue support in-memory Redis via ioredis-mock and real Redis via Docker containers, but their API surfaces require different fixture patterns.
BullMQ Test Fixtures
BullMQ separates queue, worker, and queue events into distinct classes. A test harness needs to create all three and properly close each on teardown:
import { Queue, Worker, QueueEvents, QueueScheduler } from "bullmq";
import Redis from "ioredis";
import { v4 as uuid } from "uuid";
interface BullMQTestContext {
queue: Queue;
worker: Worker;
queueEvents: QueueEvents;
connection: Redis;
cleanup: () => Promise<void>;
}
async function createBullMQTestContext<T = any>(
processor: (job: any) => Promise<T>,
opts?: { concurrency?: number }
): Promise<BullMQTestContext> {
const queueName = `test-queue-${uuid()}`;
const connection = new Redis({
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT || "6379", 10),
maxRetriesPerRequest: null,
});
const queue = new Queue(queueName, { connection });
const queueEvents = new QueueEvents(queueName, { connection });
const worker = new Worker(queueName, processor, {
connection,
concurrency: opts?.concurrency ?? 1,
});
await worker.waitUntilReady();
await queueEvents.waitUntilReady();
const cleanup = async () => {
await worker.close();
await queueEvents.close();
await queue.close();
await connection.quit();
};
return { queue, worker, queueEvents, connection, cleanup };
}
Key points about this fixture:
- Unique queue names via UUID prevent test isolation collisions when running tests in parallel.
- Connection reuse — all three BullMQ classes share the same
ioredisconnection. maxRetriesPerRequest: nullis required for reliable worker operation in test environments.waitUntilReady()ensures the worker and events listener are fully initialized before tests begin.- Cleanup closes all three components and the connection — omitting even one can leave dangling Redis connections that exhaust your connection pool.
BeeQueue Test Fixtures
BeeQueue's API is simpler — a single Queue class handles both producer and consumer roles. The fixture is correspondingly shorter:
import Queue from "bee-queue";
import Redis from "ioredis";
interface BeeQueueTestContext {
queue: Queue;
cleanup: () => Promise<void>;
}
function createBeeQueueTestContext<T = any>(
processor?: (job: any, done: any) => void
): BeeQueueTestContext {
const queueName = `test-queue-${uuid()}`;
const queue = new Queue(queueName, {
redis: {
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT || "6379", 10),
},
isWorker: !!processor,
activateQueue: true,
});
if (processor) {
queue.process(processor);
}
const cleanup = async () => {
await new Promise<void>((resolve) => queue.close(() => resolve()));
};
return { queue, cleanup };
}
Key differences from the BullMQ fixture:
- BeeQueue's
Queueconstructor accepts the Redis configuration directly — no need to create a separate connection instance. - The
isWorkerflag controls whether this queue instance processes jobs or only produces them. - BeeQueue uses callback-based
queue.close()— wrapping it in a promise is necessary forasync/awaitteardown.
Choosing Between ioredis-mock and Real Redis for Tests
| Approach | BullMQ | BeeQueue |
|---|---|---|
| ioredis-mock | Works for basic enqueue tests; fails on Lua scripts, BRPOPLPUSH, and blocking commands |
Not compatible — BeeQueue relies on BRPOP and Lua scripts internally |
| Testcontainers | Fully compatible but adds ~3s container startup per test suite | Recommended approach for reliable tests |
| Shared local Redis | Fastest but requires test isolation via unique keyspaces or queue names | Works well with unique queue names |
For BullMQ, ioredis-mock is suitable for unit tests that only verify job enqueueing. For integration tests — especially those testing worker behavior, retries, or stalled job detection — you need a real Redis instance. BeeQueue cannot use ioredis-mock at all due to its dependency on Redis blocking operations.
A practical test configuration reads the Redis connection from environment variables, defaulting to localhost:6379:
function getRedisConfig() {
return {
host: process.env.CI_REDIS_HOST || process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.CI_REDIS_PORT || process.env.REDIS_PORT || "6379", 10),
};
}
This lets CI pipelines use a Redis service container while developers use their local Redis.
Unit Testing Queue Producers
Testing the code that enqueues jobs (producers) is the most common testing scenario. The goal is to verify that jobs are created with the correct data, options, and queue name — without actually processing them.
Testing BullMQ Producers
With BullMQ, you can inject a real queue connected to a test Redis instance and verify job counts or listen for events:
import { describe, it, expect, beforeAll, afterAll } from "vitest";
interface EmailJob {
to: string;
subject: string;
body: string;
}
async function sendWelcomeEmail(
queue: Queue,
user: { email: string; name: string }
) {
const job: EmailJob = {
to: user.email,
subject: `Welcome, ${user.name}!`,
body: `Thanks for signing up, ${user.name}.`,
};
await queue.add("email", job, {
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
});
}
describe("BullMQ email producer", () => {
let ctx: BullMQTestContext;
beforeAll(async () => {
ctx = await createBullMQTestContext(async () => {});
});
afterAll(async () => {
await ctx.cleanup();
});
it("enqueues an email job with correct data", async () => {
await sendWelcomeEmail(ctx.queue, {
email: "alice@example.com",
name: "Alice",
});
const counts = await ctx.queue.getJobCounts();
expect(counts.waiting).toBe(1);
const job = await ctx.queue.getJob("email", 0);
expect(job).not.toBeNull();
expect(job!.data).toEqual({
to: "alice@example.com",
subject: "Welcome, Alice!",
body: "Thanks for signing up, Alice.",
});
});
it("applies retry options to the enqueued job", async () => {
await sendWelcomeEmail(ctx.queue, {
email: "bob@example.com",
name: "Bob",
});
const job = await ctx.queue.getWaitingJob(0);
expect(job!.opts.attempts).toBe(3);
expect(job!.opts.backoff).toEqual({
type: "exponential",
delay: 2000,
});
});
});
The getJobCounts() and getJob() methods give you full visibility into what was enqueued. You can verify job data, options, queue membership, and even job state transitions.
Testing BeeQueue Producers
BeeQueue's API for enqueuing and inspecting jobs is different — jobs are created via createJob().save() and retrieved via getJob():
import { describe, it, expect, beforeAll, afterAll } from "vitest";
interface EmailJob {
to: string;
subject: string;
body: string;
}
async function sendWelcomeEmailBee(
queue: Queue,
user: { email: string; name: string }
) {
const jobData: EmailJob = {
to: user.email,
subject: `Welcome, ${user.name}!`,
body: `Thanks for signing up, ${user.name}.`,
};
const job = queue.createJob(jobData);
job.retries(3).backoff("exponential", 2000).save();
return job;
}
describe("BeeQueue email producer", () => {
let ctx: BeeQueueTestContext;
beforeAll(async () => {
ctx = createBeeQueueTestContext();
});
afterAll(async () => {
await ctx.cleanup();
});
it("enqueues a job with correct data", async () => {
const job = await sendWelcomeEmailBee(ctx.queue, {
email: "alice@example.com",
name: "Alice",
});
expect(job.id).toBeDefined();
const retrieved = await ctx.queue.getJob(job.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.data).toEqual({
to: "alice@example.com",
subject: "Welcome, Alice!",
body: "Thanks for signing up, Alice.",
});
});
it("only retrieves jobs by ID", async () => {
await sendWelcomeEmailBee(ctx.queue, {
email: "bob@example.com",
name: "Bob",
});
const job = await ctx.queue.getJob("1");
expect(job).not.toBeNull();
expect(job!.data.to).toBe("bob@example.com");
});
});
BeeQueue's API for job retrieval is more limited — you can only get jobs by their integer ID. There's no equivalent of BullMQ's getJob(name, id) or getWaitingJob(). This makes assertion patterns less expressive but the simpler API means fewer things can go wrong.
Integration Testing Workers
Integration tests for worker processors need to verify correct behavior across the full job lifecycle: processing, returning results, handling failures, and respecting retry configurations.
Testing BullMQ Workers
BullMQ worker processors return a value that becomes the job's return value. Testing this requires waiting for the processor to complete and then inspecting the job's state:
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Job } from "bullmq";
async function processPayment(job: Job) {
const { amount, currency } = job.data;
if (amount <= 0) {
throw new Error("Amount must be positive");
}
if (currency === "EUR") {
// Simulate a currency conversion
return { status: "completed", convertedAmount: amount * 1.08, currency: "USD" };
}
return { status: "completed", convertedAmount: amount, currency };
}
describe("BullMQ payment worker", () => {
let ctx: BullMQTestContext;
beforeAll(async () => {
ctx = await createBullMQTestContext(processPayment);
});
afterAll(async () => {
await ctx.cleanup();
});
it("processes a job successfully and returns a result", async () => {
const job = await ctx.queue.add("payment", {
amount: 100,
currency: "USD",
});
const result = await job.waitUntilFinished(ctx.queueEvents, 5000);
expect(result).toEqual({
status: "completed",
convertedAmount: 100,
currency: "USD",
});
});
it("processes currency conversion correctly", async () => {
const job = await ctx.queue.add("payment", {
amount: 50,
currency: "EUR",
});
const result = await job.waitUntilFinished(ctx.queueEvents, 5000);
expect(result.convertedAmount).toBe(54);
expect(result.currency).toBe("USD");
});
it("handles job failure on invalid data", async () => {
const job = await ctx.queue.add("payment", {
amount: -5,
currency: "USD",
});
await expect(
job.waitUntilFinished(ctx.queueEvents, 5000)
).rejects.toThrow("Amount must be positive");
const failedJob = await ctx.queue.getJob(job.id!);
expect(failedJob?.failedReason).toContain("Amount must be positive");
});
it("retries a job that fails with retry option", async () => {
let attemptCount = 0;
const retryCtx = await createBullMQTestContext(async (job) => {
attemptCount++;
if (attemptCount < 2) {
throw new Error("Temporary failure");
}
return { status: "completed" };
});
await retryCtx.queue.add("retry-payment", { amount: 10 }, {
attempts: 3,
backoff: { type: "fixed", delay: 100 },
});
// Wait for the second (successful) attempt
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(attemptCount).toBe(2);
await retryCtx.cleanup();
});
});
The job.waitUntilFinished() method is BullMQ's most powerful testing tool — it blocks until the job completes or fails, returning the result value or throwing the error. This eliminates polling in tests and makes assertions straightforward.
Testing BeeQueue Workers
BeeQueue processors use callback-based completion (done(error, result)) rather than return values. Testing requires alternative patterns:
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Queue from "bee-queue";
function processPaymentBee(job: any, done: (err?: Error, result?: any) => void) {
const { amount, currency } = job.data;
if (amount <= 0) {
return done(new Error("Amount must be positive"));
}
if (currency === "EUR") {
return done(null, {
status: "completed",
convertedAmount: amount * 1.08,
currency: "USD",
});
}
done(null, { status: "completed", convertedAmount: amount, currency });
}
function waitForBeeJobCompletion(
jobId: string,
queue: Queue,
timeoutMs: number = 5000
): Promise<any> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const poll = async () => {
const job = await queue.getJob(jobId);
if (!job) {
if (Date.now() - startTime > timeoutMs) {
return reject(new Error("Timeout waiting for job completion"));
}
return setTimeout(poll, 100);
}
const status = job.status;
if (status === "completed") {
resolve(job.result);
} else if (status === "failed") {
reject(new Error(job.failedReason || "Job failed"));
} else {
if (Date.now() - startTime > timeoutMs) {
return reject(new Error("Timeout waiting for job to complete"));
}
setTimeout(poll, 100);
}
};
poll();
});
}
describe("BeeQueue payment worker", () => {
let ctx: BeeQueueTestContext;
beforeAll(async () => {
ctx = createBeeQueueTestContext(processPaymentBee);
});
afterAll(async () => {
await ctx.cleanup();
});
it("processes a job successfully", async () => {
const job = ctx.queue.createJob({ amount: 100, currency: "USD" });
await job.save();
const result = await waitForBeeJobCompletion(job.id, ctx.queue);
expect(result).toEqual({
status: "completed",
convertedAmount: 100,
currency: "USD",
});
});
it("handles job failure on invalid data", async () => {
const job = ctx.queue.createJob({ amount: -5, currency: "USD" });
await job.save();
await expect(
waitForBeeJobCompletion(job.id, ctx.queue)
).rejects.toThrow("Amount must be positive");
});
});
The polling-based waitForBeeJobCompletion() function is necessary because BeeQueue lacks BullMQ's event-driven waitUntilFinished(). This is a meaningful ergonomic difference — BullMQ tests are more concise and deterministic, while BeeQueue tests need polling loops with explicit timeouts.
Local Development Workflow
The differences between BullMQ and BeeQueue extend beyond test code into the daily development experience.
Hot-Reload with Workers
BullMQ workers run as long-lived Node.js processes. When you change worker code during development, the running process doesn't pick up the change. Use tsx or ts-node-dev with --watch to auto-restart:
{
"scripts": {
"dev:worker": "tsx watch src/workers/payment-worker.ts",
"dev:api": "tsx watch src/api/server.ts"
}
}
The tsx watch command monitors file changes and restarts the process. For BeeQueue, the same approach works — workers are Node.js processes that restart on file changes.
Docker Compose for Local Queue Development
Both libraries benefit from a consistent local Redis setup. A Docker Compose file shared between developers ensures the same Redis version and configuration:
version: "3.8"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
queue-dashboard:
image: your-queuehub-image
ports:
- "3000:3000"
environment:
REDIS_URL: redis://redis:6379
depends_on:
redis:
condition: service_healthy
Debugging Queue State During Development
Both libraries store jobs in Redis, and inspecting them directly can reveal issues faster than log-based debugging:
BullMQ — Jobs are stored in Redis lists and sorted sets keyed by state. Use Redis CLI to inspect:
# List all BullMQ queue keys
redis-cli --scan --pattern 'bull:*'
# View waiting jobs in a queue
redis-cli lrange bull:my-queue:wait 0 -1
# Get job details
redis-cli hgetall bull:my-queue:1
# Count jobs by state
redis-cli zcard bull:my-queue:active
redis-cli zcard bull:my-queue:completed
BeeQueue — Jobs are stored as Redis hashes with a simpler key structure:
# List all BeeQueue keys
redis-cli --scan --pattern 'bq:*'
# View jobs in a queue
redis-cli keys "bq:my-queue:job:*"
# Get job details
redis-cli hgetall bq:my-queue:job:1
# Check queue metadata
redis-cli hgetall bq:my-queue:settings
For a more visual debugging experience, QueueHub connects to your local Redis and shows live queue state — job counts, worker activity, and individual job details — without any instrumentation code.
Structured Logging for Queue Operations
In development, adding structured logging to workers helps trace job execution:
// BullMQ with pino
import pino from "pino";
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
async function workerProcessor(job: Job) {
const childLogger = logger.child({
jobId: job.id,
queue: job.queueName,
attempt: job.attemptsMade,
});
childLogger.info({ data: job.data }, "Processing job");
try {
const result = await processJob(job.data);
childLogger.info({ result }, "Job completed successfully");
return result;
} catch (err) {
childLogger.error({ err }, "Job failed");
throw err;
}
}
CI/CD Integration
Running queue tests in CI requires a Redis service container. Here's a complete GitHub Actions workflow that tests code using both BullMQ and BeeQueue:
name: Queue Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: Run BullMQ tests
run: npm run test:bq
env:
REDIS_HOST: localhost
REDIS_PORT: 6379
- name: Run BeeQueue tests
run: npm run test:bee
env:
REDIS_HOST: localhost
REDIS_PORT: 6379
Test Isolation in CI
When running tests in parallel CI jobs, each test suite must use unique queue names to prevent interference:
const TEST_PREFIX = `test-${process.env.CI_JOB_ID || process.pid}-`;
function getTestQueueName(base: string): string {
return `${TEST_PREFIX}${base}`;
}
This ensures that even if multiple CI jobs run simultaneously against the same Redis instance, their queue keys won't collide.
Summary: Testing and Dev Workflow Comparison
| Aspect | BullMQ | BeeQueue |
|---|---|---|
| Test fixtures | Three classes (Queue, Worker, QueueEvents) — more setup, more control | Single Queue class — simpler but less granular control |
| Producer testing | Rich introspection: getJob(name, id), getWaitingJob(), getJobCounts() |
Limited to getJob(id) — less expressive assertions |
| Worker testing | job.waitUntilFinished() — elegant, deterministic |
Polling-based — requires waitForBeeJobCompletion() helper |
| ioredis-mock support | Works for unit tests | Not compatible (uses blocking Redis commands) |
| Debugging tools | bull:* key prefix, structured key layout |
bq:* key prefix, simpler key structure |
| Test ergonomics | Strong — event-driven completion detection | Weaker — requires polling patterns |
Choosing between BullMQ and BeeQueue for your project involves tradeoffs beyond feature lists. BullMQ's richer API gives you more powerful testing tools — waitUntilFinished(), job state introspection, and ioredis-mock compatibility — at the cost of a steeper learning curve and more verbose setup. BeeQueue's simpler API means fewer concepts to learn, but you'll need to build your own test infrastructure (polling helpers, custom assertions) and cannot use Redis mocks.
Whichever library you choose, seeing what's happening inside your queues during development is essential. QueueHub connects to your local or cloud Redis and provides real-time visibility into job states, worker health, and queue metrics — for both BullMQ and BeeQueue — with zero configuration. Try it free at queuehub.tech and see your queues come to life.
Related Articles
BeeQueue Production Patterns and Redis Internals: Beyond the Basics
Go beyond basic BeeQueue setup. Master Redis key internals, Prometheus health checks, dead letter queues, graceful shutdown, batch processing, and benchmarking methodology.
Beyond Static Priority — Building a Dynamic Priority Escalation Engine with BullMQ
Learn how to build a dynamic priority escalation engine for BullMQ with three production-ready patterns: configurable escalation rules, multi-factor priority scoring, and priority-weighted worker pool routing with complete TypeScript code.
BullMQ vs BeeQueue: Operational Realities in Production
A no-fluff comparison of BullMQ and BeeQueue through the lens of production operations — deployment patterns, monitoring stacks, debugging real failures, metrics export, upgrade strategies, and the DevOps workflows each library supports (or doesn't).