Serverless Queue Testing Patterns: Inngest vs Trigger.dev vs BullMQ Compared
Modern background job systems differ fundamentally in their execution model, and those differences shape how you test them. Inngest replays your entire function body on each step. Trigger.dev executes tasks as isolated units. BullMQ processes jobs as single-invocation functions with Redis-backed state. Getting your testing strategy wrong for each system leads to brittle tests that miss real failures.
This guide compares testing approaches across all three systems, explains why they differ, and gives you a framework for choosing the right pattern.
Execution Model Comparison
Understanding execution differences is the foundation for understanding testing differences:
| Feature | Inngest | Trigger.dev | BullMQ |
|---|---|---|---|
| Execution model | Replay-based (function reruns on each step) | Task isolation (each task = one invocation) | Single-invocation worker |
| Persistence | Managed by Inngest (HTTP callbacks) | Managed by Trigger.dev | Redis |
| Infrastructure | Zero (serverless) | Zero (serverless) | Requires Redis |
| Step mocking | mockSteps in test engine |
Vitest mocks on task internals | Mock Redis or use real Redis |
| Retry model | Per-step retry | Per-task retry | Per-job retry with backoff |
| Local dev | Inngest Dev Server | Trigger.dev CLI | Local Redis |
| Test SDK | @inngest/test |
@trigger.dev/testing |
No official test SDK |
Pattern 1: Testing Step/Task Isolation
All three systems benefit from testing business logic independently from the queue infrastructure.
Inngest
// Test step logic directly before wiring it into a function
async function chargeCustomer(customerId: string, amount: number) {
const customer = await stripe.customers.retrieve(customerId);
return stripe.paymentIntents.create({ customer: customer.id, amount, currency: "usd" });
}
it("creates payment intent with correct amount", async () => {
vi.spyOn(stripe.customers, "retrieve").mockResolvedValueOnce({ id: "cus_123" } as any);
const createSpy = vi.spyOn(stripe.paymentIntents, "create").mockResolvedValueOnce({ id: "pi_abc" } as any);
await chargeCustomer("cus_123", 5000);
expect(createSpy).toHaveBeenCalledWith({ customer: "cus_123", amount: 5000, currency: "usd" });
});
// Then test the Inngest function wiring with mocked step
const { result } = await t.execute({
events: [{ name: "order/paid", data: { customerId: "cus_123", amount: 5000 } }],
mockSteps: { "charge-customer": { id: "pi_abc" } },
});Trigger.dev
// Same business logic extraction
async function chargeCustomer(customerId: string, amount: number) {
// same implementation
}
// Test the task which calls the logic
it("charges customer successfully", async () => {
vi.spyOn(stripe.customers, "retrieve").mockResolvedValueOnce({ id: "cus_123" } as any);
vi.spyOn(stripe.paymentIntents, "create").mockResolvedValueOnce({ id: "pi_abc" } as any);
const result = await chargeTask.triggerAndWait({ customerId: "cus_123", amount: 5000 });
expect(result.ok).toBe(true);
expect(result.output.paymentIntentId).toBe("pi_abc");
});BullMQ
// Extract processor logic
async function chargeProcessor(job: Job) {
const { customerId, amount } = job.data;
return chargeCustomer(customerId, amount);
}
it("charges customer via job processor", async () => {
vi.spyOn(stripe.customers, "retrieve").mockResolvedValueOnce({ id: "cus_123" } as any);
vi.spyOn(stripe.paymentIntents, "create").mockResolvedValueOnce({ id: "pi_abc" } as any);
const fakeJob = { data: { customerId: "cus_123", amount: 5000 } } as Job;
const result = await chargeProcessor(fakeJob);
expect(result.id).toBe("pi_abc");
});Verdict: All three support this pattern equally. Extract logic into testable functions regardless of which queue you use.
Pattern 2: Testing Retry Behavior
Retry semantics differ significantly across the three systems.
Inngest — Per-Step Retry
Inngest retries individual steps, not the entire function. A failed step doesn't restart steps that already succeeded:
it("retries only the failed step, not the whole function", async () => {
const stepCallCounts: Record<string, number> = {};
const { result } = await t.execute({
events: [{ name: "order/created", data: { orderId: "ord_1" } }],
mockSteps: {
"fetch-order": (invocation) => {
stepCallCounts["fetch-order"] = (stepCallCounts["fetch-order"] ?? 0) + 1;
return { id: "ord_1" }; // succeeds first time
},
"charge-payment": (invocation) => {
stepCallCounts["charge-payment"] = (stepCallCounts["charge-payment"] ?? 0) + 1;
if (stepCallCounts["charge-payment"] === 1) throw new Error("Gateway timeout");
return { chargeId: "ch_abc" };
},
"send-confirmation": { messageId: "msg_1" },
},
});
// fetch-order should only be called once (memoized across replays)
expect(stepCallCounts["fetch-order"]).toBe(1);
// charge-payment retried once
expect(stepCallCounts["charge-payment"]).toBe(2);
});Trigger.dev — Per-Task Retry
Trigger.dev retries the entire task, not individual steps within it. If a task fails halfway through, it restarts from the beginning:
it("restarts entire task on failure", async () => {
let taskRunCount = 0;
// Mock the task's dependencies
const fetchOrder = vi.spyOn(orderService, "fetch")
.mockResolvedValue({ id: "ord_1" });
const charge = vi.spyOn(paymentService, "charge")
.mockImplementationOnce(() => { taskRunCount++; throw new Error("Gateway timeout"); })
.mockResolvedValueOnce(() => { taskRunCount++; return { chargeId: "ch_abc" }; });
const result = await processOrderTask.triggerAndWait({ orderId: "ord_1" });
// fetchOrder called TWICE — once per task run (no memoization)
expect(fetchOrder).toHaveBeenCalledTimes(2);
expect(taskRunCount).toBe(2);
expect(result.ok).toBe(true);
});BullMQ — Per-Job Retry
BullMQ retries the entire job processor function. No step-level memoization:
it("retries job processor on failure", async () => {
const queue = new Queue("payments", { connection });
const processorCallCount = { count: 0 };
const worker = new Worker(
"payments",
async (job) => {
processorCallCount.count++;
if (processorCallCount.count === 1) throw new Error("Transient failure");
return { processed: true };
},
{ connection }
);
const job = await queue.add("process", { orderId: "ord_1" }, { attempts: 3, backoff: 100 });
await waitForJobCompletion(job, 5000);
expect(processorCallCount.count).toBe(2); // Failed once, succeeded on retry
});Verdict: Inngest's per-step retry is the most efficient (no repeated work) but requires idempotency only at the step level. Trigger.dev and BullMQ require the entire handler to be idempotent.
Pattern 3: Testing Fanout / Parallel Execution
All three support fanout (spawning multiple parallel tasks), but the testing approach differs.
Inngest — step.sendEvent for Fanout
const fanoutFunction = inngest.createFunction(
{ id: "process-batch" },
{ event: "batch/created" },
async ({ event, step }) => {
const items = event.data.items;
await step.sendEvent(
"dispatch-items",
items.map((item: unknown, i: number) => ({
name: "item/process",
data: { item, index: i },
}))
);
return { dispatched: items.length };
}
);
it("dispatches one event per item", async () => {
const { sentEvents } = await t.execute({
events: [{ name: "batch/created", data: { items: ["a", "b", "c"] } }],
mockSteps: { "dispatch-items": undefined },
});
expect(sentEvents).toHaveLength(3);
expect(sentEvents.map((e) => e.name)).toEqual(["item/process", "item/process", "item/process"]);
expect(sentEvents.map((e) => e.data.item)).toEqual(["a", "b", "c"]);
});Trigger.dev — batchTrigger for Fanout
export const processBatch = task({
id: "process-batch",
run: async (payload: { items: string[] }) => {
const runs = await processItem.batchTrigger(
payload.items.map((item, index) => ({ payload: { item, index } }))
);
return { dispatched: runs.length };
},
});
it("dispatches batch of item tasks", async () => {
const batchSpy = vi.spyOn(processItem, "batchTrigger").mockResolvedValueOnce([
{ id: "run_1" }, { id: "run_2" }, { id: "run_3" },
] as any);
const result = await processBatch.triggerAndWait({ items: ["a", "b", "c"] });
expect(batchSpy).toHaveBeenCalledWith([
{ payload: { item: "a", index: 0 } },
{ payload: { item: "b", index: 1 } },
{ payload: { item: "c", index: 2 } },
]);
expect(result.output.dispatched).toBe(3);
});BullMQ — addBulk for Fanout
export async function processBatch(items: string[]) {
const queue = new Queue("item-processing", { connection });
const jobs = await queue.addBulk(
items.map((item, index) => ({
name: "process-item",
data: { item, index },
}))
);
return { dispatched: jobs.length };
}
it("adds one job per item in batch", async () => {
const addBulkSpy = vi.spyOn(Queue.prototype, "addBulk").mockResolvedValueOnce([
{ id: "job_1" }, { id: "job_2" }, { id: "job_3" },
] as any);
const result = await processBatch(["a", "b", "c"]);
expect(addBulkSpy).toHaveBeenCalledWith([
expect.objectContaining({ data: { item: "a", index: 0 } }),
expect.objectContaining({ data: { item: "b", index: 1 } }),
expect.objectContaining({ data: { item: "c", index: 2 } }),
]);
expect(result.dispatched).toBe(3);
});Pattern 4: Testing Idempotency
All background job systems guarantee at-least-once delivery. Test that your handlers are idempotent:
// Universal idempotency test pattern
async function assertIdempotent<T>(
handler: () => Promise<T>,
sideEffectSpy: ReturnType<typeof vi.fn>,
expectedSideEffectCount = 1
) {
await handler();
await handler(); // Second call with same payload
expect(sideEffectSpy).toHaveBeenCalledTimes(expectedSideEffectCount);
}
// For all three systems, the pattern is:
// 1. Check if effect already applied (DB lookup, cache check)
// 2. Apply only if not already done
// 3. Return success either way
async function idempotentOrderFulfillment(orderId: string) {
const alreadyFulfilled = await db.orders.findOne({ id: orderId, status: "fulfilled" });
if (alreadyFulfilled) return { orderId, status: "already-fulfilled" };
await fulfillmentService.fulfill(orderId);
await db.orders.update({ id: orderId }, { status: "fulfilled" });
return { orderId, status: "fulfilled" };
}
it("fulfills order only once on duplicate delivery", async () => {
const fulfillSpy = vi.spyOn(fulfillmentService, "fulfill").mockResolvedValue({});
vi.spyOn(db.orders, "findOne")
.mockResolvedValueOnce(null) // Not fulfilled on first call
.mockResolvedValueOnce({ id: "ord_1", status: "fulfilled" }); // Already fulfilled on second
await idempotentOrderFulfillment("ord_1");
await idempotentOrderFulfillment("ord_1");
expect(fulfillSpy).toHaveBeenCalledTimes(1); // Only called once
});Choosing the Right System (and Test Strategy)
| Scenario | Recommended System | Test Focus |
|---|---|---|
| Serverless (Vercel, Cloudflare) | Inngest or QStash | Step mocking, event dispatch |
| Long-running tasks (>10 min) | Trigger.dev | Task isolation, subtask coordination |
| Redis already in stack | BullMQ | Real Redis integration, worker lifecycle |
| High throughput (>10k/s) | BullMQ | Rate limiting, throughput benchmarks |
| Complex orchestration | Inngest or Trigger.dev | Flow testing, child job results |
| Simple delay/retry | Any | Retry count assertions, delay values |
Integration Test Strategy
Regardless of which system you use, integration tests follow the same structure:
// 1. Start local infrastructure (Redis for BullMQ, dev server for Inngest/Trigger.dev)
// 2. Enqueue/trigger a job
// 3. Poll for completion with a timeout
// 4. Assert on side effects (database state, sent events, published messages)
// 5. Assert on return value
async function pollUntil<T>(
check: () => Promise<T | null>,
timeoutMs: number
): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await check();
if (result !== null) return result;
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Timed out after ${timeoutMs}ms`);
}
// Works for all three systems
it("end-to-end: order triggers fulfillment and notification", async () => {
await triggerOrderCreated("ord_e2e_001");
const order = await pollUntil(
() => db.orders.findOne({ id: "ord_e2e_001", status: "fulfilled" }),
30_000
);
expect(order?.status).toBe("fulfilled");
const email = await pollUntil(
() => emailLog.findOne({ orderId: "ord_e2e_001" }),
10_000
);
expect(email?.subject).toContain("Order Confirmed");
});Monitoring in Production with HelpMeTest
No matter which queue system you choose, HelpMeTest provides end-to-end monitoring to verify your background jobs complete successfully in production:
*** Test Cases ***
Verify Order Processing Pipeline
# Trigger an order and verify all downstream effects
${order_id}= Create Test Order amount=100
Wait Until Keyword Succeeds 2 min 5 sec
... Verify Order Status ${order_id} fulfilled
Verify Email Received order-confirmed ${order_id}
Verify Inventory Updated ${order_id}HelpMeTest's continuous testing ensures your queue pipelines — whether Inngest, Trigger.dev, or BullMQ — work correctly after every deployment.
Summary
Key differences in testing across the three systems:
- Inngest: Use
@inngest/test+mockSteps; test replay safety by verifying step call counts; assert onsentEventsfor fanout - Trigger.dev: Use
@trigger.dev/testing+triggerAndWait; mock with Vitest spies; remember entire task restarts on retry - BullMQ: No official test SDK; use real Redis (Testcontainers) or
ioredis-mock; test worker lifecycle events directly
The universal patterns — logic isolation, idempotency testing, integration polling — apply to all three. Master those first, then layer in system-specific techniques.