Serverless Queue Testing Patterns: Inngest vs Trigger.dev vs BullMQ Compared

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 on sentEvents for 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.

Read more