Vincent Bitter
Vincent Bitter

Reputation: 1470

Testing an Azure Function in .NET 5

I've started developing Azure Functions and now I want to create my first unit/integration test, but I'm completely stuck. Although I have a very simple Function with an HTTP Trigger and HTTP and Storage Queue output, it seems ridiculously complex te test this.

The code (simplified):

public class MyOutput
{
    [QueueOutput("my-queue-name", Connection = "my-connection")]
    public string QueueMessage { get; set; }

    public HttpResponseData HttpResponse { get; set; }
}

public static class MyFunction
{
    [Function(nameof(MyFunction))]
    public static async Task<MyOutput> Run(
        [HttpTrigger(AuthorizationLevel.Function, "POST")] HttpRequestData req,
        FunctionContext executionContext)
    {
        var logger = executionContext.GetLogger(nameof(MyFunction));
        logger.LogInformation("Received {Bytes} bytes", req.Body.Length);
        //implementation
    }
}

Now I'd expect to build a test like this:

public async Task Test()
{
    var response = await MyFunction.Run(..., ...);
    Assert.IsNotNull(response);
}

After looking hours on the internet to find a good approach, I still didn't find a way to mock HttpRequestData and FunctionContext. I also looked for a full integration test by setting up a server, but this seems really complex. The only thing I ended up was this: https://github.com/Azure/azure-functions-dotnet-worker/blob/72b9d17a485eda1e6e3626a9472948be1152ab7d/test/E2ETests/E2ETests/HttpEndToEndTests.cs

Does anyone have experience testing Azure Functions in .NET 5, who can give me a push in the right direction? Are there any good articles or examples on how to test an Azure Function in dotnet-isolated?

Upvotes: 38

Views: 20129

Answers (5)

Flatly-stacked
Flatly-stacked

Reputation: 649

For what it's worth, this is my implementation using .NET 7 and Azure Functions v4. It expands on the work of @VincentBitter and is a workable, multi-request capable HttpClientFactory mock.

The main thing to note here is the addition of the

serviceCollection.AddFunctionsWorkerDefaults();

Unit Test Mock Setup


MockHttpClientFactory

public class MockHttpClientFactory
{
    public static IHttpClientFactory Create(string name, MockHttpResponse response)
    {
        return Create(name, new List<MockHttpResponse> { response });
    }


    public static IHttpClientFactory Create(string name, List<MockHttpResponse> responses)
    {
                    
        Mock<HttpMessageHandler> messageHandler = SendAsyncHandler(responses);

        var mockHttpClientFactory = new Mock<IHttpClientFactory>();

        mockHttpClientFactory
            .Setup(x => x.CreateClient(name))
            .Returns(new HttpClient(messageHandler.Object)
            {
                BaseAddress = new Uri("https://mockdomain.mock")
            });

        return mockHttpClientFactory.Object;
    }


    private static Mock<HttpMessageHandler> SendAsyncHandler(List<MockHttpResponse> responses)
    {
        var messageHandler = new Mock<HttpMessageHandler>(MockBehavior.Strict);

        foreach(var response in responses)
        {
            messageHandler
                .Protected()
                .Setup<Task<HttpResponseMessage>>("SendAsync",
                    ItExpr.Is<HttpRequestMessage>(r => r.RequestUri!.PathAndQuery == response.UrlPart),
                    ItExpr.IsAny<CancellationToken>())
                .ReturnsAsync(new HttpResponseMessage
                {
                    StatusCode = response.StatusCode,
                    Content = (response.Response?.GetType() == typeof(string)
                        ? new StringContent(response.Response?.ToString() ?? "")
                        : new StringContent(JsonSerializer.Serialize(response.Response)))
                })
                .Verifiable();
        }               

        return messageHandler;
    }
}

MockHttpResponse

public class MockHttpResponse
{
    public MockHttpResponse()
    {           
    }

    public MockHttpResponse(string urlPart, object response, HttpStatusCode statusCode)
    {
        this.UrlPart = urlPart;
        this.Response = response;
        this.StatusCode = statusCode;
    }


    public string UrlPart { get; set; } = String.Empty;

    public object Response { get; set; } = default!;

    public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
}

MockHttpRequestData

public class MockHttpRequestData
{ 
    public static HttpRequestData Create()
    {
        return Create<string>("");
    }   
    

