Skip to content

Testing ​

[AzureFunctionsTesting] builds each façade call into the trigger data that the isolated worker binds for the generated function, such as the Service Bus messages of a queue, and invokes the app's FunctionsInvocationHandler with it. The trigger's adapter reads the data before the handler runs, as in a deployed function app.

For --host azure --trigger queue, the hardened-function template writes this handler in src/Orders/OrderHandler.cs:

csharp
using Hardened.Functions.Runtime.Attributes;

namespace Orders;

public class OrderHandler(OrderLog log)
{
    [Queue("orders")]
    public void OnOrder(Order order) => log.Record(order);
}

For --host azure, the template writes this tests/Orders.Tests/Bootstrap.cs:

csharp
using DependencyModules.NSubstitute;
using Hardened.Azure.Functions.Testing;
using Hardened.Functions.Testing;
using Hardened.Shared.Testing.Attributes;
using Orders;

[assembly: HardenedTestEntryPoint(typeof(Application))]
[assembly: FunctionTesting]
[assembly: AzureFunctionsTesting]
[assembly: NSubstituteSupport]

The template's first test is in tests/Orders.Tests/OrderHandlerTests.cs:

csharp
using DependencyModules.xUnit.Attributes;
using Xunit;

namespace Orders.Tests;

public class OrderHandlerTests
{
    [ModuleTest]
    public async Task AMessageReachesTheHandler(Application.Queues queues, OrderLog log)
    {
        await queues.Orders(new Order { Id = "A-1", Quantity = 2 });

        Assert.Equal("A-1", Assert.Single(log.Orders).Id);
    }
}

No Functions host runs in the test. The worker's converters turn what the host sends into the trigger data. [AzureFunctionsTesting] builds the trigger data directly, so the converters do not run. The container tests in the repository run the host.

Setting up a test project ​

[AzureFunctionsTesting] is in the Hardened.Azure.Functions.Testing package, in the namespace of the same name. It goes on the assembly, a class or a method, beside [FunctionTesting]. The package references every Azure adapter package. dotnet new hardened-function --host azure writes the package reference in the test project, and the Bootstrap.cs above.

Testing functions covers [FunctionTesting], the façades, ITriggerDelivery, [PipelineDelivery], how the two attributes combine, and what a failed message does to a call. Under [AzureFunctionsTesting], an ITriggerDelivery parameter is FunctionsTriggerDelivery.

The trigger data for each trigger ​

Under [AzureFunctionsTesting], a façade call hands the function this trigger data:

TriggerInvocationsThe trigger dataValues in it
[Queue], [Topic]One per callA ServiceBusDelivery of ServiceBusReceivedMessages, one per message, built with ServiceBusModelFactoryMessage ids <source>-0, <source>-1 and so on, delivery count 1, content type application/json, sequence numbers from 1, and no application properties. The settlement actions come from the test's container. See Settling Service Bus messages
[Timer]One per message, or one for a call with noneThe timer's state as JSONLast and LastUpdated an hour before the call, Next an hour after it, IsPastDue false. The messages themselves are not delivered
[Change]One per callA JSON array with a document per messageEach document is the message with _rid rid-0, _etag "00000000", _ts the current time and _lsn from 100, in order. A message with no id property gets document-0 and so on
[Stream]One per callAn EventData[] built with EventHubsModelFactory, one per messagePartition key the hub's name, message ids <hub>-0 and so on, sequence numbers from 1, offsets 64, 128 and so on, content type application/json
[Blob]One per messageA BlobClient for the blob, and the blob's properties beside itThe container the source names, the message's name or key or else blob-0 and so on, the message's size or else 0, content type application/octet-stream. Blobs lists the rest
[Event], sent through ITriggerDeliveryOne per message, to the function EventA structured CloudEvent as JSONspecversion 1.0, a new GUID as id, source and type from the path, the current time, datacontenttype application/json, and the message as data
[HardenedFunction]NoneNoneAn Azure app with one fails to build with HRDF001. See Triggers

The delivery writes every message as JSON with camelCase property names. Testing functions covers the serialization.

Queues, Topics, Timers, Events, Changes, Streams and Blobs list the headers that each adapter gives a handler, and what else the trigger data holds. Under [FunctionTesting] alone, a trigger handler's request carries none of these headers. The body is the test's message, so a blob's bucket is empty unless the message sets it.

The delivery drops the leading / of an event's path. An Azure resource ID starts with /, so the delivery does not reach an [Event] whose source is a resource ID. Events shows the failure.

A handler's CancellationToken is never cancelled, under [AzureFunctionsTesting] or under [FunctionTesting] alone.

