Skip to content

Testing Inngest functions with xUnit

There are three ways to test an Inngest function:

  • The test host of the Inngest.Testing package runs the function on the SDK’s real execution engine and plays Inngest’s part: it calls the function again after every step, passes step results through JSON, retries failures and answers sleeps, waits and invokes from mocks. Nothing leaves the test process.
  • A fake IStepTools runs every step inline. It needs no package besides Inngest and checks the function’s logic and the order of its steps, but not replays or the serialization of step results.
  • The Inngest dev server runs the whole app as Inngest runs it, flow control included.

The unit tests on this page test this function. It sends a welcome email, waits up to three days for the user to activate, and sends a reminder when they do not:

OnboardUser.cs
using Inngest;
public sealed record UserSignedUp(string UserId, string Email) : IInngestEventData
{
public static string EventName => "user/signed-up";
}
public sealed record UserActivated(string UserId) : IInngestEventData
{
public static string EventName => "user/activated";
}
public sealed record OnboardingResult(bool Activated);
public interface IEmailSender
{
Task SendAsync(string to, string subject, CancellationToken cancellationToken);
}
[InngestFunction("onboard-user")]
[EventTrigger<UserSignedUp>]
public sealed class OnboardUser(IEmailSender email) : InngestFunction<UserSignedUp, OnboardingResult>
{
public override async Task<OnboardingResult> ExecuteAsync(FunctionContext<UserSignedUp> context, CancellationToken cancellationToken)
{
var user = context.Event.Data;
await context.Step.RunAsync("send-welcome", ct => email.SendAsync(user.Email, "Welcome!", ct));
var activated = await context.Step.WaitForEventAsync<UserActivated>("wait-for-activation",
TimeSpan.FromDays(3), match: "data.userId");
if (activated is null)
{
await context.Step.RunAsync("send-reminder", ct => email.SendAsync(user.Email, "Your account is waiting", ct));
}
return new OnboardingResult(activated is not null);
}
}

The tests replace the email service with one that records the subject of every email:

RecordingEmailSender.cs
public sealed class RecordingEmailSender : IEmailSender
{
public List<string> Subjects { get; } = [];
public Task SendAsync(string to, string subject, CancellationToken cancellationToken)
{
Subjects.Add(subject);
return Task.CompletedTask;
}
}

Add the package to the test project:

Terminal window
dotnet add package Inngest.Testing

InngestTestHost takes a service collection with the app’s registrations: AddInngest, the functions and middleware to test, and the services they use. These tests register the function as the app does and the recording email sender in place of the real one:

OnboardUserTests.cs
using Inngest;
using Inngest.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
public sealed class OnboardUserTests
{
private static readonly InngestEvent SignedUp = new(UserSignedUp.EventName, new UserSignedUp("u_1", "ada@example.com"));
private readonly RecordingEmailSender _email = new();
[Fact]
public async Task Sends_a_reminder_when_the_user_does_not_activate()
{
await using var host = CreateHost();
var run = await host.RunAsync<OnboardUser>(SignedUp,
new StepMocks().WaitForEventTimeout("wait-for-activation"),
TestContext.Current.CancellationToken);
Assert.Equal(TestRunStatus.Completed, run.Status);
Assert.False(run.GetOutput<OnboardingResult>()!.Activated);
Assert.Equal(["send-welcome", "wait-for-activation", "send-reminder"], run.Steps.Select(step => step.Id));
Assert.Equal(["Welcome!", "Your account is waiting"], _email.Subjects);
}
[Fact]
public async Task Skips_the_reminder_when_the_user_activates()
{
await using var host = CreateHost();
var activated = new InngestEvent(UserActivated.EventName, new UserActivated("u_1"));
var run = await host.RunAsync<OnboardUser>(SignedUp,
new StepMocks().WaitForEvent("wait-for-activation", activated),
TestContext.Current.CancellationToken);
Assert.True(run.GetOutput<OnboardingResult>()!.Activated);
Assert.Equal(["Welcome!"], _email.Subjects);
}
[Fact]
public async Task Waits_three_days_for_the_same_user_to_activate()
{
await using var host = CreateHost();
var run = await host.ExecuteStepAsync<OnboardUser>("wait-for-activation", SignedUp,
TestContext.Current.CancellationToken);
Assert.Equal(TestRunStatus.Stopped, run.Status);
var wait = run.GetStep("wait-for-activation");
Assert.Equal("3d", wait.Options.GetProperty("timeout").GetString());
Assert.Equal("event.data.userId == async.data.userId", wait.Options.GetProperty("if").GetString());
}
private InngestTestHost CreateHost()
{
var services = new ServiceCollection();
services.AddSingleton<IEmailSender>(_email);
services.AddInngest(options => options.AppId = "shop").AddFunction<OnboardUser>();
return new InngestTestHost(services);
}
}

