Skip to content

Commit 715e57c

Browse files
authored
v5.9.1 (#100)
- *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.
1 parent 5def8f3 commit 715e57c

File tree

7 files changed

+113
-10
lines changed

7 files changed

+113
-10
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Represents the **NuGet** versions.
44

5+
## v5.9.1
6+
- *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.
7+
- *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.
8+
- *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.
9+
510
## v5.9.0
611
- *Enhancement:* Added `WithGenericTester` (_MSTest_ and _NUnit_ only) class to enable class-level generic tester usage versus one-off.
712
- *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.

Common.targets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>5.9.0</Version>
3+
<Version>5.9.1</Version>
44
<LangVersion>preview</LangVersion>
55
<Authors>Avanade</Authors>
66
<Company>Avanade</Company>

src/UnitTestEx/AspNetCore/ApiTesterBase.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public abstract class ApiTesterBase<TEntryPoint, TSelf> : TesterBase<TSelf>, IDi
2626
{
2727
private bool _disposed;
2828
private WebApplicationFactory<TEntryPoint>? _waf;
29+
private string? _solutionRelativePath;
2930

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

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

168+
/// <summary>
169+
/// Sets the content root to be relative to the solution directory (i.e. the directory containing the .sln file).
170+
/// </summary>
171+
/// <param name="solutionRelativePath">The directory of the solution file.</param>
172+
/// <returns>The <typeparamref name="TSelf"/> to support fluent-style method-chaining.</returns>
173+
/// <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.
174+
/// <para>This is the functional equivalent of <see cref="Microsoft.AspNetCore.TestHost.WebHostBuilderExtensions.UseSolutionRelativeContentRoot(Microsoft.AspNetCore.Hosting.IWebHostBuilder, string, string)"/>.</para></remarks>
175+
public TSelf UseSolutionRelativeContentRoot(string? solutionRelativePath)
176+
{
177+
if (_waf != null)
178+
throw new InvalidOperationException("The content root must be set before the WebApplicationFactory is instantiated.");
179+
180+
_solutionRelativePath = solutionRelativePath;
181+
return (TSelf)this;
182+
}
183+
167184
/// <summary>
168185
/// Releases all resources.
169186
/// </summary>

src/UnitTestEx/Mocking/MockHttpClient.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,14 +290,15 @@ public void Verify()
290290
}
291291

292292
/// <summary>
293-
/// Disposes and removes the cached <see cref="HttpClient"/>.
293+
/// Resets all the configured requests and related configurations.
294294
/// </summary>
295+
/// <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>
295296
public void Reset()
296297
{
297298
lock (_lock)
298299
{
299-
_httpClient?.Dispose();
300-
_httpClient = null;
300+
_requests.Clear();
301+
MessageHandler.Reset();
301302
}
302303
}
303304

src/UnitTestEx/Mocking/MockHttpClientRequest.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,24 @@ private static async Task<HttpResponseMessage> CreateResponseAsync(HttpRequestMe
121121

122122
var httpResponse = new HttpResponseMessage(response.StatusCode) { RequestMessage = request };
123123
if (response.Content != null)
124-
httpResponse.Content = response.Content;
124+
{
125+
// Load into buffer to ensure content is available for multiple reads (internal only).
126+
#if NET9_0_OR_GREATER
127+
await response.Content.LoadIntoBufferAsync(ct).ConfigureAwait(false);
128+
#else
129+
await response.Content.LoadIntoBufferAsync().ConfigureAwait(false);
130+
#endif
131+
132+
// 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.
133+
var bytes = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
134+
httpResponse.Content = new ByteArrayContent(bytes);
135+
136+
// Copy across the content headers (e.g. Content-Type) to the new content instance.
137+
foreach (var h in response.Content.Headers)
138+
{
139+
httpResponse.Content.Headers.TryAddWithoutValidation(h.Key, h.Value);
140+
}
141+
}
125142

126143
if (!response.HttpHeaders.IsEmpty)
127144
{

tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
using Moq;
2+
using NUnit.Framework;
23
using System;
4+
using System.Diagnostics;
5+
using System.Linq;
36
using System.Net;
47
using System.Net.Http;
58
using System.Net.Mime;
69
using System.Text;
710
using System.Threading.Tasks;
8-
using UnitTestEx.NUnit.Test.Model;
911
using UnitTestEx.NUnit;
10-
using NUnit.Framework;
11-
using System.Diagnostics;
12-
using System.Linq;
12+
using UnitTestEx.NUnit.Test.Model;
13+
using static System.Net.Mime.MediaTypeNames;
1314

1415
namespace UnitTestEx.NUnit.Test
1516
{
@@ -371,6 +372,67 @@ public async Task MockSequenceDelay()
371372
});
372373
}
373374

375+
[Test]
376+
public async Task MockReuseSameAndReset()
377+
{
378+
var mcf = MockHttpClientFactory.Create();
379+
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
380+
mc.Request(HttpMethod.Get, "products/xyz").Respond.With("some-some", HttpStatusCode.OK);
381+
382+
var hc = mcf.GetHttpClient("XXX");
383+
var res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
384+
var txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
385+
Assert.That(txt, Is.EqualTo("some-some"));
386+
387+
res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
388+
txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
389+
Assert.That(txt, Is.EqualTo("some-some"));
390+
391+
// Now let's reset.
392+
mc.Reset();
393+
394+
// Should throw as no matched requests.
395+
Console.WriteLine("No-match");
396+
Assert.ThrowsAsync<MockHttpClientException>(async () => await hc.GetAsync("products/xyz").ConfigureAwait(false));
397+
398+
// Add the request back in.
399+
mc.Request(HttpMethod.Get, "products/xyz").Respond.With("some-some", HttpStatusCode.OK);
400+
mc.Request(HttpMethod.Get, "products/abc").Respond.With("a-blue-carrot", HttpStatusCode.OK);
401+
402+
// Try again and should work.
403+
Console.WriteLine("Yes-Match");
404+
res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
405+
txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
406+
Assert.That(txt, Is.EqualTo("some-some"));
407+
408+
res = await hc.GetAsync("products/abc").ConfigureAwait(false);
409+
txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
410+
Assert.That(txt, Is.EqualTo("a-blue-carrot"));
411+
}
412+
413+
[Test]
414+
public async Task MockOnTheFlyChange()
415+
{
416+
var mcf = MockHttpClientFactory.Create();
417+
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
418+
var mcr = mc.Request(HttpMethod.Get, "products/xyz").Respond;
419+
420+
var hc = mcf.GetHttpClient("XXX");
421+
422+
// Set the response.
423+
mcr.With("some-some", HttpStatusCode.OK);
424+
425+
// Get the response and verify.
426+
var res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
427+
var txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
428+
Assert.That(txt, Is.EqualTo("some-some"));
429+
430+
mcr.With("some-other", HttpStatusCode.Accepted);
431+
res = await hc.GetAsync("products/xyz").ConfigureAwait(false);
432+
txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
433+
Assert.That(txt, Is.EqualTo("some-other"));
434+
}
435+
374436
[Test]
375437
public async Task UriAndBody_WithXmlRequest()
376438
{

tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public void Success2()
6868
.Request(HttpMethod.Get, "products/xyz").Respond.WithJson(new { id = "Xyz", description = "Xtra yellow elephant" });
6969

7070
using var test = ApiTester.Create<Startup>();
71+
test.UseSolutionRelativeContentRoot("tests/UnitTestEx.Api");
7172
test.ReplaceHttpClientFactory(mcf)
7273
.Controller<ProductController>()
7374
.Run(c => c.Get("xyz"))

0 commit comments

Comments
 (0)