Settling Service Bus messages ​

With [ServiceBusModule(ReportsItemFailures = true)] on the application, the function completes and abandons each message itself, through the ServiceBusMessageActions that the worker binds. Queues covers the setting. Under [AzureFunctionsTesting], the delivery takes the actions from the test's container.

WARNING

Under [AzureFunctionsTesting], [ServiceBusModule(ReportsItemFailures = true)] does nothing unless the test's container holds a ServiceBusMessageActions. Without one, a failed message fails the call with the handler's exception. The messages after it do not run. The deployed function does not do this, so a test of failed messages passes against the wrong behaviour.

RecordingMessageActions, in Hardened.Azure.Functions.Testing, is a ServiceBusMessageActions that records the id of each message it settles. ServiceBusMessageActions is in the Microsoft.Azure.Functions.Worker namespace, from the worker's Service Bus extension. The testing package brings that extension.

MemberHolds
CompletedThe ids of the messages completed, in order
AbandonedThe ids of the messages abandoned, in order
DeadLetteredThe ids of the messages dead-lettered. The Service Bus adapter never dead-letters
DeferredThe ids of the messages deferred. The Service Bus adapter never defers

This application turns ReportsItemFailures on:

csharp
using Hardened.Azure.Functions.ServiceBus;
using Hardened.Shared.Runtime.Attributes;

namespace Orders;

[HardenedModule]
[ServiceBusModule(ReportsItemFailures = true)]
public partial class Application;

The handler refuses an order with a negative quantity:

csharp
using Hardened.Functions.Runtime.Attributes;

namespace Orders;

public class OrderHandler(OrderLog log)
{
    [Queue("orders")]
    public void OnOrder(Order order)
    {
        if (order.Quantity < 0)
        {
            throw new InvalidOperationException($"refused {order.Id}");
        }

        log.Record(order);
    }
}

The [TestExport] on this test class registers a RecordingMessageActions for its tests. A ServiceBusMessageActions parameter is then that object. Substituting services covers [TestExport].

csharp
using DependencyModules.Testing.Attributes;
using DependencyModules.xUnit.Attributes;
using Hardened.Azure.Functions.Testing;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Orders.Tests;

[TestExport(
    typeof(ServiceBusMessageActions),
    Implementation = typeof(RecordingMessageActions),
    Lifetime = ServiceLifetime.Singleton
)]
public class SettlementTests
{
    [ModuleTest]
    public async Task OnlyTheRefusedMessageIsAbandoned(
        Application.Queues queues,
        ServiceBusMessageActions actions,
        OrderLog log
    )
    {
        await queues.Orders(
            new Order { Id = "A-1", Quantity = 2 },
            new Order { Id = "A-2", Quantity = -1 },
            new Order { Id = "A-3", Quantity = 1 }
        );

        var settled = Assert.IsType<RecordingMessageActions>(actions);

        Assert.Equal(["orders-1"], settled.Abandoned);
        Assert.Equal(["orders-0", "orders-2"], settled.Completed);
        Assert.Equal(2, log.Orders.Count);
    }
}

Every message of the call runs. The adapter abandons the refused message and completes the others. The call returns. The ids are the ones the delivery gives, from the queue's name and the message's position. The trigger data for each trigger lists them.

Without Lifetime = ServiceLifetime.Singleton, the export is transient. The delivery then settles through one instance. The test's parameter is another instance, and it records nothing.

Under [FunctionTesting] alone, the call throws at the refused message and settles nothing.

Invoking a function by hand ​

For a message that the façades do not build, such as one with application properties, its own id or a delivery count above 1, a test can build the trigger data itself and invoke the function through FunctionsInvocationHandler.

TypeNamespaceRole
FunctionsInvocationHandlerHardened.Azure.Functions.Runtime.HostingA test parameter, like any service of the application. Invoke(FunctionsTrigger trigger, FunctionContext functionContext) runs one invocation
FunctionsTriggerHardened.Azure.Functions.Runtime.ExecutionCarries the scheme, the route and the trigger data
ServiceBusDeliveryHardened.Azure.Functions.ServiceBusThe trigger data of a queue or a topic: the messages, and the actions that settle them
TestFunctionContextHardened.Azure.Functions.TestingA FunctionContext built from a function name, the binding data the host would send, and the services to resolve from
ServiceBusModelFactoryAzure.Messaging.ServiceBusIts ServiceBusReceivedMessage method builds a received message

The other triggers take the types listed under The trigger data for each trigger.

This test uses the application and the handler from Settling Service Bus messages:

csharp
using Azure.Messaging.ServiceBus;
using DependencyModules.xUnit.Attributes;
using Hardened.Azure.Functions.Runtime.Execution;
using Hardened.Azure.Functions.Runtime.Hosting;
using Hardened.Azure.Functions.ServiceBus;
using Hardened.Azure.Functions.Testing;
using Xunit;

namespace Orders.Tests;

public class ByHandTests
{
    [ModuleTest]
    public async Task AMessageBuiltByHandIsSettled(
        FunctionsInvocationHandler handler,
        IServiceProvider services,
        OrderLog log
    )
    {
        var messages = new[]
        {
            ServiceBusModelFactory.ServiceBusReceivedMessage(
                body: BinaryData.FromString("""{"id":"A-1","quantity":2}"""),
                messageId: "m-1",
                contentType: "application/json",
                deliveryCount: 5,
                properties: new Dictionary<string, object> { ["tenant"] = "acme" }
            ),
            ServiceBusModelFactory.ServiceBusReceivedMessage(
                body: BinaryData.FromString("""{"id":"A-2","quantity":-1}"""),
                messageId: "m-2",
                contentType: "application/json"
            ),
        };

        var actions = new RecordingMessageActions();

        await handler.Invoke(
            new FunctionsTrigger("QUEUE", "/orders", new ServiceBusDelivery(messages, actions)),
            new TestFunctionContext("Queue_orders", new Dictionary<string, object?>(), services)
        );

        Assert.Equal(["m-1"], actions.Completed);
        Assert.Equal(["m-2"], actions.Abandoned);
        Assert.Equal("A-1", Assert.Single(log.Orders).Id);
    }
}

The scheme and the route are the handler's, QUEUE and /orders. The function name is the one the build writes, Queue_orders. The Azure Overview covers the function names.

The message's id, its delivery count and each application property reach the handler as headers. Queues covers the headers.

In the messageHeader
The idx-azure-servicebus-message-id
The delivery countx-azure-servicebus-delivery-count
Each application propertyOne header per property

A ServiceBusDelivery built without actions settles nothing. A failed message then fails the invocation with the handler's exception.

The test passes under [AzureFunctionsTesting] and under [FunctionTesting] alone, because it does not go through the delivery.

Testing a web application ​

[AzureFunctionsWebTesting] builds each request that a web test makes into the worker's HttpRequestData, and invokes the app's FunctionsInvocationHandler with it. It reads the HttpResponseData that comes back as the test's response. No Functions host runs.

[AzureFunctionsWebTesting] is in Hardened.Azure.Functions.Testing, the same package as [AzureFunctionsTesting]. It goes on a method, a class or the assembly. For --host azure-functions, the hardened-web template writes these lines in tests/Todos.Tests/Bootstrap.cs:

csharp
using Hardened.Azure.Functions.Testing;
using Hardened.Web.Testing;

[assembly: WebTesting]
[assembly: AzureFunctionsWebTesting]

Every test in that project runs through the worker's HTTP adapter. Test hosts covers the attribute beside the other test hosts.

The request's URL is http://functions.test/api/ followed by the path and the query. The path also goes in the binding data under path. The adapter reads it there. A handler sees the path that the test sent, whatever route prefix the host project's host.json sets.

CaseWhat the test sees
The server address and scheme a handler readsfunctions.test and http, with no port
The client address a handler readsThe first address of the test's X-Forwarded-For header, else none
A Cookie header set in the testThe request's cookies, so [FromCookie] binds
A cookie the handler setsA Set-Cookie header on the response
A binary bodyThe bytes the handler wrote
A response compressed by [Compress]Content-Encoding: gzip, and Deserialize<T>() reads it
A typed client over HttpClientReads the body, because Content-Type goes on the response's content

Every request of a test runs in the test's own container, so what one request stores, the next one reads:

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Testing;
using Xunit;

namespace Todos.Tests;

public class TodoFunctionTests
{
    private record NewTodoRequest(string Title);

    private record TodoResponse(int Id, string Title, bool Done);

    [ModuleTest]
    public async Task EveryRequestOfATestSharesItsContainer(ITestWebApp app)
    {
        (await app.Post(new NewTodoRequest("Write a test"), "/todos")).Assert.Ok();

        var todos = (await app.Get("/todos")).Deserialize<List<TodoResponse>>();

        Assert.Equal([1, 2, 3], todos.Select(todo => todo.Id));
    }
}

