Inngest Function Testing: Step Mocking, Event Dispatch, and Retry Logic

Inngest Function Testing: Step Mocking, Event Dispatch, and Retry Logic

Inngest is a serverless background job platform that runs durable functions via event triggers. Unlike traditional job queues, Inngest functions are composable: each step.run is a discrete, retriable unit. Testing this architecture requires understanding how Inngest executes functions and where your logic actually lives.

How Inngest Executes Functions

Inngest re-runs your entire function body on each step completion, passing in previous step results. This means:

  • Pure step logic executes once and is memoized
  • Side effects outside step.run can run multiple times
  • Your test strategy must account for this replay behavior

Understanding this prevents the most common testing mistake: asserting on code that runs during replay rather than in the step itself.

Setting Up Inngest for Testing

Inngest provides @inngest/test for unit testing functions without a live server:

import { InngestTestEngine } from "@inngest/test";
import { inngest } from "./client";
import { sendWelcomeEmail } from "./functions/welcome";

const t = new InngestTestEngine({
  function: sendWelcomeEmail,
});

The test engine intercepts step calls, allowing you to control their outcomes without real I/O.

Testing Step Execution

The core pattern is mockSteps — you define what each named step returns, and the test engine injects those values during the replay:

import { InngestTestEngine } from "@inngest/test";
import { processOrder } from "./functions/processOrder";

const t = new InngestTestEngine({
  function: processOrder,
});

it("processes order successfully", async () => {
  const { result } = await t.execute({
    events: [
      {
        name: "order/created",
        data: { orderId: "ord_123", userId: "usr_456", total: 99.99 },
      },
    ],
    mockSteps: {
      "fetch-order": { id: "ord_123", items: [{ sku: "WIDGET", qty: 2 }] },
      "charge-payment": { chargeId: "ch_abc", status: "succeeded" },
      "send-confirmation": { messageId: "msg_xyz" },
    },
  });

  expect(result).toEqual({ orderId: "ord_123", status: "processed" });
});

Each key in mockSteps corresponds to the step ID you pass to step.run. If a step ID isn't in mockSteps, the actual function runs — useful for testing real logic while mocking I/O.

Testing Event Dispatch

Inngest functions often send events to trigger other functions. Test that the right events are dispatched with the right payloads:

it("dispatches notification event after processing", async () => {
  const { sentEvents } = await t.execute({
    events: [
      {
        name: "order/created",
        data: { orderId: "ord_123", userId: "usr_456", total: 99.99 },
      },
    ],
    mockSteps: {
      "fetch-order": { id: "ord_123", items: [] },
      "charge-payment": { chargeId: "ch_abc", status: "succeeded" },
      "send-confirmation": { messageId: "msg_xyz" },
    },
  });

  expect(sentEvents).toContainEqual(
    expect.objectContaining({
      name: "notification/order-confirmed",
      data: expect.objectContaining({
        orderId: "ord_123",
        userId: "usr_456",
      }),
    })
  );
});

sentEvents captures everything passed to step.sendEvent during execution — no real Inngest server required.

Testing Retry Logic

Inngest retries failed steps automatically. Test retry behavior by making steps throw on the first call and succeed on subsequent calls:

it("retries payment on transient failure", async () => {
  let callCount = 0;

  const { result } = await t.execute({
    events: [{ name: "order/created", data: { orderId: "ord_123" } }],
    mockSteps: {
      "fetch-order": { id: "ord_123" },
      "charge-payment": () => {
        callCount++;
        if (callCount === 1) {
          throw new Error("Payment gateway timeout");
        }
        return { chargeId: "ch_abc", status: "succeeded" };
      },
      "send-confirmation": { messageId: "msg_xyz" },
    },
  });

  expect(callCount).toBe(2);
  expect(result.status).toBe("processed");
});

When a mock step throws, InngestTestEngine treats it as a real step failure and applies Inngest's retry logic.

Testing Conditional Steps

Functions with branching logic need tests for each branch:

const fulfillOrder = inngest.createFunction(
  { id: "fulfill-order" },
  { event: "order/paid" },
  async ({ event, step }) => {
    const inventory = await step.run("check-inventory", async () => {
      return checkInventoryService(event.data.items);
    });

    if (inventory.available) {
      await step.run("ship-order", async () => {
        return shipmentService.create(event.data.orderId);
      });
      return { status: "shipped" };
    } else {
      await step.run("notify-backorder", async () => {
        return notifyBackorder(event.data.orderId);
      });
      return { status: "backordered" };
    }
  }
);

