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!
- 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.
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();
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
.
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();
}
}
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"
}
};
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.
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!
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.
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.
await jobs
.Terminate()
.Purge();
To cleanup from previous runs, you terminate leftover orchestrations and durable entities and purge the history.
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.
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()
.
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).
// 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"] = ""
});
}
You need an azure storage table to store the state of the durable functions. The two options currently are Azure and the Azurite.
Just copy the connection string from your storage account, works everywhere.
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..
The storage connection string setting is required.
Set the environment variable AzureWebJobsStorage
. Hereby you can also overwrite the configured connection from option 2 on your local dev machine.
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!