The template's store starts with two todos, so the POST adds the third. The GET in the same test lists it. On the pipeline host, each request has a container of its own, so the GET lists 1 and 2. The template's ContainerIsolationTests carries [PipelineHost], which keeps that class on the pipeline host. Writing a test covers containers and [PipelineHost].

[AzureFunctionsWebTesting] runs the application's startup services before the first request, as the worker does when it starts.

The attribute does not apply [Grants] or [Subject]. It records no LastResponse. Test hosts covers both.

Checking the function metadata ​

The build describes the app's functions twice. Hardened's generator writes a metadata provider, which the host indexes. The Worker SDK's build task writes a functions.metadata file from the same functions. The Azure Overview covers the provider and functions.metadata.

MetadataAgreement.Disagreements(provider) compares the two and returns what differs. It compares the function names, then each function's entryPoint, scriptFile, language and bindings. It compares the bindings as JSON, whatever the order of their keys. An empty list is agreement.

The provider's name is the application class's name followed by AzureFunctionMetadataProvider, such as ApplicationAzureFunctionMetadataProvider. Its namespace is the application's namespace followed by .Generated, such as Orders.Generated. This test checks the Orders app:

csharp
using Hardened.Azure.Functions.Testing;
using Orders.Generated;
using Xunit;

namespace Orders.Tests;

public class MetadataAgreementTests
{
    [Fact]
    public async Task TheProviderAndTheBuildTaskAgree()
    {
        Assert.Empty(
            await MetadataAgreement.Disagreements(new ApplicationAzureFunctionMetadataProvider())
        );
    }
}

The test is a plain xUnit [Fact]. It needs no container. Without a directory argument, Disagreements reads functions.metadata from the test's output directory. The build copies the referenced application's file there. With no file there, Disagreements returns one disagreement: the text The build task wrote no functions.metadata at, followed by the path.

Container tests in the repository ​

Hardened.Simulators.slnx, at the root of the repository, holds the tests that run the repository's Azure fixtures in the Functions host. Its Azure folder has three test projects, under src/Clouds/Azure/IntegrationTests.

ProjectTest classThe fixtureWhat the tests send and check
Hardened.IntegrationTests.AzureQueue.Host.TestsHostImageTestsA queue handlerThe host indexes Queue_orders and nothing else. A message published to the emulator reaches the handler. The host logs that the invocation succeeded. A message the handler refuses fails the invocation and is delivered again
Hardened.IntegrationTests.AzureEvents.Host.TestsHostImageTestsA queue, a topic, a timer and an [Event] handler in one appThe host indexes Event, Queue_orders_new, Timer_nightly_rollup and Topic_order_events. A message published to the topic and one published to the queue each reach their own handler
Hardened.IntegrationTests.AzureStream.Host.TestsHostImageTestsA stream handlerThe host indexes Stream_clickstream and nothing else. Two events published in one batch reach the handler in order

Each test mounts a fixture's build output at /home/site/wwwroot in the image mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0. The Functions host in that image indexes the functions that the generated provider declares. Azurite 3.35.0 is the storage account that the host needs. The Service Bus emulator 2.0.1, with the SQL Server it keeps its state in, or the Event Hubs emulator 2.2.1, is the event source. The containers share a Docker network. The host reaches the emulators by network alias.

The tests publish with the Azure SDK's own clients, ServiceBusClient and EventHubProducerClient. A [Mock] cannot reach into another process, so the tests read what a handler did from the lines that the fixture prints with the prefix HARDENED-OBSERVED . They also read the host's own log: the function list under Found the following functions:, and Executed 'Functions.<name>' (Succeeded or (Failed.

dotnet test on one of the projects runs it. The tests need Docker. This command, from the root of the repository, runs the stream project:

bash
dotnet test src/Clouds/Azure/IntegrationTests/Stream/Hardened.IntegrationTests.AzureStream.Host.Tests

Without a Docker daemon, the tests fail instead of skipping. The host image is published for amd64 only, so an Apple Silicon machine runs it under emulation. The harness, Hardened.Functions.Testing.Containers, is not a package.

No container test sends anything to a blob, change feed, timer, Event Grid or web function. The events project checks that the host indexes its timer and Event functions. It sends them nothing. The blob, change feed and web fixtures run in-process only.

Next ​

PageCovers
Testing functionsThe façades, [FunctionTesting], [PipelineDelivery] and failed messages in a test
Test hostsEvery test host, [AzureFunctionsWebTesting] among them
QueuesReportsItemFailures, and the headers a Service Bus message gives a handler
Web applicationsWhat a deployed web function app does not run
OverviewThe functions the build writes, running a function app locally, and deploying it

Released under the MIT License.