    public static HttpRequestData Create<T>(T requestData) where T : class
    {
        var serviceCollection = new ServiceCollection();
        serviceCollection.AddFunctionsWorkerDefaults();

        var serializedData = JsonSerializer.Serialize(requestData);
        var bodyDataStream = new MemoryStream(Encoding.UTF8.GetBytes(serializedData));

        var context = new Mock<FunctionContext>();
        context.SetupProperty(context => context.InstanceServices, serviceCollection.BuildServiceProvider());

        var request = new Mock<HttpRequestData>(context.Object);
        request.Setup(r => r.Body).Returns(bodyDataStream);
        request.Setup(r => r.CreateResponse()).Returns(new MockHttpResponseData(context.Object));

        return request.Object;
    }
}

MockHttpResponseData

public class MockHttpResponseData : HttpResponseData
{
    public MockHttpResponseData(FunctionContext functionContext) : base(functionContext)
    {           
    }
    

    public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;

    public override HttpHeadersCollection Headers { get; set; } = new HttpHeadersCollection();

    public override Stream Body { get; set; } = new MemoryStream();

    public override HttpCookies Cookies { get; }
}

Usage


The Azure Function Method

This azure function has been setup with DI and uses an HttpClient object. Details are out of scope for this post. You can Google for more info.

public class Function1
{
    private readonly HttpClient httpClient;


    public Function1(IHttpClientFactory httpClientFactory)
    {
        this.httpClient = httpClientFactory.CreateClient("WhateverYouNamedIt");
    }



    [Function("Function1")]
    public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req)
    {
        var httpResponse = await this.httpClient.GetAsync("/some-path");
        var httpResponseContent = await httpResponse.Content.ReadAsStringAsync();

        // do something with the httpResponse or Content

        var response = req.CreateResponse(HttpStatusCode.OK);
        await response.WriteStringAsync(httpResponseContent);
        
        return response;
    }               
}

Simple Use Case

public class UnitTest1
{
    [Fact]
    public void Test1()
    {
        var httpClientFactory = MockHttpClientFactory.Create("WhateverYouNamedIt", new MockHttpResponse());

        var exception = Record.Exception(() => new Function1(httpClientFactory));

        Assert.Null(exception);
    }
}

More Realistic Use Case

    [Fact]
    public async Task Test2()
    {
        var httpResponses = new List<MockHttpResponse>
        {
            new MockHttpResponse
            {
                UrlPart = "/some-path",
                Response = new { Name = "data" }
            }
        };

        var httpClientFactory = MockHttpClientFactory.Create("WhateverYouNamedIt", httpResponses);
        var httpRequestData = MockHttpRequestData.Create();

        var function1 = new Function1(httpClientFactory);
        var function1Response = await function1.Run(httpRequestData);
        function1Response.Body.Position = 0;

        using var streamReader = new StreamReader(function1Response.Body);
        var function1ResponseBody = await streamReader.ReadToEndAsync();
                
        Assert.Equal("{\"Name\":\"data\"}", function1ResponseBody);
    }

Upvotes: 4

cuasiJoe
cuasiJoe

Reputation: 1149

You can mock the context and provide only what you will use from it. Here you have an approach using NSubstitute to obtain an ILogger you can customize as you need:

[TestClass]
public class FunctionTest
{
    private static readonly FunctionContext _context = Substitute.For<FunctionContext>();
    private static readonly ILogger _logger = Substitute.For<ILogger>();

    [ClassInitialize]
    public static void ClassSetupAsync(TestContext _)
    {
        // create a mock log factory that returns a mocked logger
        var logFactory = Substitute.For<ILoggerFactory>();
        logFactory
            .CreateLogger(Arg.Any<string>())
            .Returns(_logger);

        // create a mock service provider that knows only about logs
        var services = Substitute.For<IServiceProvider>();
        services
            .GetService(Arg.Any<Type>())
            .Returns(logFactory);

        // use the mocked service provider in the mocked context
        // you can pass this context to your Azure Function
        _context
            .InstanceServices
            .Returns(services);
    }
}

Upvotes: 1

Vincent Bitter
Vincent Bitter

Reputation: 1470

Solution 1

I was finally able to mock the whole thing. Definitely not my best work and can use some refactoring, but at least I got a working prototype:

var serviceCollection = new ServiceCollection();
serviceCollection.AddScoped<ILoggerFactory, LoggerFactory>();
var serviceProvider = serviceCollection.BuildServiceProvider();

var context = new Mock<FunctionContext>();
context.SetupProperty(c => c.InstanceServices, serviceProvider);

var byteArray = Encoding.ASCII.GetBytes("test");
var bodyStream = new MemoryStream(byteArray);

