Skip to content

Commit 59aa105

Browse files
authored
docs: document mocking IHttpClientFactory (#779)
This pull request introduces documentation for mocking `IHttpClientFactory` scenarios where multiple `HttpClient` instances are created and disposed, ensuring that invocation history is preserved across disposals. The changes also include an example test, and a local stub for `IHttpClientFactory` to demonstrate the correct mocking pattern. **Documentation and Example Updates:** * Added a new section to `01-httpclient.md` explaining how to mock `IHttpClientFactory` so that multiple `HttpClient` instances created in `using` blocks share a single `HttpMessageHandler`, preserving invocation history for verifications. * Added a new test, `IHttpClientFactory_SharedHandler_PreservesInvocationsAcrossDisposes`, to demonstrate and verify the correct mocking approach for `IHttpClientFactory`, ensuring that all invocations are tracked even when clients are disposed.
1 parent 520954f commit 59aa105

3 files changed

Lines changed: 94 additions & 0 deletions

File tree

Docs/pages/special-types/01-httpclient.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,58 @@ httpClient.Mock.Setup
294294
.WhoseContentIs("application/json", c => c.WithStringMatching("*\"type\": \"Dark\"*")))
295295
.ReturnsAsync(HttpStatusCode.OK);
296296
```
297+
298+
## Mocking `IHttpClientFactory`
299+
300+
When the code under test pulls clients from `IHttpClientFactory` and wraps them in `using` blocks
301+
(`using var client = _factory.CreateClient("chocolate-api");`), two naive mocking patterns both break:
302+
303+
- Returning **the same mocked `HttpClient` instance** from every `CreateClient` call throws
304+
`ObjectDisposedException` on the second call, because the first `using` block already disposed it.
305+
- Returning **a freshly created mocked `HttpClient`** from a `Returns(() => ...)` factory avoids the
306+
disposal exception but spreads invocation history across many short-lived mocks, so verifications
307+
can no longer see the full call sequence.
308+
309+
The fix is to mock the underlying `HttpMessageHandler` once and hand out fresh `HttpClient` wrappers
310+
that all share it. Because Mockolate intercepts on the handler, the registry (and therefore the
311+
invocation history) lives on the handler and survives any number of wrapper disposals. Pass
312+
`disposeHandler: false` when constructing each wrapper so that disposing the wrapper does not also
313+
dispose the shared handler.
314+
315+
```csharp
316+
// One mocked handler shared by every HttpClient the factory hands out.
317+
HttpMessageHandler handler = HttpMessageHandler.CreateMock();
318+
319+
// Setup goes on any HttpClient wrapping the handler; the shared registry routes the
320+
// configuration to every wrapper that uses the same handler.
321+
HttpClient setupClient = HttpClient.CreateMock(handler, disposeHandler: false);
322+
setupClient.Mock.Setup
323+
.GetAsync(It.IsUri("*testably.org/api/chocolate/inventory/*").ForHttps())
324+
.ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK));
325+
326+
// The factory returns a fresh HttpClient each call, all sharing the handler.
327+
// `disposeHandler: false` keeps the shared handler alive when consumers `using` the client.
328+
IHttpClientFactory factory = IHttpClientFactory.CreateMock();
329+
factory.Mock.Setup.CreateClient(It.IsAny<string>())
330+
.Returns(() => HttpClient.CreateMock(handler, disposeHandler: false));
331+
332+
// The code under test can now safely use `using` blocks. Each call goes through the same
333+
// handler, so the invocation history is preserved across every disposed wrapper.
334+
foreach (string type in new[] { "Dark", "Milk", "White" })
335+
{
336+
using HttpClient client = factory.CreateClient("chocolate-api");
337+
_ = await client.GetAsync($"https://testably.org/api/chocolate/inventory/{type}");
338+
}
339+
340+
setupClient.Mock.Verify
341+
.GetAsync(It.IsUri("*testably.org/api/chocolate/inventory/*").ForHttps())
342+
.Exactly(3);
343+
```
344+
345+
**Notes:**
346+
347+
- `disposeHandler: false` is the critical bit. `HttpClient.Dispose()` defaults to disposing its
348+
handler, which would tear down the shared registry after the first `using` block.
349+
- Setup and verify can target any `HttpClient` wrapping the same handler; both reach the same
350+
registry. The `setupClient` above is just one convenient handle, and the wrappers handed out by
351+
the factory work equally well.

Tests/Mockolate.ExampleTests/ExampleTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,30 @@ public async Task HttpClientTest(HttpStatusCode statusCode)
9393

9494
await That(result.StatusCode).IsEqualTo(statusCode);
9595
}
96+
97+
[Fact]
98+
public async Task IHttpClientFactory_SharedHandler_PreservesInvocationsAcrossDisposes()
99+
{
100+
HttpMessageHandler handler = HttpMessageHandler.CreateMock();
101+
HttpClient setupClient = HttpClient.CreateMock(handler, disposeHandler: false);
102+
setupClient.Mock.Setup.GetAsync(It.Matches("*example.com*"))
103+
.ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK));
104+
105+
IHttpClientFactory factory = IHttpClientFactory.CreateMock();
106+
factory.Mock.Setup.CreateClient(It.IsAny<string>())
107+
.Returns(() => HttpClient.CreateMock(handler, disposeHandler: false));
108+
109+
HttpStatusCode[] statuses = new HttpStatusCode[3];
110+
for (int i = 0; i < statuses.Length; i++)
111+
{
112+
using HttpClient client = factory.CreateClient("api");
113+
HttpResponseMessage response = await client.GetAsync($"https://example.com/{i}");
114+
statuses[i] = response.StatusCode;
115+
}
116+
117+
await That(statuses).IsEqualTo([HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK,]);
118+
setupClient.Mock.Verify.GetAsync(It.Matches("*example.com*")).Exactly(3);
119+
}
96120
#endif
97121

98122
[Fact]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#if NET8_0_OR_GREATER
2+
using System.Net.Http;
3+
4+
namespace Mockolate.ExampleTests.TestData;
5+
6+
/// <summary>
7+
/// Local stub mirroring <c>Microsoft.Extensions.Http.IHttpClientFactory</c> so the example
8+
/// test can demonstrate mocking the factory without taking a runtime dependency on
9+
/// <c>Microsoft.Extensions.Http</c>.
10+
/// </summary>
11+
public interface IHttpClientFactory
12+
{
13+
HttpClient CreateClient(string name);
14+
}
15+
#endif

0 commit comments

Comments
 (0)