FISCHI (pronounced /ˈfiski/, FEES-kee—the Italian word for "whistles") stands for Functions ISolated Comprehensive Helpers for Integration-tests.
Bring the familiar ergonomics of ASP.NET Core's WebApplicationFactory to .NET isolated Azure Functions tests: real DI, worker middleware, scoped invocation, HttpClient, and typed trigger inputs, without emulating the Functions host.
FISCHI is a lightweight, in-memory test host for integration-style tests. It runs explicitly registered functions through your real application DI container and worker middleware, using familiar patterns such as a derived factory, HttpClient, typed invocation handles, and per-invocation scopes. There is no Functions Core Tools process to start and no storage account to provision for the supported scenarios.
Use FISCHI for fast, focused tests of the code that runs inside the isolated worker. Keep a small number of real-host integration tests for behavior owned by the Azure Functions host or its extensions, such as routing, authorization, trigger delivery, and output persistence.
Using a coding agent? Point it to this README. The quick start, supported boundary, and examples below give it the intended setup and testing patterns without requiring it to infer how FISCHI works.
FISCHI 1.0.0 targets .NET 10. Add the core package to your test project and reference the Function App project:
dotnet add <your-test-project> package Fischi --version 1.0.0
dotnet add <your-test-project> reference <your-function-app-project>The core package supports HTTP. Add only the companion packages needed by your tests:
| Scenario | Package | Test input |
|---|---|---|
| HTTP | Fischi |
HttpRequestMessage through HttpClient |
| Timer | Fischi.Timer |
TimerTriggerInput |
| Storage Queue | Fischi.Queues |
QueueTriggerInput |
| Cosmos DB change feed | Fischi.CosmosDb |
CosmosDbChangeFeedTriggerInput<TDocument> |
Companion packages reference the core package, but installing Fischi explicitly makes the test project's intent clear. Keep all FISCHI packages on the same version:
dotnet add <your-test-project> package Fischi.Timer --version 1.0.0
dotnet add <your-test-project> package Fischi.Queues --version 1.0.0
dotnet add <your-test-project> package Fischi.CosmosDb --version 1.0.0Move your application service and worker-middleware registrations into a composition-root method. Both production startup and the test factory must call this same method; otherwise a passing test may be exercising a different application from the one you deploy.
// FunctionAppConfiguration.cs
public static class FunctionAppConfiguration
{
public static void Configure(IFunctionsWorkerApplicationBuilder builder)
{
builder.Services.AddOptions<DeliveryOptions>()
.BindConfiguration("Delivery")
.ValidateOnStart();
builder.Services.AddScoped<DeliveryService>();
builder.UseMiddleware<CorrelationMiddleware>();
}
}
// Program.cs
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
FunctionAppConfiguration.Configure(builder);
builder.Build().Run();Register a function by its [Function] name, derived directly from the method metadata, then use its handle to select it. The handle avoids repeating a string function name in each test.
using Fischi;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
public sealed class FunctionAppFactory : FunctionsApplicationFactory
{
public HttpFunctionHandle Greeting => GetHttpFunction(nameof(GreetingFunction.GetGreeting));
protected override void ConfigureApplication(IFunctionsWorkerApplicationBuilder builder) =>
FunctionAppConfiguration.Configure(builder);
protected override void ConfigureHttpFunctions(HttpFunctionRegistry functions) =>
functions.For<GreetingFunction>().Register(
(function, request) => function.GetGreeting(request));
}The factory reuses the production composition root, while ConfigureHttpFunctions explicitly defines which functions tests may invoke. FISCHI does not scan the assembly or pretend to be the Azure Functions host.
public sealed class GreetingTests
{
[Fact]
public async Task GetGreeting_returns_the_application_response()
{
await using var factory = new FunctionAppFactory();
using var client = factory.CreateClient(factory.Greeting);
using var response = await client.GetAsync("/greeting");
Assert.Equal("Hello", await response.Content.ReadAsStringAsync());
}
}The fluent registration reads only the [Function] name from the directly invoked method. It does not scan assemblies, inspect HTTP trigger attributes, or route a request. Use explicit-name Register... overloads when a function cannot use a metadata expression.
If an HTTP function needs application-owned binding beyond HttpRequest, use For<TFunction>().WithContext(...). It takes nameof(FunctionType.Method), validates that it identifies one [Function] method, and uses that attribute's function name without invoking the method or requiring placeholder arguments. HttpFunctionContext exposes the copied request, invocation-scoped services and cancellation token, configured ASP.NET Core JSON options, ReadBodyAsync<T>(), and SetRouteValueFromPathSegment(...). The adapter chooses those bindings; FISCHI does not match route templates or bind models automatically.
functions.For<HttpFunctions>().WithContext(
nameof(HttpFunctions.ScheduleNotification),
InvokeScheduleNotificationAsync);The companion registrations return the same kind of typed handle. This timer example maps the supplied test input to the public worker TimerInfo parameter:
using Fischi;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Timer;
public sealed class FunctionAppFactory : FunctionsApplicationFactory
{
public FunctionHandle<TimerTriggerInput, CleanupResult> Cleanup =>
GetFunction<TimerTriggerInput, CleanupResult>(nameof(CleanupFunction.Run));
protected override void ConfigureApplication(IFunctionsWorkerApplicationBuilder builder) =>
FunctionAppConfiguration.Configure(builder);
protected override void ConfigureFunctions(FunctionRegistry functions) =>
functions.For<CleanupFunction>().Timer((function, timer) => function.Run(timer));
}
var invocation = await factory.InvokeAsync(
factory.Cleanup, new TimerTriggerInput(IsPastDue: true));
Assert.True(invocation.Result.Completed);Each invocation gets a fresh DI scope, its own FunctionContext.Items, the configured worker-middleware pipeline, and the caller’s cancellation token. A successful non-HTTP invocation returns FunctionInvocationResult<TResult> with its typed Result, invocation ID, item snapshot, and any explicitly captured output snapshot.
The public FunctionRegistry, trigger metadata records, IFunctionInvocationContextEnricher<TInput>, FunctionInvocationContextData, and FunctionInvocationOutputs form the deliberately small companion-authoring surface. Most application tests should use the built-in trigger companions instead; custom companions can use these hooks to declare worker-visible metadata, enrich explicit context data, and capture assertion-only outputs without accessing FISCHI internals.
The test factory may set an environment and configuration providers before the shared composition root runs; bound options therefore see the test values. Use a separate factory when tests need different application-wide configuration or service replacements.
public sealed class FunctionAppFactory : FunctionsApplicationFactory
{
protected override string EnvironmentName => "Testing";
protected override void ConfigureConfiguration(IConfigurationBuilder configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Delivery:Sender"] = "test@example.test",
});
protected override void ConfigureApplication(IFunctionsWorkerApplicationBuilder builder) =>
FunctionAppConfiguration.Configure(builder);
// ConfigureHttpFunctions and ConfigureFunctions register handles here.
}
public sealed class FunctionAppFixture : IAsyncLifetime
{
public FunctionAppFactory Factory { get; } = new();
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Factory.DisposeAsync().AsTask();
}
public sealed class GreetingTests(FunctionAppFixture fixture)
: IClassFixture<FunctionAppFixture>
{
[Fact]
public async Task Uses_the_shared_factory()
{
using var client = fixture.Factory.CreateClient(fixture.Factory.Greeting);
using var response = await client.GetAsync("/greeting");
Assert.True(response.IsSuccessStatusCode);
}
}Factories initialize on first client creation, invocation, or handle access. Call Initialize() when a test needs an explicit startup-validation boundary—for example, to assert an invalid ValidateOnStart option. ConfigureTestApplication adds test-only worker middleware after application middleware; the constructor’s test-service callback adds or replaces services after the composition root and before the provider is built.
TimerTriggerInput supplies IsPastDue and optional TimerScheduleStatus (Last, Next, and LastUpdated), which the companion maps to the worker’s public TimerInfo and ScheduleStatus.
functions.For<CleanupFunction>().Timer(
(function, timer) => function.Run(timer));
var invocation = await factory.InvokeAsync(
factory.Cleanup,
new TimerTriggerInput(IsPastDue: true));FISCHI does not calculate schedules or read/write persisted timer status.
QueueTriggerInput maps its body, message ID, dequeue count, pop receipt, timestamps, and optional QueueRetryInformation to the public QueueMessage and FunctionContext.RetryContext surfaces.
functions.For<ProcessOrderFunction>().Queue(
(function, message, context) => function.Run(message, context),
result => result.AuditEntry);
var invocation = await factory.InvokeAsync(
factory.ProcessOrder,
new QueueTriggerInput("{\"id\":\"42\"}", "message-42", dequeueCount: 2,
retry: new QueueRetryInformation(RetryCount: 1, MaxRetryCount: 5)));
Assert.Contains("order-42", invocation.Outputs.Cast<string>());The optional result-output selector captures an assertion-only output in invocation.Outputs. Alternatively, an adapter can use InMemoryQueueResultCollector<T>.For(context). Captured values are invocation-local: FISCHI does not serialize, deliver, or retry them.
The input is already typed and caller supplied; FISCHI passes Changes to the registered function without deserializing documents or inspecting trigger attributes.
functions.For<ProcessOrdersFunction>().CosmosDbChangeFeed().Documents<Order>()
.Register((function, changes) => function.Run(changes));
var invocation = await factory.InvokeAsync(
factory.ProcessOrders,
new CosmosDbChangeFeedTriggerInput<Order>([new Order("42")]));| Behavior under test | Choose | Why |
|---|---|---|
Application DI, worker middleware, function logic, explicit typed trigger input, IActionResult execution |
FISCHI | This is the worker-side in-memory boundary. |
HTTP route or method dispatch, /api prefix, function-key authorization |
Real host | FISCHI selects a registered function directly; it has no endpoint routing or host auth. |
| Attribute discovery or automatic trigger/input/output binding | Real host | Registrations and their adapters own all supported binding explicitly. |
| Queue delivery, deserialization, retry scheduling, or output delivery | Real host | FISCHI maps a supplied message and can only capture assertion outputs. |
| Timer schedule calculation or persisted schedule state | Real host | Tests supply TimerTriggerInput and any schedule status themselves. |
| Cosmos polling, leases, checkpoints, or continuations | Real host | FISCHI accepts one caller-supplied typed batch. |
| Host lifecycle, host-generated configuration/context data, or host-to-worker gRPC | Real host | FISCHI neither builds an Azure Functions host nor emulates the gRPC protocol. |
FISCHI reports registration, selection, and unsupported-binding mistakes as InvalidOperationException with a corrective action. Failures in middleware or a function are wrapped in InMemoryFunctionInvocationException, retaining the original exception, function name, invocation ID, trigger metadata, and invocation-item snapshot. HTTP failures also identify the request method and URI. Cancellation continues unchanged.
| Trigger | Input | Observation |
|---|---|---|
| HTTP | Standard HttpRequestMessage through HttpClient |
HttpResponseMessage from an IActionResult |
| Timer | TimerTriggerInput |
FunctionInvocationResult<TResult> |
| Queue | QueueTriggerInput |
FunctionInvocationResult<TResult> and explicit invocation-local output capture |
| Cosmos DB change feed | CosmosDbChangeFeedTriggerInput<TDocument> |
FunctionInvocationResult<TResult> for a typed supplied batch |
Blob triggers plus Blob, Table, and Cosmos input/output bindings are not supported. Composite HTTP results that combine [HttpResult] with output bindings are also outside this boundary. Use Azure Functions Core Tools or another equivalent real-host integration test when host behavior is part of the assertion.
Fischi, Fischi.Timer, Fischi.Queues, and Fischi.CosmosDb are released as an aligned SemVer set. Breaking changes will be released as a major version.
FISCHI does not require a particular assertion or mocking library. If they fit your tests, you may also find these free, MIT-licensed projects by the same author useful:
- MELT makes assertions against
Microsoft.Extensions.Loggingoutput easier. - Burla provides explicit, strict-by-default mocking for .NET.
These are optional suggestions, not dependencies or paid endorsements.
Need another trigger or binding? Open an issue and describe the test scenario you want to support. Concrete use cases help define a useful worker-side contract without accidentally emulating the Functions host.
Pull requests are welcome. If contributing code is not practical, offers of AI-token or Azure-credit sponsorship can also help with implementation, compatibility testing, and maintenance; but they are never expected to use the library or participate in the project.
Each NuGet package embeds this README and the MIT license. The detailed v1.0.0 contracts remain in the specifications: supported scenarios defines the trigger matrix, the public API contract covers registration and diagnostics, and the boundary contract explains the sample/library split.
dotnet test FunctionApp.slnxThe repository’s release workflow builds, tests, inspects, and packs all four packages. Pushing a tag that starts with v (for example, v1.0.0 or v1.0.0-rc.1) also publishes them to NuGet using the tag without its leading v as the shared package version. Configure the NuGet API key as the GitHub Actions repository secret NUGET_API_KEY before pushing a release tag.
./eng/release.sh