RunAsync<OnboardUser> runs the function registered as OnboardUser, under the id it was registered with; RunAsync("onboard-user", ...) does the same by id. Both have overloads without mocks, and overloads that take an IReadOnlyList<InngestEvent> in place of the event to run a function that takes batches. A run goes on until the function completes, fails for good, or waits on a step that has no mock, and RunAsync then returns a TestRun. A function with its options in code is registered with the same FunctionOptions as in the app, and runs the same way.

The host copies the service collection, so tests can share one. It builds the services the way the app does at startup, so function options that Inngest would reject make the constructor throw. host.Services is the built container, and disposing the host disposes it. Runs are independent of each other, and one host can run several at once.

  • It calls the function again after every step, as Inngest does, so code outside steps runs once per call, and every step result reaches the function through JSON written with the app’s serializer options.
  • It resolves the function, its middleware and the services they use from a new scope for every call, and the app’s middleware runs around every call and step. A scoped step runs its handler in a scope of its own on top of that.
  • It runs steps that start together in calls of their own, one per step, and calls the function again once all of them have finished, or after each one in race mode.
  • It retries a failing step or function at once, with the attempt numbers Inngest would send, until the function’s Retries run out (4 retries when it sets none). A NonRetriableException fails at once, and a RetryAfterException is retried without waiting out its delay.
  • When a run fails for good, it runs the function’s failure handler, if it has one, with the inngest/function.failed event Inngest would send, and with the same mocks.
  • It records the events the app sends instead of sending them.

What it does not do:

  • It sends nothing to Inngest. The SDK’s own HttpClient throws for every request, even to a dev server on the same machine, and the INNGEST_* environment variables are ignored, so the machine a test runs on does not change how the test runs. Options set in code and a configuration section passed to AddInngest still apply.
  • It does not apply what Inngest enforces outside the SDK: concurrency limits, throttling, rate limits, debounce, priority, idempotency, singletons, cancellation and timeouts. A batch is the list of events the test passes.
  • It does not check a wait mock’s event against the wait’s Match or If, only against the event name.
  • It does not carry out signal waits, fetch steps or inference steps, which cannot be mocked, so the run waits on them. Nor does it run invoked functions: an invoke without a mock leaves the run waiting.
  • It does not checkpoint. A function with checkpointing on runs one step per call in the host, as without it, so code outside steps runs more often than in production; the results are the same.

TestRun is the run as the host left it:

Member What it holds
Status Completed when the function returned, Failed when it failed for good, Waiting when it waits on steps the test did not mock or the host does not carry out, and Stopped when ExecuteStepAsync reached its step.
Output, GetOutput<T>() What the function returned, as the JSON Inngest stores; null unless the run completed. GetOutput<T>() reads it with the app’s serializer options and throws InvalidOperationException when there is no output.
Error The exception the run failed with, as the function or the SDK threw it: a StepFailedException when a step failed for good and the function did not catch it. Null unless the run failed.
Steps, GetStep(id) Every step the run reached, in the order Inngest heard of them. GetStep finds one by the id the code passed, and throws KeyNotFoundException naming the steps the run reached when there is none.
WaitingOn The steps a waiting run waits on.
SentEvents The events the run sent, from send steps and through the client, in order.
FailureHandler The run of the failure handler, when the function failed for good and has one; otherwise null.
RunId, FunctionId The id of the run, and the id of the function without the app’s prefix.

ToString() describes the outcome, such as Waiting on step "wait-for-activation" (WaitForEvent)., which makes a failed assertion easier to read.

Each step is a TestStep:

Member What it holds
Id, HashedId The id the code passed, and the hash Inngest stores the step under.
Type The StepType, such as Run, Sleep, WaitForEvent, Invoke or SendEvent. Null for an operation the host does not know.
Op, Options The last operation the SDK reported for the step, such as StepRun, Sleep or WaitForEvent, and its options as JSON, such as the duration of a sleep or the event, timeout and if of a wait.
Status Pending when the step is planned or waits on something the test did not mock, Completed, or Failed when it failed for good.
Attempts How many times its handler, or its mock, ran. Zero for a sleep, wait or invoke, which Inngest carries out.
Mocked Whether a mock answered the step.
Output, GetOutput<T>() The result the function receives, as JSON: the value of a run step or an invoke, the event a wait received, or JSON null for a sleep or a wait that timed out. Null unless the step completed.
Error, Errors What the step failed with for good, and what each failed attempt threw, including attempts that a retry made up for.