// Test the "in stock" path
it("ships when inventory is available", async () => {
  const { result } = await t.execute({
    events: [{ name: "order/paid", data: { orderId: "ord_123", items: ["WIDGET"] } }],
    mockSteps: {
      "check-inventory": { available: true },
      "ship-order": { trackingId: "TRK_001" },
    },
  });
  expect(result.status).toBe("shipped");
});

// Test the "out of stock" path
it("backlogs when inventory is unavailable", async () => {
  const { result } = await t.execute({
    events: [{ name: "order/paid", data: { orderId: "ord_123", items: ["WIDGET"] } }],
    mockSteps: {
      "check-inventory": { available: false },
      "notify-backorder": { notified: true },
    },
  });
  expect(result.status).toBe("backordered");
});

Testing Sleep and Wait Steps

step.sleep and step.waitForEvent pause execution for a duration or until an event arrives. In tests, these are mocked automatically — you don't need to wait real time:

const followUpEmail = inngest.createFunction(
  { id: "follow-up-email" },
  { event: "user/signed-up" },
  async ({ event, step }) => {
    await step.sleep("wait-3-days", "3d");

    const didConvert = await step.waitForEvent("check-conversion", {
      event: "user/subscribed",
      timeout: "7d",
      match: "data.userId",
    });

    if (!didConvert) {
      await step.run("send-nudge", async () => {
        return sendNudgeEmail(event.data.userId);
      });
    }

    return { sent: !didConvert };
  }
);

it("sends nudge email when user does not convert", async () => {
  const { result } = await t.execute({
    events: [{ name: "user/signed-up", data: { userId: "usr_123" } }],
    mockSteps: {
      "wait-3-days": undefined,
      "check-conversion": null, // null = waitForEvent timed out
      "send-nudge": { success: true },
    },
  });
  expect(result.sent).toBe(true);
});

Passing null for a waitForEvent mock simulates timeout — the function receives null and your conditional logic proceeds accordingly.

Integration Testing with a Local Dev Server

For integration tests that need real Inngest execution, run the Inngest Dev Server locally:

npx inngest-cli@latest dev

Then configure your app to use the local server in tests:

// test/setup.ts
process.env.INNGEST_BASE_URL = "http://localhost:8288";
process.env.INNGEST_EVENT_KEY = "local";

// Register your functions
import { serve } from "inngest/express";
import { inngest } from "../src/client";
import * as functions from "../src/functions";

const app = express();
app.use("/api/inngest", serve({ client: inngest, functions: Object.values(functions) }));
app.listen(3000);

Then trigger events and poll for completion:

import { Inngest } from "inngest";

const testClient = new Inngest({ id: "test-client", eventKey: "local" });

it("processes order end-to-end", async () => {
  const { ids } = await testClient.send({
    name: "order/created",
    data: { orderId: "ord_e2e_123", userId: "usr_456", total: 50 },
  });

  // Poll for run completion
  const runId = ids[0];
  let run;
  for (let i = 0; i < 30; i++) {
    run = await fetch(`http://localhost:8288/v1/runs/${runId}`).then((r) => r.json());
    if (run.status === "Completed") break;
    await new Promise((r) => setTimeout(r, 1000));
  }

  expect(run.status).toBe("Completed");
  expect(run.output).toMatchObject({ status: "processed" });
});

Common Testing Mistakes

Testing replay side effects: Code outside step.run runs on every replay. Don't put assertions on replay-sensitive code — put logic inside steps.

Not matching step IDs exactly: Step IDs in mockSteps must match the string you pass to step.run. Typos silently run the real implementation.

Over-mocking: If you mock every step, you're not testing your function logic — you're testing that mock injection works. Leave at least one step unmocked to exercise real code.

Ignoring sentEvents: Many bugs live in dispatched events, not return values. Always assert on sentEvents when your function sends events.

Testing with HelpMeTest

For production-grade Inngest testing — including monitoring your background functions in CI/CD — HelpMeTest runs end-to-end tests against your real Inngest deployment:

*** Settings ***
Library    Browser

*** Test Cases ***
Verify Order Processing Function Completes
    New Browser    chromium    headless=True
    New Page    https://your-app.com/admin/orders
    Click    [data-testid="create-test-order"]
    Wait For Element    [data-testid="order-status-processed"]    timeout=30s
    Get Text    [data-testid="order-status"]    ==    Processed

HelpMeTest's AI-powered monitoring catches Inngest function failures in production before your customers do — and its self-healing tests adapt when your function signatures change.

Summary

Testing Inngest functions effectively means:

  1. Use @inngest/test for unit tests — mock steps, assert on return values and sent events
  2. Test each branch of conditional logic explicitly
  3. Mock sleep and waitForEvent to test time-dependent flows without waiting
  4. Use the Inngest Dev Server for integration tests that need real event routing
  5. Always assert on sentEvents when your function dispatches events

Read more