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

v5.1.0 #80

Merged
merged 1 commit into from
Dec 7, 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
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
Loading