A step id that the code uses more than once, as in a loop, gets a :<n> suffix from its second use on, so the third step named page is page:2, both in GetStep and in mocks.

StepMocks holds what the host answers in place of steps, by step id. Every method returns a new set, so tests can share a set and add to it.

Method What the step does
Run<T>(id, T result) Returns result instead of running its handler. An AIWrapAsync step and a RunScopedAsync step are mocked the same way, the latter then making no scope, and so is a model call or tool call of an AI chat client.
Run<T>(id, Func<int, T> handler) Calls handler in place of the step’s own handler on every attempt, with the attempt number from 0. Throw from it to fail the attempt.
RunThrows(id, exception) Throws on every attempt, so the step is retried and then fails for good, at once for a NonRetriableException.
Sleep(id) The sleep ends.
AllSleeps() Every sleep ends.
WaitForEvent(id, received) The wait receives received, which must have the name the step waits for.
WaitForEventTimeout(id) The wait times out and returns null.
Invoke<T>(id, T result) The invoked function returns result.
Invoke<TFunction, TResult>(id, TResult result) The same for a step that invokes TFunction by its class.
InvokeThrows(id, exception) The invoked function fails, and the step throws StepFailedException where the function awaits it.
WaitForSignal<T>(id, T data) The wait for a signal receives data, under the name of the signal it waits for.
WaitForSignalTimeout(id) The wait for a signal times out and returns null.
SendSignal(id, runId) The send-signal step returns runId as the run the signal resumed; null says no run waited.
Fetch(id, statusCode, body, headers) The fetch step gets this response, as Inngest would hand it back.
Infer<T>(id, T response) The AI provider answers the inference step with response.

A mocked run step still goes through the engine: its value is serialized, memoized and replayed like a real result, and the app’s middleware runs around it. A run step without a mock runs its handler.

For a step that invokes a function by its class, as InvokeAsync<ScoreAccount, AccountScore> does in Invoking functions, name the class in the mock too:

using Inngest.Testing;
var mocks = new StepMocks()
.Invoke<ScoreAccount, AccountScore>("score-acme", new AccountScore("acme", 40))
.Invoke<ScoreAccount, AccountScore>("score-globex", new AccountScore("globex", 60));

The compiler checks that ScoreAccount returns an AccountScore, and the host checks that the step invokes ScoreAccount: when it invokes another function, RunAsync throws an InvalidOperationException that names both. Invoke<T> without a class answers any invoke, by class or by id.

A mock that does not fit its step makes RunAsync throw InvalidOperationException too: a mock for another kind of step, such as Run for a sleep, or a wait mock whose event has another name. Send steps take no mock; the host records their events. A signal sent without a mock resumes no run and is recorded too, and so are realtime messages.

The steps of a fan-out over a list are mocked by their ids, such as charge[0].

Steps that start together, as with Task.WhenAll, hold each other up: without race mode Inngest calls the function again only once all of them have finished, so a wait without a mock keeps the run waiting even when the other steps of the group are mocked.

Sleeps and wait timeouts end at the time they would end under Inngest, on a simulated clock: it starts at the app’s time and moves forward only when something completes, so a run never waits in real time, and steps that run together end in the order of their end times. When the app’s TimeProvider is a FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing, the host moves it to the end of every sleep, so code that reads the clock after a sleep sees the time it would see under Inngest.

run.SentEvents holds the events one run sent, and host.SentEvents every event sent through the app’s client since the host was created, from runs or not. The host checks each event as the client does before sending, so an event with a reserved inngest/ name fails its send step, and a send returns ids in the format Inngest gives events. An event sent through IInngestClient outside a step is sent again on every call, so it shows up once per call; send it from a step to send it once. Events sent from a run carry the run’s sessions in Meta.PropagatedSessions, and the host delivers an event’s sessions to the run it starts.

run.SentSignals and host.SentSignals hold the signals sent without a mock, each with its data as JSON, and run.Published and host.Published the realtime messages, a stream as one message with all its text and IsStream set.

ExecuteStepAsync runs the function only until one step has run, after its retries, or until a step that Inngest carries out, such as a sleep or a wait, has been reached. The run then ends as Stopped, unless it completed, failed or started waiting before it got there. The last test above uses it to check how the function waits, with no mocks for what comes after the wait. ExecuteStepAsync<TFunction> finds the function by its class, and ExecuteStepAsync(functionId, stepId, ...) by its id.

To test without the extra package, give the function a fake IStepTools. This one runs step handlers at once, returns at once from sleeps, answers waits from a dictionary, and records every step id in order:

