Testing against a real Inngest server
The test host runs your functions in memory and plays Inngest’s part. It cannot tell you what only Inngest does: flow control, cancellation, the expressions of triggers and waits, signed requests, or how the server calls an app that is really listening on a port. For those, run the app against an Inngest server.
Inngest.Testing.Containers is a
Testcontainers module for that. It starts the inngest/inngest image, either as
the dev server or as a self-hosted server with signing and event keys, and gives you what a test needs to use it: its
address, the settings that point an app at it, and a call that has it sync the app.
Inngest.Testing.Containers.AspNetCore adds a
WebApplicationFactory that runs your ASP.NET Core app on a real port for the server to call.
You need Docker running on the machine that runs the tests, with Linux containers. Like every Testcontainers module, the
builder takes the image you choose; the module is tested with inngest/inngest:v1.49.0, and starts the server with
command line flags that older images may not know.
dotnet add package Inngest.Testing.ContainersThe dev server
Section titled “The dev server”InngestServerBuilder takes the image and builds an InngestServerContainer. Start it, and tell the app where it is with
Configure, which sets the options of the SDK: dev mode, and the address of the server.
The server and the app have to reach each other, so neither can be told the other’s address before it starts. Start the
container first, then the app, and then have the container sync the app at the address it listens on. This fixture runs
an ASP.NET Core app on a port the system picks. It uses MapInngest, so the test project needs Inngest.AspNetCore and the
ASP.NET Core framework, which it has when it references your app:
using Inngest;using Inngest.Testing.Containers;using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Hosting;using Microsoft.Extensions.DependencyInjection;using Xunit;
public sealed class InngestFixture : IAsyncLifetime{ private WebApplication? _app;
public InngestServerContainer Server { get; } = new InngestServerBuilder("inngest/inngest:v1.49.0").Build();
public async ValueTask InitializeAsync() { await Server.StartAsync();
var builder = WebApplication.CreateBuilder();
// All interfaces, not loopback: the container reaches the app through the Docker bridge on Linux. builder.WebHost.ConfigureKestrel(kestrel => kestrel.ListenAnyIP(0)); builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>(); builder.Services .AddInngest(options => { options.AppId = "shop"; Server.Configure(options); }) .AddFunction<OnboardUser>();
_app = builder.Build(); _app.MapInngest("/api/inngest"); await _app.StartAsync();
await Server.SyncAsync(new Uri(new Uri(_app.Urls.First()), "/api/inngest")); }
public async ValueTask DisposeAsync() { if (_app is not null) { await _app.DisposeAsync(); }
await Server.DisposeAsync(); }}StartAsync returns when the server answers its health check. If it never does, the exception carries the server’s log.
Configure sets IsDev, DevServerUrl and BaseUrl, replacing addresses the app set in code, and it must run after
the container has started, since the port is only known then. The container publishes the API on a free port of the
machine, so parallel test runs never collide. GetBaseAddress() is that address, for a test that talks to the server
itself, for instance to send an event or read a run over its REST API.
How the server finds the app
Section titled “How the server finds the app”SyncAsync sends the app’s Inngest endpoint a PUT, which an SDK answers by registering its functions with the
server under the URL the request arrived on; the server calls that URL later. Inside the container localhost is the
container itself, so a loopback address, or 0.0.0.0 or [::], which Kestrel reports for an app on all interfaces, goes
out with host.docker.internal and the port as its host. Any other address, such as the name of a container on a shared
network, is used as given. Docker Desktop resolves host.docker.internal by itself, and on Linux the builder maps it to
the host gateway. The request also says which kind of server it comes from, dev or cloud, as the server’s own sync
does, and the self-hosted server’s request is signed with its signing key. SyncAsync returns once the server knows the
app’s functions, and throws when the app does not answer or refuses. Syncing again is harmless. An app that sets
ServeOrigin (or INNGEST_SERVE_ORIGIN) registers that origin instead of the one the request names, which the container
cannot reach; leave it unset in tests.
When the app’s port is known before anything starts, the container can do the syncing: WithAppUrl adds the address to
the command line, and the server syncs the app when it starts and keeps trying every second until the app answers. The
app may start before or after the server.
using Inngest.Testing.Containers;
var server = new InngestServerBuilder("inngest/inngest:v1.49.0") .WithAppUrl("http://localhost:5280/api/inngest") .Build();A loopback address in the URL is rewritten to host.docker.internal, and WithAppUrl can be called once for each app.
WithAppUrl gives no signal when the sync has happened, and an event sent before the app is registered starts no run,
because the server has no function for it. SyncAsync returns when the server knows the app, so use it where a test
has to know. After WithAppUrl, the test waits for the app to show up. The apps query of the GraphQL API that the Dev UI
uses lists the apps the server has synced:
using System.Net.Http.Json;using System.Text.Json;using Inngest.Testing.Containers;
using var http = new HttpClient { BaseAddress = server.GetBaseAddress() };while (true){ using var response = await http.PostAsJsonAsync("v0/gql", new { query = "{ apps { externalID functionCount } }" }, cancellationToken); var body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken); var apps = body.GetProperty("data").GetProperty("apps").EnumerateArray(); if (apps.Any(app => app.GetProperty("externalID").GetString() == "shop" && app.GetProperty("functionCount").GetInt32() > 0)) { break; }
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);}An app under WebApplicationFactory
Section titled “An app under WebApplicationFactory”A WebApplicationFactory runs the app in memory, and an in-memory server has no port for Inngest to call. From .NET 10
the factory can run Kestrel instead, and InngestWebApplicationFactory in
Inngest.Testing.Containers.AspNetCore does
everything the fixture above does: it listens on all interfaces on a free port, applies Configure to the app’s options
after the app’s own, and syncs the app.
dotnet add package Inngest.Testing.Containers.AspNetCoreDerive from it where you would derive from WebApplicationFactory, and override ConfigureWebHost for the fakes of
your own. This one replaces the email service with one that tells the test when it sent something, which is how a test
sees what a function did:
// The last line of the app's Program.cs, which makes the class that top-level statements generate visible to tests.public partial class Program;using Inngest;using Inngest.Testing.Containers;using Microsoft.AspNetCore.Hosting;using Microsoft.Extensions.DependencyInjection;using Xunit;
public sealed class SignalingEmailSender : IEmailSender{ private readonly TaskCompletionSource<string> _sent = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<string> Sent => _sent.Task;
public Task<string> SendAsync(string to, string subject, CancellationToken cancellationToken) { _sent.TrySetResult($"{subject} to {to}"); return Task.FromResult("message-id"); }}
public sealed class ShopFactory(InngestServerContainer inngest, IEmailSender email) : InngestWebApplicationFactory<Program>(inngest){ protected override void ConfigureWebHost(IWebHostBuilder builder) => builder.ConfigureServices(services => services.AddSingleton(email));}
public sealed class OnboardingTests : IAsyncLifetime{ private readonly SignalingEmailSender _email = new(); private readonly InngestServerContainer _server = new InngestServerBuilder("inngest/inngest:v1.49.0").Build(); private ShopFactory? _app;
public async ValueTask InitializeAsync() { await _server.StartAsync();
_app = new ShopFactory(_server, _email); await _app.StartAsync(); }
[Fact] public async Task A_user_who_signs_up_gets_a_welcome_email() { var inngest = _app!.Services.GetRequiredService<IInngestClient>();
await inngest.SendAsync(new UserSignedUp("u_1", "ada@example.com"));
Assert.Equal("Welcome! to ada@example.com", await _email.Sent.WaitAsync(TimeSpan.FromSeconds(30))); }
public async ValueTask DisposeAsync() { if (_app is not null) { await _app.DisposeAsync(); }
await _server.DisposeAsync(); }}The event goes from the app’s own IInngestClient to the container, the server calls the app’s endpoint over the
socket, and the function sends the email through the fake. factory.CreateClient() returns a client for the same
socket, so a test can also call the app’s own endpoints, such as one that sends the event.
Some things to know about the factory:
- It targets .NET 10.
WebApplicationFactory.UseKestrelwas added in that release; before it, a real port needs a second host built next to the in-memory one, which runs the app’s hosted services twice and splits its state in two. On .NET 8 and 9, host the app yourself as above. - Start the container first. The app’s settings hold its address, so creating the host throws while the container is not running.
StartAsyncstarts the app, then syncs it. Creating a client or readingServicesfirst starts the app too, but the server does not know it untilStartAsync.- Every client reaches the app at
localhostand its port. Kestrel reports an app on all interfaces as[::], which a client cannot connect to on Windows, and a factory handshttp://localhost, port 80, to options of its own that have no address. So the factory declaresCreateClientagain and configures whatCreateDefaultClientreturns. Use it through its own type: a variable typed asWebApplicationFactory<Program>calls the originalCreateClient. - The app runs on Kestrel, so the factory’s
Serverproperty, the in-memoryTestServer, throwsNotSupportedException. - Customize the app by overriding
ConfigureWebHost. A factory returned byWithWebHostBuilderkeeps the Inngest settings but is another instance of the app on the in-memory server, because ASP.NET Core does not carry the Kestrel choice over to it. - The server calls the app over plain HTTP, so turn off HTTPS redirection in the test.
- One container serves one instance of an app id. Syncing a second app with the same id replaces the first.
The self-hosted server
Section titled “The self-hosted server”The dev server signs nothing. inngest start is the self-hosted server: it signs every request it makes to the app,
wants the signing key on its REST API and an event key on events, so an app in cloud mode with the same keys
exercises request signing for real. WithSelfHosting runs it. Keys you do not give are generated, and the container
tells them:
using Inngest.Testing.Containers;
var server = new InngestServerBuilder("inngest/inngest:v1.49.0") .WithSelfHosting() .Build();await server.StartAsync();
// Hexadecimal digits in pairs: inngest start accepts nothing else as a signing key.string signingKey = server.SigningKey!;string eventKey = server.EventKey!;Configure then sets cloud mode with the keys and the server’s address. SyncAsync signs its request with the signing
key. To use keys of your own, call WithSigningKey and WithEventKey after WithSelfHosting; the signing key must be
plain hexadecimal digits in pairs, without the signkey-env- prefix that Inngest Cloud keys carry, and the builder
rejects anything else when it builds.
Connect workers
Section titled “Connect workers”A worker that uses Connect reaches the server through its gateway, and the server
hands the worker the gateway’s address as it is inside the container. WithConnectGateway publishes the gateway on the
same port on the machine, picks a free one when you give none, and makes the container wait for the gateway as well as
the API. The worker needs no URL to sync and no SyncAsync:
using Inngest.Connect;using Inngest.Testing.Containers;using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder();builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();builder.Services .AddInngest(options => { options.AppId = "shop-worker"; server.Configure(options); }) .AddFunction<OnboardUser>() .AddConnect();
using var worker = builder.Build();await worker.StartAsync();Build the container with new InngestServerBuilder("inngest/inngest:v1.49.0").WithConnectGateway(). ConnectGatewayPort
tells the port. The builder picks the port when it builds the container and keeps it from other processes until Docker
has pulled the image and is about to publish it. A port that is taken by then can only be one a process took in that
moment; if a start fails because the port is already allocated, build the container again, which picks another, or give
WithConnectGateway a port of your own.
The worker syncs through the gateway when it connects, which gives no signal either: an event sent before the app is
registered starts no run. The worker takes work once IInngestConnection.State is Active, so the test waits for that:
using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;
var connection = worker.Services.GetRequiredService<IInngestConnection>();var state = connection.State;while (state != InngestConnectionState.Active){ state = await connection.WaitForStateChangedAsync(state, cancellationToken);}Apps that read their settings from the environment
Section titled “Apps that read their settings from the environment”Configure is for apps that take an Action<InngestOptions>. For an app that reads the INNGEST_* variables, such as
a process the test starts, GetSdkEnvironment lists the same settings as variables. A process inherits the variables of
the machine, and the SDK reads some of them in an order that lets an inherited one win: INNGEST_DEV=1 turns signature
checks off, and INNGEST_BASE_URL can point at Inngest Cloud. So the list sets every variable that decides the mode and
the addresses: INNGEST_DEV (the address of the dev server, or 0 for the self-hosted server), INNGEST_BASE_URL,
INNGEST_API_BASE_URL and INNGEST_EVENT_API_BASE_URL, and for the self-hosted server INNGEST_SIGNING_KEY and
INNGEST_EVENT_KEY. The names are those every Inngest SDK reads. Other INNGEST_* variables the process inherits still
apply, such as INNGEST_SERVE_ORIGIN or INNGEST_ENV; remove the ones that matter from the process’s environment.
using System.Diagnostics;using Inngest.Testing.Containers;
var start = new ProcessStartInfo("dotnet", ["run", "--project", "src/Shop"]);foreach (var (name, value) in server.GetSdkEnvironment()){ start.Environment[name] = value;}Options
Section titled “Options”Method of InngestServerBuilder |
What it does |
|---|---|
WithSelfHosting() |
Runs inngest start in place of inngest dev. |
WithSigningKey(key), WithEventKey(key) |
Sets the keys of the self-hosted server instead of generating them. |
WithAppUrl(url) |
Has the server sync the app at this address when it starts, and retry every second until the app answers. Once for each app. |
WithConnectGateway(port) |
Publishes the Connect gateway on the same port inside the container and on the machine. Picks a free port without an argument. |
WithFastTiming(false) |
Leaves the server’s own timing. On by default: failed syncs are tried again after a second, failed steps are retried after a second instead of the server’s backoff, and the queue is polled every 50 ms instead of every 150 ms. |
WithCommand(...) |
Adds arguments after the ones the builder makes. |
Everything else is the Testcontainers builder, such as WithImagePullPolicy, WithNetwork and WithWaitStrategy. The
container is ready when the API answers /health, and the gateway /ready when it is published; a wait strategy with
conditions of its own replaces that.
Member of InngestServerContainer |
What it holds |
|---|---|
GetBaseAddress() |
The address of the server’s API: events, the REST and GraphQL APIs, syncs and the health check. GetConnectionString() returns it as text. |
Configure(options) |
Sets an app’s InngestOptions to use the server. |
GetSdkEnvironment() |
The same settings as INNGEST_* variables, with the ones an inherited variable could outrank. |
SyncAsync(appUrl) |
Has the server register the app that listens at this address. |
IsDevServer, SigningKey, EventKey |
Which server it is, and the keys of the self-hosted one; both keys are null for the dev server, which takes any event key. |
ConnectGatewayPort |
The gateway’s port, or null. |
The dev server runs with --no-discovery, so that it does not scan ports for apps, and --no-poll, so that it syncs an
app once instead of every few seconds. inngest start has neither flag and syncs only the apps it is told about. Both
run with DO_NOT_TRACK=1. One more setting is the builder’s to make: the server binds its Connect ports only after it
has opened connections, and the kernel may give one of those connections such a port as its source port, which fails the
start. The builder reserves the ports in the container, so the kernel never hands them out.
What this does not do
Section titled “What this does not do”- The server keeps nothing between containers. Its runs, events and apps are gone when the container is, and parallel tests that share a container share its apps and events, so give each test its own event data to tell them apart.
- It talks to Docker on the machine that runs the tests. A container reaches the app on that machine as
host.docker.internal, which does not hold for a remote Docker host. - It waits for nothing the server does. A test that follows a run polls the REST or GraphQL API, or has the function signal the test as above; the server caches some REST answers for a number of seconds, so a poll that must be quick adds a unique query parameter to each request.
inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.