Skip to content

Commit

Permalink
Implement/correct issues #3, #4, and #5 (#6)
Browse files Browse the repository at this point in the history
Co-authored-by: Eric Sibly <eric.sibly@avanade.com>
  • Loading branch information
chullybun and chullybun authored Jan 27, 2022
1 parent 53a7f0d commit 068bd2d
Show file tree
Hide file tree
Showing 22 changed files with 744 additions and 110 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

Represents the **NuGet** versions.

## v1.0.4
- *[Issue 3](https://github.com/Avanade/UnitTestEx/issues/3)*: Added support for MOQ `Times` struct to verify the number of times a request is made.
- *[Issue 4](https://github.com/Avanade/UnitTestEx/issues/4)*: Added support for MOQ sequences; i.e. multiple different responses.
- *[Issue 5](https://github.com/Avanade/UnitTestEx/issues/5)*: Deleted `MockServiceBus` as the mocking failed to work as intended. This has been replaced by `FunctionTesterBase` methods of `CreateServiceBusMessage`, `CreateServiceBusMessageFromResource` and `CreateServiceBusMessageFromJson`.

## v1.0.3
- *Fixed:* `MockHttpClientFactory.CreateClient` overloads were ambiquous, this has been corrected.
- *Fixed:* Resolved logging output challenges between the various test frameworks and `ApiTester` (specifically) to achieve consistent output.
Expand Down
42 changes: 32 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@

_UnitTestEx_ provides [.NET testing](https://docs.microsoft.com/en-us/dotnet/core/testing/) extensions to the most popular testing frameworks: [MSTest](https://github.com/Microsoft/testfx-docs), [NUnit](https://nunit.org/) and [Xunit](https://xunit.net/).

The scenarios that _UnitTestEx_ looks to address is the end-to-end unit-style testing of the following. The capabilities look to adhere to the AAA pattern of unit testing; Arrange, Act and Assert.
The scenarios that _UnitTestEx_ looks to address is the end-to-end unit-style testing of the following whereby the capabilities look to adhere to the AAA pattern of unit testing; Arrange, Act and Assert.

This framework looks to address the following testing scenarios:
- [API Controller](#API-Controller)
- [HTTP-triggered Azure Function](#HTTP-triggered-Azure-Function)
- [Generic Azure Function](#Generic-Azure-Function)
Expand Down Expand Up @@ -69,24 +68,18 @@ To support other non [HTTP-triggered Azure Functions](#HTTP-triggered-Azure-Func
The following is an [example](./tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs).

``` csharp
var mcf = MockHttpClientFactory.Create();
mcf.CreateClient("XXX", new Uri("https://somesys"))
.Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = "Smith" }).Respond.With(HttpStatusCode.OK);

using var test = FunctionTester.Create<Startup>();
test.ConfigureServices(sc => mcf.Replace(sc))
.GenericTrigger<ServiceBusFunction>()
.Run(f => f.Run(new Person { FirstName = "Bob", LastName = "Smith" }, test.Logger))
.Run(f => f.Run2(test.CreateServiceBusMessage(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger))
.AssertSuccess();

mcf.VerifyAll();
```

<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`, and mock a response based on the configured request. This leverages the [Moq](https://github.com/moq/moq4) framework internally to enable.
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`.

The following is an [example](./tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs).

Expand All @@ -102,8 +95,37 @@ test.ConfigureServices(sc => mcf.Replace(sc))
.AssertOK(new { id = "Abc", description = "A blue carrot" });
```

</br>

### Times

To verify the number of times that a request/response is performed _UnitTestEx_ support MOQ [`Times`](https://github.com/moq/moq4/blob/main/src/Moq/Times.cs), as follows:

``` csharp
var mcf = MockHttpClientFactory.Create();
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
mc.Request(HttpMethod.Post, "products/xyz").Times(Times.Exactly(2)).WithJsonBody(new Person { FirstName = "Bob", LastName = "Jane" })
.Respond.WithJsonResource("MockHttpClientTest-UriAndBody_WithJsonResponse3.json", HttpStatusCode.Accepted);
```

<br/>

### Sequeuce

To support different responses per execution MOQ supports [sequences](https://github.com/moq/moq4/blob/main/src/Moq/SequenceSetup.cs). This capability has been extended for _UnitTestEx_.

``` csharp
var mcf = MockHttpClientFactory.Create();
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
mc.Request(HttpMethod.Get, "products/xyz").Respond.WithSequence(s =>
{
s.Respond().With(HttpStatusCode.NotModified);
s.Respond().With(HttpStatusCode.NotFound);
});
```

<br>

## Examples

As _UnitTestEx_ is intended for testing, look at the tests for further details on how to leverage:
Expand Down
2 changes: 1 addition & 1 deletion src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>UnitTestEx.MSTest</RootNamespace>
<Version>1.0.3</Version>
<Version>1.0.4</Version>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Authors>UnitTestEx Developers</Authors>
<Company>Avanade</Company>
Expand Down
2 changes: 1 addition & 1 deletion src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>UnitTestEx.NUnit</RootNamespace>
<Version>1.0.3</Version>
<Version>1.0.4</Version>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Authors>UnitTestEx Developers</Authors>
<Company>Avanade</Company>
Expand Down
2 changes: 1 addition & 1 deletion src/UnitTestEx.XUnit/UnitTestEx.Xunit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>UnitTestEx.Xunit</RootNamespace>
<Version>1.0.3</Version>
<Version>1.0.4</Version>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Authors>UnitTestEx Developers</Authors>
<Company>Avanade</Company>
Expand Down
67 changes: 67 additions & 0 deletions src/UnitTestEx/Functions/FunctionTesterBase.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx

using Azure.Core.Amqp;
using Azure.Messaging.ServiceBus;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -298,6 +300,71 @@ public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, stri
return hr;
}

/// <summary>
/// Creates a <see cref="ServiceBusReceivedMessage"/> where the <see cref="ServiceBusMessage.Body"/> <see cref="BinaryData"/> will contain the <paramref name="value"/> as serialized JSON.
/// </summary>
/// <typeparam name="T">The <paramref name="value"/> <see cref="Type"/>.</typeparam>
/// <param name="value">The value.</param>
/// <returns>The <see cref="ServiceBusReceivedMessage"/>.</returns>
public ServiceBusReceivedMessage CreateServiceBusMessage<T>(T value)
=> CreateServiceBusMessageFromJson(JsonConvert.SerializeObject(value));

/// <summary>
/// Creates a <see cref="ServiceBusReceivedMessage"/> where the <see cref="ServiceBusMessage.Body"/> <see cref="BinaryData"/> will contain the <paramref name="value"/> as serialized JSON.
/// </summary>
/// <typeparam name="T">The <paramref name="value"/> <see cref="Type"/>.</typeparam>
/// <param name="value">The value.</param>
/// <param name="messageModify">Optional <see cref="AmqpAnnotatedMessage"/> modifier than enables the message to be further configured.</param>
/// <returns>The <see cref="ServiceBusReceivedMessage"/>.</returns>
public ServiceBusReceivedMessage CreateServiceBusMessage<T>(T value, Action<AmqpAnnotatedMessage> messageModify)
=> CreateServiceBusMessageFromJson(JsonConvert.SerializeObject(value), messageModify);

/// <summary>
/// Creates a <see cref="ServiceBusReceivedMessage"/> where the <see cref="ServiceBusMessage.Body"/> <see cref="BinaryData"/> will contain the JSON formatted embedded resource as the content (<see cref="MediaTypeNames.Application.Json"/>).
/// </summary>
/// <typeparam name="TAssembly">The <see cref="Type"/> to infer <see cref="Type.Assembly"/> for the embedded resources.</typeparam>
/// <param name="resourceName">The embedded resource name (matches to the end of the fully qualifed resource name).</param>
/// <param name="messageModify">Optional <see cref="AmqpAnnotatedMessage"/> modifier than enables the message to be further configured.</param>
/// <returns>The <see cref="ServiceBusReceivedMessage"/>.</returns>
public ServiceBusReceivedMessage CreateServiceBusMessageFromResource<TAssembly>(string resourceName, Action<AmqpAnnotatedMessage>? messageModify = null)
=> CreateServiceBusMessageFromResource(resourceName, messageModify, typeof(TAssembly).Assembly);

/// <summary>
/// Creates a <see cref="ServiceBusReceivedMessage"/> where the <see cref="ServiceBusMessage.Body"/> <see cref="BinaryData"/> will contain the JSON formatted embedded resource as the content (<see cref="MediaTypeNames.Application.Json"/>).
/// </summary>
/// <param name="resourceName">The embedded resource name (matches to the end of the fully qualifed resource name).</param>
/// <param name="messageModify">Optional <see cref="AmqpAnnotatedMessage"/> modifier than enables the message to be further configured.</param>
/// <param name="assembly">The <see cref="Assembly"/> that contains the embedded resource; defaults to <see cref="Assembly.GetEntryAssembly()"/>.</param>
/// <returns>The <see cref="ServiceBusReceivedMessage"/>.</returns>
public ServiceBusReceivedMessage CreateServiceBusMessageFromResource(string resourceName, Action<AmqpAnnotatedMessage>? messageModify = null, Assembly? assembly = null)
=> CreateServiceBusMessageFromJson(Resource.GetString(resourceName, assembly ?? Assembly.GetCallingAssembly()), messageModify);

/// <summary>
/// Creates a <see cref="ServiceBusReceivedMessage"/> where the <see cref="ServiceBusMessage.Body"/> <see cref="BinaryData"/> will contain the serialized <paramref name="json"/>.
/// </summary>
/// <param name="json">The JSON body.</param>
/// <param name="messageModify">Optional <see cref="AmqpAnnotatedMessage"/> modifier than enables the message to be further configured.</param>
/// <returns>The <see cref="ServiceBusReceivedMessage"/>.</returns>
public ServiceBusReceivedMessage CreateServiceBusMessageFromJson(string json, Action<AmqpAnnotatedMessage>? messageModify = null)
{
var message = new AmqpAnnotatedMessage(AmqpMessageBody.FromData(new ReadOnlyMemory<byte>[] { Encoding.UTF8.GetBytes(json ?? throw new ArgumentNullException(nameof(json))) }));
message.Header.DeliveryCount = 1;
message.Header.Durable = true;
message.Header.Priority = 1;
message.Header.TimeToLive = TimeSpan.FromSeconds(60);
message.Properties.ContentType = MediaTypeNames.Application.Json;
message.Properties.MessageId = new AmqpMessageId("messageId");

messageModify?.Invoke(message);

var t = typeof(ServiceBusReceivedMessage);
var c = t.GetConstructor(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, new Type[] { typeof(AmqpAnnotatedMessage) }, null);
if (c == null)
throw new InvalidOperationException($"{typeof(ServiceBusReceivedMessage).Name} constructor that accepts Type {typeof(AmqpAnnotatedMessage).Name} parameter was not found.");

return (ServiceBusReceivedMessage)c.Invoke(new object?[] { message });
}

/// <summary>
/// Releases all resources.
/// </summary>
Expand Down
26 changes: 22 additions & 4 deletions src/UnitTestEx/Mocking/MockHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using Moq;
using System;
using System.Collections.Generic;
using System.Net.Http;

namespace UnitTestEx.Mocking
Expand All @@ -11,6 +12,8 @@ namespace UnitTestEx.Mocking
/// </summary>
public class MockHttpClient
{
private readonly List<MockHttpClientRequest> _requests = new List<MockHttpClientRequest>();

/// <summary>
/// Initializes a new instance of the <see cref="MockHttpClient"/> class.
/// </summary>
Expand All @@ -37,20 +40,35 @@ internal MockHttpClient(MockHttpClientFactory factory, string name, Uri? baseAdd
/// <summary>
/// Verifies that all verifiable <see cref="Mock"/> expectations have been met; being all requests have been invoked.
/// </summary>
/// <remarks>This is a wrapper for '<c>MessageHandler.Verify()</c>' which can be invoked directly to leverage additional capabilities (overloads).</remarks>
public void Verify() => MessageHandler.Verify();
/// <remarks>This is a wrapper for '<c>MessageHandler.Verify()</c>' which can be invoked directly to leverage additional capabilities (overloads). Additionally, the <see cref="MockHttpClientRequest.Verify"/> is invoked for each
/// underlying <see cref="Request(HttpMethod, string)"/> to perform the corresponding <see cref="MockHttpClientRequest.Times(Times)"/> verification.<para>Note: no verify will occur where using sequences; this appears to be a
/// limitation of MOQ.</para></remarks>
public void Verify()
{
MessageHandler.Verify();

foreach (var r in _requests)
{
r.Verify();
}
}

/// <summary>
/// Gets the mocked <see cref="HttpClient"/>.
/// </summary>
internal HttpClient HttpClient { get; set; }

/// <summary>
/// Creates a new <see cref="MockHttpClientRequest"/> for the <see cref="HttpClient"/> with no body content.
/// Creates a new <see cref="MockHttpClientRequest"/> for the <see cref="HttpClient"/>.
/// </summary>
/// <param name="method">The <see cref="HttpMethod"/>.</param>
/// <param name="requestUri">The string that represents the request <see cref="Uri"/>.</param>
/// <returns>The <see cref="MockHttpClientRequest"/>.</returns>
public MockHttpClientRequest Request(HttpMethod method, string requestUri) => new(this, method, requestUri);
public MockHttpClientRequest Request(HttpMethod method, string requestUri)
{
var r = new MockHttpClientRequest(this, method, requestUri);
_requests.Add(r);
return r;
}
}
}
3 changes: 2 additions & 1 deletion src/UnitTestEx/Mocking/MockHttpClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ public IServiceCollection Replace(IServiceCollection sc) => sc.ReplaceSingleton(
/// <summary>
/// Verifies that all verifiable <see cref="Mock"/> expectations have been met for all <see cref="MockHttpClient"/> instances; being all requests have been invoked.
/// </summary>
/// <remarks>This invokes <see cref="MockHttpClient.Verify"/> for each <see cref="MockHttpClient"/> instance.</remarks>
/// <remarks>This invokes <see cref="MockHttpClient.Verify"/> for each <see cref="MockHttpClient"/> instance. <para>Note: no verify will occur where using sequences; this appears to be a
/// limitation of MOQ.</para></remarks>
public void VerifyAll()
{
foreach (var mc in _mockClients)
Expand Down
Loading

0 comments on commit 068bd2d

Please sign in to comment.