/AzureFunctions.TestHelpers

Test your Azure Functions! ⚡

Primary LanguageC#

Build status codecov nuget

AzureFunctions.TestHelpers ⚡

Test your Azure Functions! Spin up integration tests. By combining bits and pieces of the WebJobs SDK, Azure Functions and Durable Functions and adding some convenience classes and extension methods on top.

You'll ❤ the feedback!

Updates

  • v4.0: Update to Azure Functions SDK v4
  • v3.3: Allow to pass a retry delay on Wait and Ready methods
  • v3.2: Updated dependencies, Ready also ignored durable entities
  • v3.1: WaitFor to better support durable entities
  • v3.0: Upgrade to durable task v2
  • v2.1: Removed AddDurableTaskInTestHub
  • v2.0: Wait, ThrowIfFailed and Purge separated.

Use Startup class

If only inheriting from IWebJobsStartup

new HostBuilder()
    .ConfigureWebJobs(builder => builder
        .UseWebJobsStartup<Startup>())
    .Build();

If inheriting from FunctionsStartup

new HostBuilder()
    .ConfigureWebJobs(builder => builder
        .UseWebJobsStartup(typeof(Startup), new WebJobsBuilderContext(), NullLoggerFactory.Instance))
    .Build();

Configure Services for Dependency Injection

I just found out the default ConfigureServices on the HostBuilder also works. But if it makes more sense to you to configure services on the WebJobsBuilder since you also configure the Startup there you can use:

mock = Substitute.For<IInjectable>();
host = new HostBuilder()
    .ConfigureWebJobs(builder => builder
        .UseWebJobsStartup<Startup>()
        .ConfigureServices(services => services.Replace(ServiceDescriptor.Singleton(mock))))
    .Build();

Register and replace services that are injected into your functions. Include Microsoft.Azure.Functions.Extensions in your test project to enable dependency injection!

Note: Not sure if this is still a requirement for Azure Functions >= v2.0.

HTTP Triggered Functions

Invoke a regular http triggered function:

[Fact]
public static async Task HttpTriggeredFunctionWithDependencyReplacement()
{
    // Arrange
    var mock = Substitute.For<IInjectable>();
    using (var host = new HostBuilder()
        .ConfigureWebJobs(builder => builder
            .AddHttp()
            .ConfigureServices(services => services.AddSingleton(mock)))
        .Build())
    {
        await host.StartAsync();
        var jobs = host.Services.GetService<IJobHost>();

        // Act
        await jobs.CallAsync(nameof(DemoHttpFunction), new Dictionary<string, object>
        {
            ["request"] = new DummyHttpRequest()
        });

        // Assert
        mock
            .Received()
            .Execute();
    }
}

HTTP Request

Because you can't invoke an HTTP-triggered function without a request, and I couldn't find one in the standard libraries, I created the DummyHttpRequest.

await jobs.CallAsync(nameof(DemoInjection), new Dictionary<string, object>
{
    ["request"] = new DummyHttpRequest("{ \"some-key\": \"some value\" }")
});

New: Now you can set string content via the constructor overload!

You can set all kinds of regular settings on the request when needed:

var request = new DummyHttpRequest
{
    Scheme = "http",
    Host = new HostString("some-other"),
    Headers = {
        ["Authorization"] = $"Bearer {token}",
        ["Content-Type"] =  "application/json"
    }
};

New: Now you can use a DummyQueryCollection to mock the url query:

var request = new DummyHttpRequest
{
    Query = new DummyQueryCollection
    {
        ["firstname"] = "Jane",
        ["lastname"] = "Doe"
    }
};

HTTP Response

To capture the result(s) of http-triggered functions you use the options.SetResponse callback on the AddHttp extension method:

// Arrange
var observer = Observer.For<object>();

using (var host = new HostBuilder()
    .ConfigureWebJobs(builder => builder
        .AddHttp(options => options.SetResponse = (_, o) => observer.Add(o)))
    .Build())
{
    await host.StartAsync();
    var jobs = host.Services.GetService<IJobHost>();

    // Act
    await jobs.CallAsync(nameof(DemoHttpFunction), new Dictionary<string, object>
    {
        ["request"] = new DummyHttpRequest()
    });
}

// Assert
await Hypothesis
    .On(observer)
    .Timebox(2.Seconds())
    .Any()
    .Match(o => o is OkResult)
    .Validate();

I'm using Hypothesist for easy async testing.

Durable Functions

Invoke a (time-triggered) durable function:

[Fact]
public static async Task DurableFunction()
{
    // Arrange
    var mock = Substitute.For<IInjectable>();
    using (var host = new HostBuilder()
        .ConfigureWebJobs(builder => builder
            .AddDurableTask(options => options.HubName = nameof(MyTestFunction))
            .AddAzureStorageCoreServices()
            .ConfigureServices(services => services.AddSingleton(mock)))
        .Build())
    {
        await host.StartAsync();
        var jobs = host.Services.GetService<IJobHost>();
        await jobs.
            Terminate()
            .Purge();

        // Act
        await jobs.CallAsync(nameof(DemoStarter), new Dictionary<string, object>
        {
            ["timerInfo"] = new TimerInfo(new WeeklySchedule(), new ScheduleStatus())
        });

        await jobs
            .Ready()
            .ThrowIfFailed()
            .Purge();

        // Assert
        mock
            .Received()
            .Execute();
    }
}

