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

Start now free