var request = new Mock<HttpRequestData>(context.Object);
request.Setup(r => r.Body).Returns(bodyStream);
request.Setup(r => r.CreateResponse()).Returns(() =>
{
    var response = new Mock<HttpResponseData>(context.Object);
    response.SetupProperty(r => r.Headers, new HttpHeadersCollection());
    response.SetupProperty(r => r.StatusCode);
    response.SetupProperty(r => r.Body, new MemoryStream());
    return response.Object;
});

var result = await MyFunction.Run(request.Object, context.Object);
result.HttpResponse.Body.Seek(0, SeekOrigin.Begin);
var reader = new StreamReader(result.HttpResponse.Body);
var responseBody = await reader.ReadToEndAsync();

Assert.IsNotNull(result);
Assert.AreEqual(HttpStatusCode.OK, result.HttpResponse.StatusCode);
Assert.AreEqual("Hello test", responseBody);

Solution 2

I added the Logger via Dependency Injection and created my own implementations for HttpRequestData and HttpResponseData. This is way easier to re-use and makes the tests itself cleaner.

public class FakeHttpRequestData : HttpRequestData
{
        public FakeHttpRequestData(FunctionContext functionContext, Uri url, Stream body = null) : base(functionContext)
    {
        Url = url;
        Body = body ?? new MemoryStream();
    }

    public override Stream Body { get; } = new MemoryStream();

    public override HttpHeadersCollection Headers { get; } = new HttpHeadersCollection();

    public override IReadOnlyCollection<IHttpCookie> Cookies { get; }

    public override Uri Url { get; }

    public override IEnumerable<ClaimsIdentity> Identities { get; }

    public override string Method { get; }

    public override HttpResponseData CreateResponse()
    {
        return new FakeHttpResponseData(FunctionContext);
    }
}

public class FakeHttpResponseData : HttpResponseData
{
    public FakeHttpResponseData(FunctionContext functionContext) : base(functionContext)
    {
    }

    public override HttpStatusCode StatusCode { get; set; }
    public override HttpHeadersCollection Headers { get; set; } = new HttpHeadersCollection();
    public override Stream Body { get; set; } = new MemoryStream();
    public override HttpCookies Cookies { get; }
}

Now the test looks like this:

// Arrange
var body = new MemoryStream(Encoding.ASCII.GetBytes("{ \"test\": true }"))
var context = new Mock<FunctionContext>();
var request = new FakeHttpRequestData(
                context.Object, 
                new Uri("https://stackoverflow.com"), 
                body);

// Act
var function = new MyFunction(new NullLogger<MyFunction>());
var result = await function.Run(request);
result.HttpResponse.Body.Position = 0;

// Assert
var reader = new StreamReader(result.HttpResponse.Body);
var responseBody = await reader.ReadToEndAsync();
Assert.IsNotNull(result);
Assert.AreEqual(HttpStatusCode.OK, result.HttpResponse.StatusCode);
Assert.AreEqual("Hello test", responseBody);

Upvotes: 60

Ama
Ama

Reputation: 1565

To complement what you have been doing, I was trying to mock GetLogger() so that I could inject ILogger; unlucky enough, GetLogger() is an extension (static) so it cannot be mocked through reflection. I am now on the way to mocking the fields which are used by the GetLogger() extension (.Net source code).

That would look like this:

using Mock;
using Microsoft.Extensions.DependencyInjection;

public static FunctionContext CreateFunctionContext(ILogger logger = null)
    {
        
        logger = logger ?? CreateNullLogger();

        var LoggerFactory = new Mock<ILoggerFactory>();
        LoggerFactory.Setup(p => p.CreateLogger(It.IsAny<string>())).Returns(logger);

        var InstanceServices = new Mock<IServiceProvider>();
        InstanceServices.Setup(p => p.GetService(It.IsAny<Type>())).Returns(LoggerFactory.Object);

        var context = new Mock<FunctionContext>();
        context.Setup(p => p.InstanceServices).Returns(InstanceServices.Object);
        return context.Object;

    }

Upvotes: 4

Thomas Zeman
Thomas Zeman

Reputation: 910

Calling the Run method of your Function is more a unit than an integration test. Think about some middleware you have set up or authentication that will not run when calling the Function directly.

However, it seems, currently, there is no elegant, in-process way to do integration testing with Azure Isolated Functions based on .NET 5. Refer to: https://github.com/Azure/azure-functions-dotnet-worker/issues/541 and https://github.com/Azure/azure-functions-dotnet-worker/issues/281

I had some success by starting the Azure Function Host "func" locally, hosting my functions and then using a normal HttpClient from the Test itself. Before that, you can set up your other dependencies which you don't want to mock, such as Azurite and some blob data.

Upvotes: 0

Related Questions