You'll have to configure Azure WebJobs Storage to run durable functions!

Time Triggered Functions

Do NOT add timers to the web jobs host!

using (var host = new HostBuilder()
       .ConfigureWebJobs(builder => builder
           //.AddTimers() <-- DON'T ADD TIMERS
           .AddDurableTask(options => options.HubName = nameof(MyTestFunction))
           .AddAzureStorageCoreServices()
           .ConfigureServices(services => services.AddSingleton(mock)))
       .Build())
   {
   }
}

It turns out it is not required to invoke time-triggered functions, and by doing so your functions will be triggered randomly, messing up the status of your orchestration instances.

Isolate Durable Functions

Add and configure Durable Functions using the durable task extensions and use a specific hub name to isolate from other parallel tests.

host = new HostBuilder()
    .ConfigureWebJobs(builder => builder
        .AddDurableTask(options => options.HubName = nameof(MyTestFunction))
        .AddAzureStorageCoreServices()
    .Build();

BREAKING: In v2.1 I removed the AddDurableTaskInTestHub() method. You can easily do it yourself with AddDurableTask(options => ...) and be more specific about the context of your test. This way, you don't end up with hundreds of empty history and instance tables in your storage account.

Cleanup

await jobs
    .Terminate()
    .Purge();

To cleanup from previous runs, you terminate leftover orchestrations and durable entities and purge the history.

WaitFor

await jobs
    .WaitFor(nameof(DemoOrchestration), TimeSpan.FromSeconds(30))
    .ThrowIfFailed();

With the WaitFor you specify what orchestration you want to wait for. You can either use the Ready function if you just want all orchestrations to complete.

Ready

await jobs
    .Ready(TimeSpan.FromSeconds(30))
    .ThrowIfFailed();

The Ready function is handy if you want to wait for termination.

BREAKING: In v2 the WaitForOrchestrationsCompletion is broken down into Wait(), ThrowIfFailed() and Purge().

Reuse

When injecting a configured host into your test, make sure you do NOT initialize nor clean it in the constructor. For example, when using xUnit you use the IAsyncLifetime for that, otherwise your test will probably hang forever.

Initialize and start the host in a fixture:

public class HostFixture : IDisposable, IAsyncLifetime
{
    private readonly IHost _host;
    public IJobHost Jobs => _host.Services.GetService<IJobHost>();

    public HostFixture() =>
        _host = new HostBuilder()
            .ConfigureWebJobs(builder => builder
                .AddDurableTask(options => options.HubName = nameof(MyTest))
                .AddAzureStorageCoreServices())
            .Build();

    public void Dispose() => 
        _host.Dispose();

    public Task InitializeAsync() => 
        _host.StartAsync();

    public Task DisposeAsync() => 
        Task.CompletedTask;
}

Inject and cleanup the host in the test class:

public class MyTest : IClassFixture<HostFixture>, IAsyncLifetime
{
    private readonly HostFixture _host;

    public MyTest(HostFixture host) =>
        _host = host;

    public Task InitializeAsync() => 
        _host.Jobs
            .Terminate()
            .Purge();

    public Task DisposeAsync() => 
        Task.CompletedTask;
}

But please, don't to do a ConfigureAwait(false).GetAwaiter().GetResult().

Using ConfigureAwait(false) to avoid deadlocks is a dangerous practice. You would have to use ConfigureAwait(false) for every await in the transitive closure of all methods called by the blocking code, including all third- and second-party code. Using ConfigureAwait(false) to avoid deadlock is at best just a hack).

Don’t block on async code.

Queue Triggered Functions

// Arrange
using (var host = new HostBuilder()
    .ConfigureWebJobs(builder => builder
        .AddAzureStorageQueues()
        .ConfigureServices(services => services.AddSingleton(mock)))
    .Build())
{
    await host.StartAsync();
    var jobs = host.Services.GetService<IJobHost>();

    // Act
    await jobs.CallAsync(nameof(DemoQueueFunction), new Dictionary<string, object>
    {
        ["queueItem"] = ""
    });
}

Azure Storage Account

You need an azure storage table to store the state of the durable functions. The two options currently are Azure and the Azurite.

Option 1: Azure

Just copy the connection string from your storage account, works everywhere.

Option 2: Azurite

azurite@v3 does have the required features implemented now!

See test and fixture for using docker to host azurite in a container, or checkout the docs on how to run it on your system..

Set the Storage Connection String

The storage connection string setting is required.

Option 1: with an environment variable

Set the environment variable AzureWebJobsStorage. Hereby you can also overwrite the configured connection from option 2 on your local dev machine.

Option 2: with a configuration file

Include an appsettings.json in your test project:

{
  "AzureWebJobsStorage": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...==;EndpointSuffix=core.windows.net"
}

and make sure it is copied to the output directory:

<ItemGroup>
    <Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Happy coding!