Skip to content

Commit

Permalink
v5.1.0 (#80)
Browse files Browse the repository at this point in the history
- *Enhancement:* Where an `HttpRequest` is used for an Azure Functions `HttpTriggerTester` the passed `HttpRequest.PathAndQuery` is checked against that defined by the corresponding `HttpTriggerAttribute.Route` and will result in an error where different. The `HttpTrigger.WithRouteChecK` and `WithNoRouteCheck` methods control the path and query checking as needed.
  • Loading branch information
chullybun authored Dec 7, 2024
1 parent c210ed1 commit 4a7d0c8
Show file tree
Hide file tree
Showing 18 changed files with 441 additions and 36 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

Represents the **NuGet** versions.

## v5.1.0
- *Enhancement:* Where an `HttpRequest` is used for an Azure Functions `HttpTriggerTester` the passed `HttpRequest.PathAndQuery` is checked against that defined by the corresponding `HttpTriggerAttribute.Route` and will result in an error where different. The `HttpTrigger.WithRouteChecK` and `WithNoRouteCheck` methods control the path and query checking as needed.

## v5.0.0
- *Enhancement:* `UnitTestEx` package updated to include only standard .NET core capabilities; new packages created to house specific as follows:
- `UnitTestEx.Azure.Functions` created to house Azure Functions specific capabilities;
Expand Down
2 changes: 1 addition & 1 deletion Common.targets
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>5.0.0</Version>
<Version>5.1.0</Version>
<LangVersion>preview</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ test.ReplaceHttpClientFactory(mcf)

Both the [_Isolated worker model_](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide) and [_In-process model_](https://learn.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library) are supported.

Additionally, where an `HttpRequest` is used the passed `HttpRequest.PathAndQuery` is checked against that defined by the corresponding `HttpTriggerAttribute.Route` and will result in an error where different. The `HttpTrigger.WithRouteChecK` and `WithNoRouteCheck` methods control the path and query checking as needed.

<br/>

## Service Bus-trigger Azure Function
Expand Down Expand Up @@ -113,6 +115,14 @@ test.Run<Gin, int>(gin => gin.Pour())

<br/>

## DI Mocking

Each of the aforementioned test capabilities support Dependency Injection (DI) mocking. This is achieved by replacing the registered services with mocks, stubs, or fakes. The [`TesterBase`](./src/UnitTestEx/Abstractions/TesterBaseT.cs) enables using the `Mock*`, `Replace*` and `ConfigureServices` methods.

The underlying `Services` property also provides access to the `IServiceCollection` within the underlying test host to enable further configuration as required.

<br/>

## HTTP Client mocking

Where invoking a down-stream system using an [`HttpClient`](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) within a unit test context this should generally be mocked. To enable _UnitTestEx_ provides a [`MockHttpClientFactory`](./src/UnitTestEx/Mocking/MockHttpClientFactory.cs) to manage each `HttpClient` (one or more), and mock a response based on the configured request. This leverages the [Moq](https://github.com/moq/moq4) framework internally to enable. One or more requests can also be configured per `HttpClient`.
Expand All @@ -132,6 +142,8 @@ test.ReplaceHttpClientFactory(mcf)
.Assert(new { id = "Abc", description = "A blue carrot" });
```

The `ReplaceHttpClientFactory` leverages the `Replace*` capabilities discussed earlier in [DI Mocking](#di-mocking).

<br/>

### HTTP Client configurations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri =

var context = new DefaultHttpContext();

var uri = new Uri(requestUri!, UriKind.RelativeOrAbsolute);
var uri = requestUri is null ? new Uri("http://functiontest") : new Uri(requestUri, UriKind.RelativeOrAbsolute);
if (!uri.IsAbsoluteUri)
uri = new Uri($"http://functiontest{(requestUri != null && requestUri.StartsWith('/') ? requestUri : $"/{requestUri}")}");

Expand Down
233 changes: 226 additions & 7 deletions src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions src/UnitTestEx.Azure.Functions/Azure/Functions/RouteCheckOption.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace UnitTestEx.Azure.Functions
{
/// <summary>
/// Represents the route check option for the <see cref="HttpTriggerTester{TFunction}"/>.
/// </summary>
public enum RouteCheckOption
{
/// <summary>
/// No route check is required,
/// </summary>
None,

/// <summary>
/// The route should match the specified path excluding any query string.
/// </summary>
Path,

/// <summary>
/// The route should match the specified path including the query string.
/// </summary>
PathAndQuery,

/// <summary>
/// The route should start with the specified path and query string.
/// </summary>
PathAndQueryStartsWith,

/// <summary>
/// The route query (ignore path) should match the specified query string.
/// </summary>
Query,

/// <summary>
/// The route query (ignore path) should start with the specified query string.
/// </summary>
QueryStartsWith
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,13 @@ public async Task<VoidAssertor> RunAsync(Expression<Func<TFunction, Task>> expre
sbv = v;
foreach (var pi in p)
{
if (pi is ServiceBusReceivedMessage sbrm)
if (pi.Value is ServiceBusReceivedMessage sbrm)
sbv = sbrm;
else if (pi is WebJobsServiceBusMessageActionsAssertor psba)
else if (pi.Value is WebJobsServiceBusMessageActionsAssertor psba)
sba = psba;
else if (pi is WebJobsServiceBusSessionMessageActionsAssertor pssba)
else if (pi.Value is WebJobsServiceBusSessionMessageActionsAssertor pssba)
ssba = pssba;
else if (pi is WorkerServiceBusMessageActionsAssertor pwsba)
else if (pi.Value is WorkerServiceBusMessageActionsAssertor pwsba)
wsba = pwsba;
}

Expand Down
69 changes: 69 additions & 0 deletions src/UnitTestEx.Azure.Functions/ExtensionMethods.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx

using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs.ServiceBus;
using System;
using UnitTestEx.Abstractions;
Expand All @@ -12,6 +13,10 @@ namespace UnitTestEx
/// </summary>
public static class ExtensionMethods
{
internal const string HttpMethodCheckName = "HttpTriggerTester_MethodCheck";
internal const string HttpRouteCheckOptionName = "HttpTriggerTester_" + nameof(RouteCheckOption);
internal const string HttpRouteComparisonName = "HttpTriggerTester_" + nameof(StringComparison);

/// <summary>
/// Creates a <see cref="WebJobsServiceBusMessageActionsAssertor"/> as the <see cref="ServiceBusMessageActions"/> instance to enable test mock and assert verification.
/// </summary>
Expand All @@ -34,5 +39,69 @@ public static class ExtensionMethods
/// <returns>The <see cref="WorkerServiceBusMessageActionsAssertor"/>.</returns>
/// <param name="tester">The <see cref="TesterBase"/>.</param>
public static WorkerServiceBusMessageActionsAssertor CreateWorkerServiceBusMessageActions(this TesterBase tester) => new(tester.Implementor);

/// <summary>
/// Sets the default that <i>no</i> check is performed to ensure that the <see cref="Microsoft.Azure.WebJobs.HttpTriggerAttribute.Methods"/> or <see cref="Microsoft.Azure.Functions.Worker.HttpTriggerAttribute.Methods"/> contains the <see cref="HttpRequest.Method"/> for the <see cref="HttpTriggerTester{TFunction}.WithNoMethodCheck"/>.
/// </summary>
/// <param name="setup">The <see cref="TestSetUp"/>.</param>
public static TestSetUp WithNoMethodCheck(this TestSetUp setup)
{
setup.Properties[HttpMethodCheckName] = false;
return setup;
}

/// <summary>
/// Sets the default that a check is performed to ensure that the <see cref="Microsoft.Azure.WebJobs.HttpTriggerAttribute.Methods"/> or <see cref="Microsoft.Azure.Functions.Worker.HttpTriggerAttribute.Methods"/> contains the <see cref="HttpRequest.Method"/> for the <see cref="HttpTriggerTester{TFunction}.WithMethodCheck"/>.
/// </summary>
/// <param name="setup">The <see cref="TestSetUp"/>.</param>
public static TestSetUp WithMethodCheck(this TestSetUp setup)
{
setup.Properties[HttpMethodCheckName] = true;
return setup;
}

/// <summary>
/// Sets the default <see cref="RouteCheckOption"/> to be <see cref="RouteCheckOption.None"/> for the <see cref="HttpTriggerTester{TFunction}.WithNoRouteCheck"/>.
/// </summary>
/// <param name="setup">The <see cref="TestSetUp"/>.</param>
public static TestSetUp WithNoHttpRouteCheck(this TestSetUp setup) => WithHttpRouteCheck(setup, RouteCheckOption.None);

/// <summary>
/// Sets the default <see cref="RouteCheckOption"/> to be checked during execution for the <see cref="HttpTriggerTester{TFunction}.WithRouteCheck(RouteCheckOption, StringComparison?)"/>.
/// </summary>
/// <param name="setup">The <see cref="TestSetUp"/>.</param>
/// <param name="option">The <see cref="RouteCheckOption"/>.</param>
/// <param name="comparison">The <see cref="StringComparison"/>.</param>
public static TestSetUp WithHttpRouteCheck(this TestSetUp setup, RouteCheckOption option, StringComparison? comparison = StringComparison.OrdinalIgnoreCase)
{
setup.Properties[HttpRouteCheckOptionName] = option;
setup.Properties[HttpRouteComparisonName] = comparison;
return setup;
}

/// <summary>
/// Invokes the <see cref="HttpTriggerTester{TFunction}.WithNoMethodCheck"/> or <see cref="HttpTriggerTester{TFunction}.WithMethodCheck"/> method based on the <see cref="TestSetUp"/> <see cref="HttpMethodCheckName"/> property.
/// </summary>
/// <typeparam name="TFunction">The Azure Function <see cref="System.Type"/>.</typeparam>
/// <param name="tester">The <see cref="HttpTriggerTester{TFunction}"/>.</param>
/// <param name="setup">The <see cref="TestSetUp"/>.</param>
internal static void SetHttpMethodCheck<TFunction>(this HttpTriggerTester<TFunction> tester, TestSetUp setup) where TFunction : class
{
if (!setup.Properties.TryGetValue(HttpMethodCheckName, out var check) || (bool)check! == true)
tester.WithMethodCheck();
else
tester.WithNoMethodCheck();
}

/// <summary>
/// Invokes the <see cref="HttpTriggerTester{TFunction}.WithRouteCheck"/> method to set the <see cref="RouteCheckOption"/> and <see cref="StringComparison"/> from the <see cref="TestSetUp"/>.
/// </summary>
/// <typeparam name="TFunction">The Azure Function <see cref="System.Type"/>.</typeparam>
/// <param name="tester">The <see cref="HttpTriggerTester{TFunction}"/>.</param>
/// <param name="setup">The <see cref="TestSetUp"/>.</param>
internal static void SetHttpRouteCheck<TFunction>(this HttpTriggerTester<TFunction> tester, TestSetUp setup) where TFunction : class
=> tester.WithRouteCheck(
setup.Properties.TryGetValue(HttpRouteCheckOptionName, out var option) ? (RouteCheckOption)option! : RouteCheckOption.PathAndQuery,
setup.Properties.TryGetValue(HttpRouteComparisonName, out var comparison) ? (StringComparison)comparison! : StringComparison.OrdinalIgnoreCase);
}
}
2 changes: 1 addition & 1 deletion src/UnitTestEx.Azure.Functions/FunctionTester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
namespace UnitTestEx
{
/// <summary>
/// Provides the <b>NUnit</b> Function testing capability.
/// Provides the Function testing capability.
/// </summary>
public static class FunctionTester
{
Expand Down
29 changes: 20 additions & 9 deletions src/UnitTestEx/Hosting/HostTesterBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
using UnitTestEx.Abstractions;
using UnitTestEx.Json;

using ParameterNameValuePair = (string? Name, object? Value);

namespace UnitTestEx.Hosting
{
/// <summary>
Expand Down Expand Up @@ -52,21 +54,21 @@ public class HostTesterBase<THost>(TesterBase owner, IServiceScope serviceScope)
/// <param name="paramAttributeTypes">The optional parameter <see cref="Attribute"/> <see cref="Type"/>(s) to find.</param>
/// <param name="onBeforeRun">Action to verify the method parameters prior to method invocation.</param>
/// <returns>The resulting exception if any and elapsed milliseconds.</returns>
protected async Task<(Exception? Exception, double ElapsedMilliseconds)> RunAsync(Expression<Func<THost, Task>> expression, Type[]? paramAttributeTypes, Action<object?[], Attribute?, object?>? onBeforeRun)
protected async Task<(Exception? Exception, double ElapsedMilliseconds)> RunAsync(Expression<Func<THost, Task>> expression, Type[]? paramAttributeTypes, OnBeforeRun? onBeforeRun)
{
TestSetUp.LogAutoSetUpOutputs(Implementor);

var mce = MethodCallExpressionValidate(expression);
var pis = mce.Method.GetParameters();
var @params = new object?[pis.Length];
var @params = new ParameterNameValuePair[pis.Length];
Attribute? paramAttribute = null;
object? paramValue = null;

for (int i = 0; i < mce.Arguments.Count; i++)
{
var ue = Expression.Convert(mce.Arguments[i], typeof(object));
var le = Expression.Lambda<Func<object>>(ue);
@params[i] = le.Compile().Invoke();
@params[i] = new(pis[i].Name, le.Compile().Invoke());

if (paramAttribute == null && paramAttributeTypes != null)
{
Expand All @@ -77,7 +79,7 @@ public class HostTesterBase<THost>(TesterBase owner, IServiceScope serviceScope)
break;
}

paramValue = @params[i];
paramValue = @params[i].Value;
}
}

Expand All @@ -88,7 +90,8 @@ public class HostTesterBase<THost>(TesterBase owner, IServiceScope serviceScope)

try
{
await ((Task)mce.Method.Invoke(h, @params)!).ConfigureAwait(false);
var p = @params.Select(x => x.Value).ToArray();
await ((Task)mce.Method.Invoke(h, p)!).ConfigureAwait(false);
sw.Stop();
return (null, sw.Elapsed.TotalMilliseconds);
}
Expand All @@ -112,21 +115,21 @@ public class HostTesterBase<THost>(TesterBase owner, IServiceScope serviceScope)
/// <param name="paramAttributeTypes">The optional parameter <see cref="Attribute"/> <see cref="Type"/> array to find.</param>
/// <param name="onBeforeRun">Action to verify the method parameters prior to method invocation.</param>
/// <returns>The resulting value, resulting exception if any, and elapsed milliseconds.</returns>
protected async Task<(TValue Result, Exception? Exception, double ElapsedMilliseconds)> RunAsync<TValue>(Expression<Func<THost, Task<TValue>>> expression, Type[]? paramAttributeTypes, Action<object?[], Attribute?, object?>? onBeforeRun)
protected async Task<(TValue Result, Exception? Exception, double ElapsedMilliseconds)> RunAsync<TValue>(Expression<Func<THost, Task<TValue>>> expression, Type[]? paramAttributeTypes, OnBeforeRun? onBeforeRun)
{
TestSetUp.LogAutoSetUpOutputs(Implementor);

var mce = MethodCallExpressionValidate(expression);
var pis = mce.Method.GetParameters();
var @params = new object?[pis.Length];
var @params = new ParameterNameValuePair[pis.Length];
Attribute? paramAttribute = null;
object? paramValue = null;

for (int i = 0; i < mce.Arguments.Count; i++)
{
var ue = Expression.Convert(mce.Arguments[i], typeof(object));
var le = Expression.Lambda<Func<object>>(ue);
@params[i] = le.Compile().Invoke();
@params[i] = new(pis[i].Name, le.Compile().Invoke());

if (paramAttribute == null && paramAttributeTypes != null)
{
Expand All @@ -137,7 +140,7 @@ public class HostTesterBase<THost>(TesterBase owner, IServiceScope serviceScope)
break;
}

paramValue = @params[i];
paramValue = @params[i].Value;
}
}

Expand All @@ -164,6 +167,14 @@ public class HostTesterBase<THost>(TesterBase owner, IServiceScope serviceScope)
}
}

/// <summary>
/// Represents the on before run delegate.
/// </summary>
/// <param name="parameters">The parameter name and value array (all method parameters).</param>
/// <param name="paramAttribute">The selected parameter <see cref="Attribute"/> found.</param>
/// <param name="paramValue">The corresponding value for the parameter with the <paramref name="paramAttribute"/>.</param>
protected delegate void OnBeforeRun(ParameterNameValuePair[] parameters, Attribute? paramAttribute, object? paramValue);

/// <summary>
/// Validates that the <paramref name="expression"/> is a valid <see cref="MethodCallExpression"/>.
/// </summary>
Expand Down
8 changes: 8 additions & 0 deletions tests/UnitTestEx.Function/PersonFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ public async Task<IActionResult> RunWithContent([HttpTrigger(AuthorizationLevel.
log.LogInformation("C# HTTP trigger function processed a request.");
return new ContentResult { Content = JsonSerializer.Serialize(new { first = person.FirstName, last = person.LastName }), ContentType = MediaTypeNames.Application.Json, StatusCode = 200 };
}

[FunctionName("PersonFunctionQuery")]
public async Task<IActionResult> RunWithQuery([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/people?name={name}")] HttpRequest request, string name, ILogger log)
{
await Task.CompletedTask.ConfigureAwait(false);
log.LogInformation("C# HTTP trigger function processed a request.");
return new OkObjectResult(new { name });
}
}

public class Namer
Expand Down
Loading

0 comments on commit 4a7d0c8

Please sign in to comment.