Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Azure Functions unit testing sample #312

Merged
merged 9 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you still want this change, it is fine to leave in. Otherwise if you think Moq.Protected is sufficient, we can remove this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to keep this as I'd like for customer to be able to mock this directly :)

=> new ReplaySafeLogger(this, this.LoggerFactory.CreateLogger(categoryName));

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