From 10623b9dc2d280a5953be93ae378e3c247969a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Breu=C3=9F=20Valentin?= Date: Mon, 11 May 2026 14:20:08 +0200 Subject: [PATCH 1/2] docs: document mocking `IHttpClientFactory` --- Docs/pages/special-types/01-httpclient.md | 55 +++++++++++++++++++ Tests/Mockolate.ExampleTests/ExampleTests.cs | 24 ++++++++ .../TestData/IHttpClientFactory.cs | 15 +++++ 3 files changed, 94 insertions(+) create mode 100644 Tests/Mockolate.ExampleTests/TestData/IHttpClientFactory.cs diff --git a/Docs/pages/special-types/01-httpclient.md b/Docs/pages/special-types/01-httpclient.md index 08d3dbfc..fd4f196c 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();`), 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..da5363dd 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 From abd436b3a733314828aa25f7017e6c7bc9d59766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 11 May 2026 15:07:53 +0200 Subject: [PATCH 2/2] Fix review issues --- Docs/pages/special-types/01-httpclient.md | 4 ++-- Tests/Mockolate.ExampleTests/ExampleTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Docs/pages/special-types/01-httpclient.md b/Docs/pages/special-types/01-httpclient.md index fd4f196c..9a7be1a5 100644 --- a/Docs/pages/special-types/01-httpclient.md +++ b/Docs/pages/special-types/01-httpclient.md @@ -298,7 +298,7 @@ httpClient.Mock.Setup ## Mocking `IHttpClientFactory` When the code under test pulls clients from `IHttpClientFactory` and wraps them in `using` blocks -(`using var client = _factory.CreateClient();`), two naive mocking patterns both break: +(`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. @@ -321,7 +321,7 @@ HttpMessageHandler handler = HttpMessageHandler.CreateMock(); HttpClient setupClient = HttpClient.CreateMock(handler, disposeHandler: false); setupClient.Mock.Setup .GetAsync(It.IsUri("*testably.org/api/chocolate/inventory/*").ForHttps()) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + .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. diff --git a/Tests/Mockolate.ExampleTests/ExampleTests.cs b/Tests/Mockolate.ExampleTests/ExampleTests.cs index da5363dd..88e4b863 100644 --- a/Tests/Mockolate.ExampleTests/ExampleTests.cs +++ b/Tests/Mockolate.ExampleTests/ExampleTests.cs @@ -100,7 +100,7 @@ public async Task IHttpClientFactory_SharedHandler_PreservesInvocationsAcrossDis HttpMessageHandler handler = HttpMessageHandler.CreateMock(); HttpClient setupClient = HttpClient.CreateMock(handler, disposeHandler: false); setupClient.Mock.Setup.GetAsync(It.Matches("*example.com*")) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + .ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK)); IHttpClientFactory factory = IHttpClientFactory.CreateMock(); factory.Mock.Setup.CreateClient(It.IsAny())