InlineStepTools.cs
using Inngest;
public sealed class InlineStepTools : IStepTools
{
public List<string> Steps { get; } = [];
public Dictionary<string, object> Waits { get; } = [];
public List<InngestEvent> Sent { get; } = [];
public async Task<T> RunAsync<T>(string id, Func<CancellationToken, Task<T>> handler)
{
Steps.Add(id);
return await handler(CancellationToken.None);
}
public Task SleepAsync(string id, TimeSpan duration)
{
Steps.Add(id);
return Task.CompletedTask;
}
public Task SleepUntilAsync(string id, DateTimeOffset until)
{
Steps.Add(id);
return Task.CompletedTask;
}
public Task<InngestEvent<TData>?> WaitForEventAsync<TData>(string id, WaitForEventOptions options)
{
Steps.Add(id);
return Task.FromResult(Waits.TryGetValue(id, out var received) ? (InngestEvent<TData>?)received : null);
}
public Task<TResult> InvokeAsync<TResult>(string id, InvokeOptions options) =>
throw new NotSupportedException($"Step {id} invokes {options.FunctionId}; give the fake a result for it.");
public Task<SendEventsResult> SendEventAsync(string id, IReadOnlyList<InngestEvent> events)
{
Steps.Add(id);
Sent.AddRange(events);
return Task.FromResult(new SendEventsResult([.. events.Select((_, i) => $"{id}-{i}")]));
}
}

The SDK’s extension methods for these steps, such as RunAsync with a handler that takes no cancellation token or the typed WaitForEventAsync<T> and SendEventAsync<T>, end up in these six methods, and so do their overloads that take a StepOptions in place of the id. ParallelAsync needs nothing from the fake. The other members of IStepTools have defaults that throw NotSupportedException: WaitForSignalAsync, SendSignalAsync, FetchAsync, InferAsync, AIWrapAsync, RunScopedAsync, PublishAsync, PublishStreamAsync and InvokeAsync<TFunction, TResult>, which invokes by class and needs the app’s registrations to find the function’s id. Implement the ones the function under test calls; Scoped steps shows RunScopedAsync.

FunctionContext<T> has public init properties, so a test builds one directly:

OnboardUserFakeTests.cs
using Inngest;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
public sealed class OnboardUserFakeTests
{
private readonly RecordingEmailSender _email = new();
private readonly InlineStepTools _steps = new();
[Fact]
public async Task Sends_a_reminder_when_the_user_does_not_activate()
{
await new OnboardUser(_email).ExecuteAsync(Context(), TestContext.Current.CancellationToken);
Assert.Equal(["send-welcome", "wait-for-activation", "send-reminder"], _steps.Steps);
Assert.Equal(["Welcome!", "Your account is waiting"], _email.Subjects);
}
[Fact]
public async Task Skips_the_reminder_when_the_user_activates()
{
_steps.Waits["wait-for-activation"] = new InngestEvent<UserActivated> { Name = UserActivated.EventName, Data = new("u_1") };
await new OnboardUser(_email).ExecuteAsync(Context(), TestContext.Current.CancellationToken);
Assert.Equal(["send-welcome", "wait-for-activation"], _steps.Steps);
}
private FunctionContext<UserSignedUp> Context()
{
var signedUp = new InngestEvent<UserSignedUp> { Name = UserSignedUp.EventName, Data = new("u_1", "ada@example.com") };
return new FunctionContext<UserSignedUp>
{
FunctionId = "onboard-user",
RunId = "test-run",
Attempt = 0,
Step = _steps,
Logger = NullLogger.Instance,
Services = new ServiceCollection().BuildServiceProvider(),
Event = signedUp,
Events = [signedUp],
};
}
}

These tests check the function’s logic and the order of its steps. They do not exercise replays or the serialization of step results, which the test host does.

A function that takes ICurrentUser from Inngest.UserContext is tested with a fake ICurrentUser in the services of the host. Add the real middleware to the services instead to test how the user travels: the host restores the user of the event it is given, and records the events a function sends with the user they carry.

Flow control, cancellation and the expressions of triggers and waits are applied by Inngest, so only a run against Inngest exercises them. To test the whole path, run the app against an Inngest dev server in a container, send an event and wait for the function to do its work. The Inngest.Testing.Containers package starts the server from your tests and has it sync the app. For an ASP.NET Core app on .NET 10, the separate Inngest.Testing.Containers.AspNetCore package adds a WebApplicationFactory that runs the app against that server. See Testing with containers.

Inngest’s testing guide covers the same ideas for its other SDKs.

The functions of the examples can be tested the same way.

inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.