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

v4.3.0 #71

Merged
merged 1 commit into from
Mar 18, 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.

## v4.3.0
- *Enhancement:* A new `MockHttpClient.WithRequestsFromResource` method enables the specification of the Request/Response configuration from a YAML/JSON embedded resource. The [`mock.unittestex.json`](./src/UnitTestEx/Schema/mock.unittestex.json) JSON schema defines content.

## v4.2.0
- *Enhancement:* Any configuration specified as part of registering the `HttpClient` services from a Dependency Injection (DI) perspective is ignored by default when creating an `HttpClient` using the `MockHttpClientFactory`. This default behavior is intended to potentially minimize any side-effect behavior that may occur that is not intended for the unit testing. See [`README`](./README.md#http-client-configurations) for more details on capabilities introduced; highlights are:
- New `MockHttpClient.WithConfigurations` method indicates that the `HttpMessageHandler` and `HttpClient` configurations are to be used.
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>4.2.0</Version>
<Version>4.3.0</Version>
<LangVersion>preview</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,60 @@ mc.Request(HttpMethod.Get, "products/kjl").Respond.WithSequence(s =>

<br/>

### YAML/JSON configuration

The Request/Response configuration can also be specified within an embedded resource using YAML/JSON as required. The [`mock.unittestex.json`](./src/UnitTestEx/Schema/mock.unittestex.json) JSON schema defines content; where the file is named `*.unittestex.yaml` or `*.unittestex.json` then the schema-based intellisense and validation will occur within the likes of Visual Studio.

To reference the YAML/JSON from a unit test the following is required:

``` csharp
var mcf = MockHttpClientFactory.Create();
mcf.CreateClient("XXX", new Uri("https://unit-test")).WithRequestsFromResource("my.mock.unittestex.yaml");
```

The following represents a YAML example for one-to-one request/responses:

``` yaml
- method: post
uri: products/xyz
body: ^
response:
status: 202
body: |
{"product":"xyz","quantity":1}

- method: get
uri: people/123
response:
body: |
{
"first":"Bob",
"last":"Jane"
}
```

The following represents a YAML example for a request/response with sequences:

``` yaml
- method: get
uri: people/123
sequence:
- body: |
{
"first":"Bob",
"last":"Jane"
}
- body: |
{
"first":"Sarah",
"last":"Johns"
}
```

_Note:_ Not all scenarios are currently available using YAML/JSON configuration.

<br/>

## Expectations

By default _UnitTestEx_ provides out-of-the-box `Assert*` capabilities that are applied after execution to verify the test results. However, by adding the `UnitTestEx.Expectations` namespace in a test additional `Expect*` capabilities will be enabled (where applicable). These allow expectations to be defined prior to the execution which are automatically asserted on execution.
Expand Down
42 changes: 41 additions & 1 deletion src/UnitTestEx.NUnit/ObjectComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static void Assert(JsonElementComparerOptions? options, object? expected,
return;
}

if (expected is null)
if (actual is null)
{
new NUnitTestImplementor().AssertFail($"Expected and Actual values are not equal: value != NULL.");
return;
Expand All @@ -51,5 +51,45 @@ public static void Assert(JsonElementComparerOptions? options, object? expected,
if (cr.HasDifferences)
new NUnitTestImplementor().AssertFail($"Expected and Actual values are not equal:{Environment.NewLine}{cr}");
}

/// <summary>
/// Compares two JSON strings to each other.
/// </summary>
/// <param name="expected">The expected JSON.</param>
/// <param name="actual">The actual JSON.</param>
/// <param name="pathsToIgnore">The JSON paths to ignore from the comparison.</param>
public static void JsonAssert(string? expected, string? actual, params string[] pathsToIgnore) => JsonAssert(null, expected, actual, pathsToIgnore);

/// <summary>
/// Compares two JSON strings to each other.
/// </summary>
/// <param name="options">The <see cref="JsonElementComparerOptions"/>.</param>
/// <param name="expected">The expected JSON.</param>
/// <param name="actual">The actual JSON.</param>
/// <param name="pathsToIgnore">The JSON paths to ignore from the comparison.</param>
public static void JsonAssert(JsonElementComparerOptions? options, string? expected, string? actual, params string[] pathsToIgnore)
{
if (expected is null && actual is null)
return;

if (expected is null)
{
new NUnitTestImplementor().AssertFail($"Expected and Actual values are not equal: NULL != value.");
return;
}

if (actual is null)
{
new NUnitTestImplementor().AssertFail($"Expected and Actual values are not equal: value != NULL.");
return;
}

var o = (options ?? TestSetUp.Default.JsonComparerOptions).Clone();
o.JsonSerializer ??= TestSetUp.Default.JsonSerializer;

var cr = new JsonElementComparer(o).Compare(expected, actual, pathsToIgnore);
if (cr.HasDifferences)
new NUnitTestImplementor().AssertFail($"Expected and Actual values are not equal:{Environment.NewLine}{cr}");
}
}
}
157 changes: 154 additions & 3 deletions src/UnitTestEx/Mocking/MockHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Reflection;
using System.Text.Json;

namespace UnitTestEx.Mocking
{
Expand Down Expand Up @@ -229,15 +234,15 @@ public void WithoutMocking(params Type[] excludeTypes)
/// <summary>
/// Creates a new <see cref="MockHttpClientRequest"/> for the <see cref="GetHttpClient()"/>.
/// </summary>
/// <param name="method">The <see cref="HttpMethod"/>.</param>
/// <param name="method">The <see cref="HttpMethod"/>. Defaults to <see cref="HttpMethod.Get"/>.</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)
public MockHttpClientRequest Request(HttpMethod? method = null, string? requestUri = null)
{
if (_noMocking)
throw new InvalidOperationException($"{nameof(Request)} is not supported where {nameof(WithoutMocking)} has been specified.");

var r = new MockHttpClientRequest(this, method, requestUri);
var r = new MockHttpClientRequest(this, method ?? HttpMethod.Get, requestUri);
_requests.Add(r);
return r;
}
Expand Down Expand Up @@ -294,5 +299,151 @@ private class MockHttpMessageHandlerBuilder(string? name, IServiceProvider servi
/// <inheritdoc/>
public override HttpMessageHandler Build() => CreateHandlerPipeline(PrimaryHandler, AdditionalHandlers.Where(x => !_excludeTypes.Contains(x.GetType())));
}

/// <summary>
/// Adds mocked request(s) from the embedded resource formatted as either YAML or JSON.
/// </summary>
/// <typeparam name="TAssembly">The <see cref="Type"/> used to infer <see cref="Assembly"/> that contains the embedded resource.</typeparam>
/// <param name="resourceName">The embedded resource name (matches to the end of the fully qualifed resource name).</param>
/// <returns></returns>
public MockHttpClient WithRequestsFromResource<TAssembly>(string resourceName) => WithRequestsFromResource(resourceName, typeof(TAssembly).Assembly);

/// <summary>
/// Adds mocked request(s) from the embedded resource formatted as either YAML or JSON.
/// </summary>
/// <param name="resourceName">The embedded resource name (matches to the end of the fully qualifed resource name).</param>
/// <param name="assembly">The <see cref="Assembly"/> that contains the embedded resource; defaults to <see cref="Assembly.GetCallingAssembly"/>.</param>
/// <returns>The <see cref="MockHttpClient"/> to support fluent-style method-chaining.</returns>
public MockHttpClient WithRequestsFromResource(string resourceName, Assembly? assembly = null)
{
ArgumentNullException.ThrowIfNull(resourceName, nameof(resourceName));

bool isYaml = false;
if (resourceName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || resourceName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase))
isYaml = true;
else if (!resourceName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && !resourceName.EndsWith(".jsn", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Only YAML or JSON embedded resources are supported; the extension must be one of the following: .yaml, .yml, .json, .jsn", nameof(resourceName));

using var sr = Resource.GetStream(resourceName, assembly ?? Assembly.GetCallingAssembly());
var reqs = isYaml ? Resource.DeserializeYaml<List<MockConfigRequest>>(sr) : Resource.DeserializeJson<List<MockConfigRequest>>(sr);

if (reqs is not null)
{
foreach (var req in reqs)
{
req.Add(this);
}
}

return this;
}

/// <summary>
/// The mocked config contract for a Request.
/// </summary>
private class MockConfigRequest
{
public string? Method { get; set; }
public string? Uri { get; set; }
public string? Body { get; set; }
public string? Media { get; set; }
public string[]? Ignore { get; set; }
public MockConfigResponse? Response { get; set; }
public List<MockConfigResponse>? Sequence { get; set; }

/// <summary>
/// Adds the request and response to the client.
/// </summary>
public void Add(MockHttpClient client)
{
var req = client.Request(string.IsNullOrEmpty(Method) ? HttpMethod.Get : new HttpMethod(Method.ToUpperInvariant()), Uri).WithPathsToIgnore(Ignore ?? []);
MockHttpClientResponse mres;

if (Body is not null)
{
if (Body == "^")
mres = req.WithAnyBody().Respond;
else
{
if (string.IsNullOrEmpty(Media))
{
try
{
_ = JsonDocument.Parse(Body);
Media = MediaTypeNames.Application.Json;
}
catch
{
Media = MediaTypeNames.Text.Plain;
}
}

mres = req.WithBody(Body, Media).Respond;
}
}
else
mres = req.Respond;

if (Response is not null && Sequence is not null)
throw new InvalidOperationException($"A mocked request can not contain both a {nameof(Response)} and a {nameof(Sequence)} as they are mutually exclusive.");

// One-to-one response.
if (Sequence is null)
{
(Response ?? new()).Add(mres);
return;
}

// A sequence of responses.
mres.WithSequence(seq =>
{
foreach (var res in Sequence)
{
(res ?? new()).Add(seq.Respond());
}
});

}
}

/// <summary>
/// The mocked config contract for a Response.
/// </summary>
private class MockConfigResponse
{
public HttpStatusCode? Status { get; set; }
public string? Body { get; set; }
public string? Media { get; set; }

/// <summary>
/// Adds the response.
/// </summary>
public void Add(MockHttpClientResponse res)
{
if (string.IsNullOrEmpty(Body))
{
res.With(Status ?? HttpStatusCode.NoContent);
return;
}

var content = new StringContent(Body);
if (string.IsNullOrEmpty(Media))
{
try
{
_ = JsonDocument.Parse(Body);
content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json);
}
catch
{
content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Text.Plain);
}
}
else
content.Headers.ContentType = MediaTypeHeaderValue.Parse(Media);

res.With(content, Status ?? HttpStatusCode.OK);
}
}
}
}
17 changes: 13 additions & 4 deletions src/UnitTestEx/Mocking/MockHttpClientRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public sealed class MockHttpClientRequest
{
private readonly MockHttpClient _client;
private readonly HttpMethod _method;
private readonly string _requestUri;
private readonly string? _requestUri;
private bool _anyContent;
private object? _content;
private string? _mediaType;
Expand All @@ -40,11 +40,11 @@ public sealed class MockHttpClientRequest
/// <param name="client">The <see cref="MockHttpClient"/>.</param>
/// <param name="method">The <see cref="HttpMethod"/>.</param>
/// <param name="requestUri">The string that represents the request <see cref="Uri"/>.</param>
internal MockHttpClientRequest(MockHttpClient client, HttpMethod method, string requestUri)
internal MockHttpClientRequest(MockHttpClient client, HttpMethod method, string? requestUri)
{
_client = client;
_method = method ?? throw new ArgumentNullException(nameof(method));
_requestUri = requestUri ?? throw new ArgumentNullException(nameof(requestUri));
_requestUri = requestUri;

Rule = new MockHttpClientRequestRule();
Rule.Response = new MockHttpClientResponse(this, Rule);
Expand Down Expand Up @@ -151,7 +151,7 @@ private bool RequestPredicate(HttpRequestMessage request)
if (request.Method != _method)
return false;

var uri = new Uri(_requestUri, UriKind.RelativeOrAbsolute);
var uri = new Uri(_requestUri ?? string.Empty, UriKind.RelativeOrAbsolute);
if (uri.IsAbsoluteUri)
{
if (_client.IsBaseAddressSpecified && request.RequestUri != uri)
Expand Down Expand Up @@ -329,6 +329,15 @@ public MockHttpClientRequestBody WithJsonResourceBody(string resourceName, Assem
return new MockHttpClientRequestBody(Rule);
}

/// <summary>
/// Sets the JSON paths to ignore from the comparison.
/// </summary>
internal MockHttpClientRequest WithPathsToIgnore(params string[] pathsToIgnore)
{
_pathsToIgnore = pathsToIgnore;
return this;
}

/// <summary>
/// Sets the number of <paramref name="times"/> that the request can be invoked.
/// </summary>
Expand Down
Loading
Loading