Skip to content

Commit

Permalink
Add Azure Functions unit testing sample (#312)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidmrdavid authored May 17, 2024
1 parent 7eef072 commit 23eb257
Show file tree
Hide file tree
Showing 5 changed files with 258 additions and 6 deletions.
17 changes: 12 additions & 5 deletions Microsoft.DurableTask.sln
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,22 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebAPI", "samples\WebAPI\We
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared", "src\Shared\Shared.csproj", "{57A4C812-B0D9-49E9-9EBE-7E94D3D78ED7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "misc", "misc\misc.csproj", "{1E135970-60CF-470A-9270-4560BFA0A7DF}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "misc", "misc\misc.csproj", "{1E135970-60CF-470A-9270-4560BFA0A7DF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.OrchestrationServiceClientShim", "src\Client\OrchestrationServiceClientShim\Client.OrchestrationServiceClientShim.csproj", "{505F6151-6E36-4E0A-A740-14751B8A9397}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client.OrchestrationServiceClientShim", "src\Client\OrchestrationServiceClientShim\Client.OrchestrationServiceClientShim.csproj", "{505F6151-6E36-4E0A-A740-14751B8A9397}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.OrchestrationServiceClientShim.Tests", "test\Client\OrchestrationServiceClientShim.Tests\Client.OrchestrationServiceClientShim.Tests.csproj", "{93E3B973-0FC4-4241-B7BB-064FB538FB50}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client.OrchestrationServiceClientShim.Tests", "test\Client\OrchestrationServiceClientShim.Tests\Client.OrchestrationServiceClientShim.Tests.csproj", "{93E3B973-0FC4-4241-B7BB-064FB538FB50}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Grpc", "src\Grpc\Grpc.csproj", "{44AD321D-96D4-481E-BD41-D0B12A619833}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Grpc", "src\Grpc\Grpc.csproj", "{44AD321D-96D4-481E-BD41-D0B12A619833}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "test\Benchmarks\Benchmarks.csproj", "{82C0CD7D-2764-421A-8256-7E2304D5A6E7}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmarks", "test\Benchmarks\Benchmarks.csproj", "{82C0CD7D-2764-421A-8256-7E2304D5A6E7}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzers", "src\Analyzers\Analyzers.csproj", "{998E9D97-BD36-4A9D-81FC-5DAC1CE40083}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzers.Tests", "test\Analyzers.Tests\Analyzers.Tests.csproj", "{541FCCCE-1059-4691-B027-F761CD80DE92}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureFunctionsApp.Tests", "samples\AzureFunctionsUnitTests\AzureFunctionsApp.Tests.csproj", "{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -179,6 +181,10 @@ Global
{541FCCCE-1059-4691-B027-F761CD80DE92}.Debug|Any CPU.Build.0 = Debug|Any CPU
{541FCCCE-1059-4691-B027-F761CD80DE92}.Release|Any CPU.ActiveCfg = Release|Any CPU
{541FCCCE-1059-4691-B027-F761CD80DE92}.Release|Any CPU.Build.0 = Release|Any CPU
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -213,6 +219,7 @@ Global
{82C0CD7D-2764-421A-8256-7E2304D5A6E7} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
{998E9D97-BD36-4A9D-81FC-5DAC1CE40083} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
{541FCCCE-1059-4691-B027-F761CD80DE92} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}
Expand Down
1 change: 1 addition & 0 deletions samples/AzureFunctionsApp/AzureFunctionsApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public static async Task<List<string>> RunOrchestrator(
{
ILogger logger = context.CreateReplaySafeLogger(nameof(AzureFunctionsApp));
logger.LogInformation("Saying hello.");

var outputs = new List<string>();

// Replace name and input with values relevant for your Durable Functions Activity
Expand Down
28 changes: 28 additions & 0 deletions samples/AzureFunctionsUnitTests/AzureFunctionsApp.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="moq" Version="4.20.70" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AzureFunctionsApp\AzureFunctionsApp.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
216 changes: 216 additions & 0 deletions samples/AzureFunctionsUnitTests/SampleUnitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Company.Function; // same namespace as the Azure Functions app

using System.IO;
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;

public class SampleUnitTests
{
[Fact]
public async Task OrchestrationReturnsMultipleGreetings()
{
// create mock orchestration context, and mock ILogger.
Mock<TaskOrchestrationContext> contextMock = new();

// a simple ILogger that captures emitted logs in a list
TestLogger logger = new();

// The DurableTaskClient CreateReplaySafeLogger API obtains a logger from a protected LoggerFactory property, we mock it here
Mock<ILoggerFactory> loggerFactoryMock = new();
loggerFactoryMock.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(logger);
contextMock.Protected().Setup<ILoggerFactory>("LoggerFactory").Returns(loggerFactoryMock.Object);

// mock activity results
// In Moq, optional arguments need to be specified as well. We specify them with It.IsAny<T>(), where T is the type of the optional argument
contextMock.Setup(x => x.CallActivityAsync<string>(nameof(AzureFunctionsApp.SayHello), "Tokyo", It.IsAny<TaskOptions>()))
.ReturnsAsync("Hello Tokyo!");
contextMock.Setup(x => x.CallActivityAsync<string>(nameof(AzureFunctionsApp.SayHello), "Seattle", It.IsAny<TaskOptions>()))
.ReturnsAsync("Hello Seattle!");
contextMock.Setup(x => x.CallActivityAsync<string>(nameof(AzureFunctionsApp.SayHello), "London", It.IsAny<TaskOptions>()))
.ReturnsAsync("Hello London!");

// execute the orchestrator
var contextObj = contextMock.Object;
List<string> outputs = await AzureFunctionsApp.RunOrchestrator(contextObj);

// assert expected outputs
Assert.Equal(3, outputs.Count);
Assert.Equal("Hello Tokyo!", outputs[0]);
Assert.Equal("Hello Seattle!", outputs[1]);
Assert.Equal("Hello London!", outputs[2]);
}

[Fact]
public void ActivityReturnsGreeting()
{
Mock<FunctionContext> contextMock = new();

// a simple ILogger that captures emitted logs in a list
TestLogger logger = new();

// Mock ILogger service, needed since an ILogger is created in the client via <FunctionContext>.GetLogger(...);
Mock<ILoggerFactory> loggerFactoryMock = new();
loggerFactoryMock.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(logger);
Mock<IServiceProvider> instanceServicesMock = new();
instanceServicesMock.Setup(x => x.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object);

// register mock'ed DI services
var instanceServices = instanceServicesMock.Object;
contextMock.Setup(x => x.InstanceServices).Returns(instanceServices);

var context = contextMock.Object;

string output = AzureFunctionsApp.SayHello("Tokyo", context);

// Assert expected logs are emitted
var capturedLogs = logger.CapturedLogs;
Assert.Contains(capturedLogs, log => log.Contains("Saying hello to Tokyo."));

// assert expected outputs
Assert.Equal("Hello Tokyo!", output);
}

[Fact]
public async Task ClientReturnsUrls()
{
// orchestrator instanceID ID we expect to generated
var instanceId = "myInstanceId";

// we need to mock the FunctionContext and provide it with two mocked services needed by the client
// (1) an ILogger service
// (2) an ObjectSerializer service,
Mock<FunctionContext> contextMock = new();

// a simple ILogger that captures emitted logs in a list
TestLogger logger = new();

// Mock ILogger service, needed since an ILogger is created in the client via <FunctionContext>.GetLogger(...);
Mock<ILoggerFactory> loggerFactoryMock = new();
loggerFactoryMock.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(logger);
Mock<IServiceProvider> instanceServicesMock = new();
instanceServicesMock.Setup(x => x.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object);

// mock JsonObjectSerializer service, used during HTTP response serialization
ObjectSerializer serializer = new JsonObjectSerializer();
IOptions<WorkerOptions> options = new OptionsWrapper<WorkerOptions>(new WorkerOptions());
options.Value.Serializer = serializer;
instanceServicesMock.Setup(x => x.GetService(typeof(IOptions<WorkerOptions>))).Returns(options);

// register mock'ed DI services
var instanceServices = instanceServicesMock.Object;
contextMock.Setup(x => x.InstanceServices).Returns(instanceServices);

// instantiate worker context
var context = contextMock.Object;

// Initialize mock'ed DurableTaskClient with the ability to start orchestrations
Mock<DurableTaskClient> clientMock = new("test client");
clientMock.Setup(x => x.ScheduleNewOrchestrationInstanceAsync(nameof(AzureFunctionsApp),
It.IsAny<object>(),
It.IsAny<StartOrchestrationOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(instanceId);
var client = clientMock.Object;

// Create dummy request object
TestRequestData request = new(context);

// Invoke the function
var output = await AzureFunctionsApp.HttpStart(request, client, context);

// Assert expected logs are emitted
var capturedLogs = logger.CapturedLogs;
Assert.Contains(capturedLogs, log => log.Contains($"Started orchestration with ID = '{instanceId}'"));

// deserialize http output
output.Body.Seek(0, SeekOrigin.Begin);
using StreamReader reader = new(output.Body, Encoding.UTF8);
string content = reader.ReadToEnd();
Dictionary<string, string>? keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, string>>(content);

// Validate format of response URLs
Assert.NotNull(keyValuePairs);
Assert.Contains(keyValuePairs, kvp => kvp.Key == "id" && kvp.Value == instanceId);
Assert.Contains(keyValuePairs, kvp => kvp.Key == "purgeHistoryDeleteUri" && kvp.Value == $"http://localhost:8888/runtime/webhooks/durabletask/instances/{instanceId}");
Assert.Contains(keyValuePairs, kvp => kvp.Key == "sendEventPostUri" && kvp.Value == $"http://localhost:8888/runtime/webhooks/durabletask/instances/{instanceId}/raiseEvent/{{eventName}}");
Assert.Contains(keyValuePairs, kvp => kvp.Key == "statusQueryGetUri" && kvp.Value == $"http://localhost:8888/runtime/webhooks/durabletask/instances/{instanceId}");

}

// naive implementation of HttpRequestData for testing purposes
public class TestRequestData : HttpRequestData
{
readonly FunctionContext context;

public TestRequestData(FunctionContext functionContext) : base(functionContext)
{
this.context = functionContext;
}

public override Stream Body => new MemoryStream();

public override HttpHeadersCollection Headers => new();

public override IReadOnlyCollection<IHttpCookie> Cookies => new List<IHttpCookie>();

public override Uri Url => new("http://localhost:8888/myUrl");

public override IEnumerable<ClaimsIdentity> Identities => Enumerable.Empty<ClaimsIdentity>();

public override string Method => "POST";

public override HttpResponseData CreateResponse()
{
return new TestResponse(this.context);
}
}

// naive implementation of HttpResponseData for testing purposes, creating by TestRequestData's `CreateResponse` method
public class TestResponse : HttpResponseData
{
public TestResponse(FunctionContext functionContext) : base(functionContext)
{
}

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

public override HttpCookies Cookies => throw new NotImplementedException();

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

public class TestLogger : ILogger
{
// list of all logs emitted, for validation
public IList<string> CapturedLogs {get; set;} = new List<string>();

public IDisposable BeginScope<TState>(TState state) => Mock.Of<IDisposable>();

public bool IsEnabled(LogLevel logLevel)
{
return true;
}

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
string formattedLog = formatter(state, exception);
this.CapturedLogs.Add(formattedLog);
}

}
}
2 changes: 1 addition & 1 deletion src/Abstractions/TaskOrchestrationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ public virtual Task CallSubOrchestratorAsync(
/// </summary>
/// <param name="categoryName">The logger's category name.</param>
/// <returns>An instance of <see cref="ILogger"/> that is replay-safe.</returns>
public ILogger CreateReplaySafeLogger(string categoryName)
public virtual ILogger CreateReplaySafeLogger(string categoryName)
=> new ReplaySafeLogger(this, this.LoggerFactory.CreateLogger(categoryName));

/// <inheritdoc cref="CreateReplaySafeLogger(string)" />
Expand Down

0 comments on commit 23eb257

Please sign in to comment.