I’ve been building up the infrastructure one could use to write request tests for a C# Azure Functions API. My goal is to write tests that are as close to end-to-end as I can get while still staying in the familiar confines of the xUnit test suite (and the productivity that comes from having the tests written in the same language, running in the same IDE, and using familiar tooling).
Here’s what I’ve done so far:
- Setting Up an Azure Functions Dependency Injection Context
- Testing JSON Input/Output in Azure Functions Request Tests
- Using Reflection to Find Azure Function Methods
Today, I’m going to bring it all together and show how to invoke an Azure Function (given just an HttpRequest
) and return an HttpResponse
as a result.
1. Route Matching
In the last post, I showed how to extract an AzureFunctionInfo
object for each HTTP-triggered method in a class. Here’s the AzureFunctionInfo
again:
public class AzureFunctionInfo
{
public string Route { get; set; }
public string[] HttpMethods { get; set; }
public MethodInfo MethodInfo { get; set; }
}
To determine which AzureFunctionInfo
should be used, we need to match the route and methods to the incoming HttpRequest
. For this, I slightly modified some code from Mark Vincze’s article on Matching route templates manually in ASP.NET Core:
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Template;
// Originally from: https://blog.markvincze.com/matching-route-templates-manually-in-asp-net-core/
public static class RouteMatcher
{
public static RouteValueDictionary Match(string routeTemplate, string requestPath)
{
var template = TemplateParser.Parse(routeTemplate);
var matcher = new TemplateMatcher(template, GetDefaults(template));
var values = new RouteValueDictionary();
return matcher.TryMatch(requestPath, values) ? values : null;
}
private static RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate)
{
var result = new RouteValueDictionary();
foreach (var parameter in parsedTemplate.Parameters)
{
if (parameter.DefaultValue != null)
{
result.Add(parameter.Name, parameter.DefaultValue);
}
}
return result;
}
}
It takes a “route template” (e.g., /users/{id}
) and the request path, and it returns a RouteValueDictionary
or null
, depending on whether or not the path matches the template.
In addition to checking the route, the HTTP method also needs to match in order to locate the correct function:
public Task<HttpResponse> MakeRequest(
HttpRequest request,
IEnumerable<AzureFunctionInfo> functionInfos,
IHost host) {
foreach (var functionInfo in functionInfos)
{
var routeValueDictionary = RouteMatcher.Match(functionInfo.Route, request.Path);
if (routeValueDictionary == null || !functionInfo.HttpMethods.Contains(request.Method))
{
continue;
}
// Found the functionInfo!
...
2. Finding the Azure Function Class
In the first post in this series, I showed how to build an IHost
instance with a dependency injection context configured with the classes that hold the HTTP-triggered methods.
We’ll use that host to get an instance of the class that’s going to be handling the request:
...
var functionObject = host.Services.GetService(functionInfo.MethodInfo.DeclaringType);
// Additionally, need to set the DI context inside the request
request.HttpContext.RequestServices = host.Services;
...
3. Interpreting Parameters
The Azure Functions framework can automatically convert path and query parameters from the incoming request into arguments that are passed to the HTTP-triggered method. The GetUser
example from my previous post is an example of this, taking an id
argument.
This code inspects the arguments for the function that we’re going to call and determines what the appropriate value should be:
...
var jsonOptions = host.Services.GetService>();
var args = await Task.WhenAll(functionInfo.MethodInfo.GetParameters().Select<ParameterInfo, Task<object>>(async paramInfo =>
{
if (paramInfo.Name == null)
{
return null;
}
if (paramInfo.ParameterType.IsPrimitive)
{
var value = routeValueDictionary[paramInfo.Name];
var primitiveValue = value != null ? Convert.ChangeType(value, paramInfo.ParameterType) : Activator.CreateInstance(paramInfo.ParameterType);
return primitiveValue;
}
if (paramInfo.ParameterType == typeof(HttpRequest))
{
return request;
}
if (paramInfo.ParameterType == typeof(string))
{
return (string)routeValueDictionary[paramInfo.Name];
}
if (paramInfo.ParameterType == typeof(ILogger))
{
return host.Services.GetRequiredService<ILoggerFactory>().CreateLogger(FunctionType);
}
// If we get to here, it must be the request body
var bodyText = request.Body != null ? await new StreamReader(request.Body).ReadToEndAsync() : string.Empty;
return JsonConvert.DeserializeObject(bodyText, paramInfo.ParameterType, jsonOptions.Value.SerializerSettings);
}));
...
4. Calling the Function
We’re finally ready to call the HTTP-triggered method:
...
var actionResult = await functionInfo.MethodInfo.Invoke(functionObject, args) as Task;
request.HttpContext.Response.Body = new MemoryStream();
if (actionResult is NoContentResult)
{
return request.HttpContext.Response;
}
// Serialize the result into JSON in the response body
var context = new ActionContext(request.HttpContext, request.HttpContext.GetRouteData(), new ActionDescriptor());
await actionResult.ExecuteResultAsync(context);
// Rewind the stream so the body can be read
request.HttpContext.Response.Body.Position = 0;
return request.HttpContext.Response;
}
throw new Exception($"Unable to handle: ${request.Path}");
}
The ActionContext
code at the end is discussed in Testing JSON Input/Output in Azure Functions Request Tests.
There’s a fair amount of glue code you need to put in place, but I believe I’ve achieved what I had initially set out to do. I’m able to write end-to-end tests for my Azure Functions API in C#, and I can do it within the same testing framework we’d already been using for unit tests.