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.runcan 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 devThen 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"] == ProcessedHelpMeTest'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:
- Use
@inngest/testfor unit tests — mock steps, assert on return values and sent events - Test each branch of conditional logic explicitly
- Mock
sleepandwaitForEventto test time-dependent flows without waiting - Use the Inngest Dev Server for integration tests that need real event routing
- Always assert on
sentEventswhen your function dispatches events