Skip to content
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
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.

## v5.9.1
- *Fixed:* The `MockHttpClientRequest` now caches the response content internally, and creates a new `HttpContent` instance for each request to ensure that the content can be read multiple times across multiple requests (where applicable); avoids potential object disposed error.
- *Fixed:* The `MockHttpClient.Reset()` was incorrectly resetting the `MockHttpClient` instance to its default state, but was not resetting the internal request configuration which is used to determine the response. This has now been corrected to reset the internal mocked state only.
- *Fixed:* The `ApiTesterBase` has had `UseSolutionRelativeContentRoot` added to correct the error where the underlying `WebApplicationFactory` was not correctly finding the `appsettings.json` file from the originating solution.

## v5.9.0
- *Enhancement:* Added `WithGenericTester` (_MSTest_ and _NUnit_ only) class to enable class-level generic tester usage versus one-off.
- *Enhancement:* Added `TesterBase.UseScopedTypeSetUp()` to enable a function that will be executed directly before each `ScopedTypeTester{TService}` is instantiated to allow standardized/common set up to occur.
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.9.0</Version>
<Version>5.9.1</Version>
<LangVersion>preview</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
19 changes: 18 additions & 1 deletion src/UnitTestEx/AspNetCore/ApiTesterBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public abstract class ApiTesterBase<TEntryPoint, TSelf> : TesterBase<TSelf>, IDi
{
private bool _disposed;
private WebApplicationFactory<TEntryPoint>? _waf;
private string? _solutionRelativePath;

/// <summary>
/// Initializes a new instance of the <see cref="ApiTesterBase{TEntryPoint, TSelf}"/> class.
Expand Down Expand Up @@ -64,7 +65,7 @@ protected WebApplicationFactory<TEntryPoint> GetWebApplicationFactory()
return _waf;

_waf = new WebApplicationFactory<TEntryPoint>().WithWebHostBuilder(whb =>
whb.UseSolutionRelativeContentRoot(Environment.CurrentDirectory)
whb.UseSolutionRelativeContentRoot(_solutionRelativePath ?? Environment.CurrentDirectory)
.ConfigureAppConfiguration((_, cb) =>
{
cb.AddJsonFile("appsettings.unittest.json", optional: true);
Expand Down Expand Up @@ -164,6 +165,22 @@ protected override void ResetHost()
/// <returns>The <see cref="TestServer"/>.</returns>
public TestServer GetTestServer() => HostExecutionWrapper(() => GetWebApplicationFactory().Server);

/// <summary>
/// Sets the content root to be relative to the solution directory (i.e. the directory containing the .sln file).
/// </summary>
/// <param name="solutionRelativePath">The directory of the solution file.</param>
/// <returns>The <typeparamref name="TSelf"/> to support fluent-style method-chaining.</returns>
/// <remarks>This is required when the API project is not in the same directory as the test project and ensures that the API project's appsettings.json files are found and used.
/// <para>This is the functional equivalent of <see cref="Microsoft.AspNetCore.TestHost.WebHostBuilderExtensions.UseSolutionRelativeContentRoot(Microsoft.AspNetCore.Hosting.IWebHostBuilder, string, string)"/>.</para></remarks>
public TSelf UseSolutionRelativeContentRoot(string? solutionRelativePath)
{
if (_waf != null)
throw new InvalidOperationException("The content root must be set before the WebApplicationFactory is instantiated.");

_solutionRelativePath = solutionRelativePath;
return (TSelf)this;
}

/// <summary>
/// Releases all resources.
/// </summary>
Expand Down
7 changes: 4 additions & 3 deletions src/UnitTestEx/Mocking/MockHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,14 +290,15 @@ public void Verify()
}

/// <summary>
/// Disposes and removes the cached <see cref="HttpClient"/>.
/// Resets all the configured requests and related configurations.
/// </summary>
/// <remarks>This invokes a <see cref="MockExtensions.Reset(Mock)"/> to reset the mock state. This includes its setups, configured default return values, registered event handlers, and all recorded invocations.</remarks>
public void Reset()
{
lock (_lock)
{
_httpClient?.Dispose();
_httpClient = null;
_requests.Clear();
MessageHandler.Reset();
}
}

Expand Down
19 changes: 18 additions & 1 deletion src/UnitTestEx/Mocking/MockHttpClientRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,24 @@ private static async Task<HttpResponseMessage> CreateResponseAsync(HttpRequestMe

var httpResponse = new HttpResponseMessage(response.StatusCode) { RequestMessage = request };
if (response.Content != null)
httpResponse.Content = response.Content;
{
// Load into buffer to ensure content is available for multiple reads (internal only).
#if NET9_0_OR_GREATER
await response.Content.LoadIntoBufferAsync(ct).ConfigureAwait(false);
#else
await response.Content.LoadIntoBufferAsync().ConfigureAwait(false);
#endif

// Copy content for the response vs. trying to reuse the same instance which may have already been read by the caller and therefore not available for the next call.
var bytes = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
httpResponse.Content = new ByteArrayContent(bytes);

// Copy across the content headers (e.g. Content-Type) to the new content instance.
foreach (var h in response.Content.Headers)
{
httpResponse.Content.Headers.TryAddWithoutValidation(h.Key, h.Value);
}
}

if (!response.HttpHeaders.IsEmpty)
{
Expand Down
70 changes: 66 additions & 4 deletions tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
using Moq;
using NUnit.Framework;
using System;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Mime;
using System.Text;
using System.Threading.Tasks;
using UnitTestEx.NUnit.Test.Model;
using UnitTestEx.NUnit;
using NUnit.Framework;
using System.Diagnostics;
using System.Linq;
using UnitTestEx.NUnit.Test.Model;
using static System.Net.Mime.MediaTypeNames;

namespace UnitTestEx.NUnit.Test
{
Expand Down Expand Up @@ -371,6 +372,67 @@ public async Task MockSequenceDelay()
});
}

[Test]
public async Task MockReuseSameAndReset()
{
var mcf = MockHttpClientFactory.Create();
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
mc.Request(HttpMethod.Get, "products/xyz").Respond.With("some-some", HttpStatusCode.OK);

var hc = mcf.GetHttpClient("XXX");
var res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
var txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.That(txt, Is.EqualTo("some-some"));

res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.That(txt, Is.EqualTo("some-some"));

// Now let's reset.
mc.Reset();

// Should throw as no matched requests.
Console.WriteLine("No-match");
Assert.ThrowsAsync<MockHttpClientException>(async () => await hc.GetAsync("products/xyz").ConfigureAwait(false));

// Add the request back in.
mc.Request(HttpMethod.Get, "products/xyz").Respond.With("some-some", HttpStatusCode.OK);
mc.Request(HttpMethod.Get, "products/abc").Respond.With("a-blue-carrot", HttpStatusCode.OK);

// Try again and should work.
Console.WriteLine("Yes-Match");
res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.That(txt, Is.EqualTo("some-some"));

res = await hc.GetAsync("products/abc").ConfigureAwait(false);
txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.That(txt, Is.EqualTo("a-blue-carrot"));
}

[Test]
public async Task MockOnTheFlyChange()
{
var mcf = MockHttpClientFactory.Create();
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
var mcr = mc.Request(HttpMethod.Get, "products/xyz").Respond;

var hc = mcf.GetHttpClient("XXX");

// Set the response.
mcr.With("some-some", HttpStatusCode.OK);

// Get the response and verify.
var res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
var txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.That(txt, Is.EqualTo("some-some"));

mcr.With("some-other", HttpStatusCode.Accepted);
res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.That(txt, Is.EqualTo("some-other"));
}

[Test]
public async Task UriAndBody_WithXmlRequest()
{
Expand Down
1 change: 1 addition & 0 deletions tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public void Success2()
.Request(HttpMethod.Get, "products/xyz").Respond.WithJson(new { id = "Xyz", description = "Xtra yellow elephant" });

using var test = ApiTester.Create<Startup>();
test.UseSolutionRelativeContentRoot("tests/UnitTestEx.Api");
test.ReplaceHttpClientFactory(mcf)
.Controller<ProductController>()
.Run(c => c.Get("xyz"))
Expand Down
Loading