diff --git a/Docs/pages/special-types/01-httpclient.md b/Docs/pages/special-types/01-httpclient.md index 08d3dbfc..9a7be1a5 100644 --- a/Docs/pages/special-types/01-httpclient.md +++ b/Docs/pages/special-types/01-httpclient.md @@ -294,3 +294,58 @@ httpClient.Mock.Setup .WhoseContentIs("application/json", c => c.WithStringMatching("*\"type\": \"Dark\"*"))) .ReturnsAsync(HttpStatusCode.OK); ``` + +## Mocking `IHttpClientFactory` + +When the code under test pulls clients from `IHttpClientFactory` and wraps them in `using` blocks +(`using var client = _factory.CreateClient("chocolate-api");`), two naive mocking patterns both break: + +- Returning **the same mocked `HttpClient` instance** from every `CreateClient` call throws + `ObjectDisposedException` on the second call, because the first `using` block already disposed it. +- Returning **a freshly created mocked `HttpClient`** from a `Returns(() => ...)` factory avoids the + disposal exception but spreads invocation history across many short-lived mocks, so verifications + can no longer see the full call sequence. + +The fix is to mock the underlying `HttpMessageHandler` once and hand out fresh `HttpClient` wrappers +that all share it. Because Mockolate intercepts on the handler, the registry (and therefore the +invocation history) lives on the handler and survives any number of wrapper disposals. Pass +`disposeHandler: false` when constructing each wrapper so that disposing the wrapper does not also +dispose the shared handler. + +```csharp +// One mocked handler shared by every HttpClient the factory hands out. +HttpMessageHandler handler = HttpMessageHandler.CreateMock(); + +// Setup goes on any HttpClient wrapping the handler; the shared registry routes the +// configuration to every wrapper that uses the same handler. +HttpClient setupClient = HttpClient.CreateMock(handler, disposeHandler: false); +setupClient.Mock.Setup + .GetAsync(It.IsUri("*testably.org/api/chocolate/inventory/*").ForHttps()) + .ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK)); + +// The factory returns a fresh HttpClient each call, all sharing the handler. +// `disposeHandler: false` keeps the shared handler alive when consumers `using` the client. +IHttpClientFactory factory = IHttpClientFactory.CreateMock(); +factory.Mock.Setup.CreateClient(It.IsAny()) + .Returns(() => HttpClient.CreateMock(handler, disposeHandler: false)); + +// The code under test can now safely use `using` blocks. Each call goes through the same +// handler, so the invocation history is preserved across every disposed wrapper. +foreach (string type in new[] { "Dark", "Milk", "White" }) +{ + using HttpClient client = factory.CreateClient("chocolate-api"); + _ = await client.GetAsync($"https://testably.org/api/chocolate/inventory/{type}"); +} + +setupClient.Mock.Verify + .GetAsync(It.IsUri("*testably.org/api/chocolate/inventory/*").ForHttps()) + .Exactly(3); +``` + +**Notes:** + +- `disposeHandler: false` is the critical bit. `HttpClient.Dispose()` defaults to disposing its + handler, which would tear down the shared registry after the first `using` block. +- Setup and verify can target any `HttpClient` wrapping the same handler; both reach the same + registry. The `setupClient` above is just one convenient handle, and the wrappers handed out by + the factory work equally well. diff --git a/Tests/Mockolate.ExampleTests/ExampleTests.cs b/Tests/Mockolate.ExampleTests/ExampleTests.cs index 8b436982..88e4b863 100644 --- a/Tests/Mockolate.ExampleTests/ExampleTests.cs +++ b/Tests/Mockolate.ExampleTests/ExampleTests.cs @@ -93,6 +93,30 @@ public async Task HttpClientTest(HttpStatusCode statusCode) await That(result.StatusCode).IsEqualTo(statusCode); } + + [Fact] + public async Task IHttpClientFactory_SharedHandler_PreservesInvocationsAcrossDisposes() + { + HttpMessageHandler handler = HttpMessageHandler.CreateMock(); + HttpClient setupClient = HttpClient.CreateMock(handler, disposeHandler: false); + setupClient.Mock.Setup.GetAsync(It.Matches("*example.com*")) + .ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK)); + + IHttpClientFactory factory = IHttpClientFactory.CreateMock(); + factory.Mock.Setup.CreateClient(It.IsAny()) + .Returns(() => HttpClient.CreateMock(handler, disposeHandler: false)); + + HttpStatusCode[] statuses = new HttpStatusCode[3]; + for (int i = 0; i < statuses.Length; i++) + { + using HttpClient client = factory.CreateClient("api"); + HttpResponseMessage response = await client.GetAsync($"https://example.com/{i}"); + statuses[i] = response.StatusCode; + } + + await That(statuses).IsEqualTo([HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK,]); + setupClient.Mock.Verify.GetAsync(It.Matches("*example.com*")).Exactly(3); + } #endif [Fact] diff --git a/Tests/Mockolate.ExampleTests/TestData/IHttpClientFactory.cs b/Tests/Mockolate.ExampleTests/TestData/IHttpClientFactory.cs new file mode 100644 index 00000000..e1e6aced --- /dev/null +++ b/Tests/Mockolate.ExampleTests/TestData/IHttpClientFactory.cs @@ -0,0 +1,15 @@ +#if NET8_0_OR_GREATER +using System.Net.Http; + +namespace Mockolate.ExampleTests.TestData; + +/// +/// Local stub mirroring Microsoft.Extensions.Http.IHttpClientFactory so the example +/// test can demonstrate mocking the factory without taking a runtime dependency on +/// Microsoft.Extensions.Http. +/// +public interface IHttpClientFactory +{ + HttpClient CreateClient(string name); +} +#endif