Unit Testing Code That Uses HttpClient: HttpMessageHandler Stubs, MockHttp, IHttpClientFactory and Typed Clients
You fake the HttpMessageHandler underneath HttpClient, not HttpClient itself; a handler that also matches the URL (RichardSzalay.MockHttp) catches bugs such as a missing slash on BaseAddress silently dropping the v2 segment. Your own stub handler, When/Expect and matchers, JSON body matching, testing typed clients and the IHttpClientFactory registration; 429/Retry-After, 5xx, timeout, cancellation and broken JSON scenarios with real failure messages.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3, RichardSzalay.MockHttp 7.1.0 and Microsoft.Extensions.Http 10.0.15.
Predict First
This post adds the Shipping module to Bookstore, the online bookstore used as the shared example across this site's series. Its first piece is a typed client that calls the carrier's tracking API:
public sealed class ParcelTrackingClient(HttpClient http)
{
public async Task<ParcelStatus?> GetStatusAsync(string trackingNumber, CancellationToken ct)
{
using HttpRequestMessage request = new(HttpMethod.Get, $"parcels/{Uri.EscapeDataString(trackingNumber)}?lang=en");
using HttpResponseMessage response = await SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
return await ReadAsync<ParcelStatus>(response, ct);
}
// SendAsync and ReadAsync: in the error section
}
The client is registered through IHttpClientFactory, and the address comes from "CarrierBaseUrl": "https://carrier.example.com/api/v2" in appsettings.json:
public static IHttpClientBuilder AddParcelTracking(this IServiceCollection services, string carrierBaseUrl) =>
services.AddHttpClient<ParcelTrackingClient>(client =>
{
client.BaseAddress = new Uri(carrierBaseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Add("X-Carrier-Account", "BOOKSTORE-TR");
});
According to the carrier's documentation, the tracking address is https://carrier.example.com/api/v2/parcels/{trackingNumber}. The test registers the client exactly as production does and only replaces the network with RichardSzalay.MockHttp's handler:
[Fact]
public async Task Registered_client_reads_a_delivered_parcel()
{
MockHttpMessageHandler carrier = new();
carrier.When(HttpMethod.Get, "https://carrier.example.com/api/v2/parcels/BS2026000042TR")
.Respond("application/json", CarrierResponses.Delivered);
ServiceCollection services = new();
services.AddParcelTracking(CarrierResponses.BaseUrl)
.ConfigurePrimaryHttpMessageHandler(() => carrier);
using ServiceProvider provider = services.BuildServiceProvider();
ParcelTrackingClient client = provider.GetRequiredService<ParcelTrackingClient>();
ParcelStatus? status = await client.GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
Assert.Equal("Delivered", status?.State);
}
CarrierResponses.BaseUrl is the same value as in appsettings.json. Does the test pass?
Answer: The Trailing Slash on BaseAddress
It fails:
failed Bookstore.Shipping.Tests.ParcelTrackingRegistrationTests.Registered_client_reads_a_delivered_parcel (32ms)
Xunit.MicrosoftTestingPlatform.XunitException: RichardSzalay.MockHttp.MockHttpMatchException : Failed to match a mocked request for GET https://carrier.example.com/api/parcels/BS2026000042TR?lang=en
1 backend definitions were evaluated but did not match:
[FAILED] URL matches https://carrier.example.com/api/v2/parcels/BS2026000042TR
AND [SKIPPED] method matches GET
The request went to api/parcels/... instead of api/v2/parcels/...: v2 is gone. HttpClient resolves a relative address against BaseAddress using the rules of RFC 3986. Under those rules, the last segment of the base path counts as a "file" when there is no trailing slash, and the relative address replaces it. I pinned the behavior down with a theory; all four rows are green:
[Theory]
[InlineData("https://carrier.example.com/api/v2", "parcels/42", "https://carrier.example.com/api/parcels/42")]
[InlineData("https://carrier.example.com/api/v2/", "parcels/42", "https://carrier.example.com/api/v2/parcels/42")]
[InlineData("https://carrier.example.com/api/v2/", "/parcels/42", "https://carrier.example.com/parcels/42")]
[InlineData("https://carrier.example.com/api/v2", "/parcels/42", "https://carrier.example.com/parcels/42")]
public void Relative_uri_is_resolved_against_the_base_address(string baseAddress, string relative, string expected)
{
Uri resolved = new(new Uri(baseAddress), relative);
Assert.Equal(expected, resolved.ToString());
}
Only the second row gives the right result: the base address ends with a slash and the relative address does not start with one. A leading slash wipes the whole path; a missing trailing slash wipes the last segment. The fix goes into the registration, whatever form the address arrives in:
// Without the trailing slash, "parcels/..." replaces the last path segment ("v2")
client.BaseAddress = new Uri(carrierBaseUrl.EndsWith('/') ? carrierBaseUrl : carrierBaseUrl + "/");
The real value of the test is in what it caught. The StubHttpMessageHandler from post 08 answered every request with the same response; with that stub this test would be green and the bug would show up in production: the real carrier API returns 404 for api/parcels/..., and the client turns that into null, "no such parcel". The customer sees "parcel not found" on the tracking page of an order that was delivered. A handler that matches the URL makes the request address part of the test too.
Why HttpClient Is Not Mocked: the Handler Layer
HttpClient methods such as GetAsync and PostAsJsonAsync are not virtual; they all end up in HttpMessageHandler.SendAsync, and that method is protected. Trying to fake HttpClient with a mocking library either does not work at all or leads to the Protected() calls and string method names from post 17. The right seam is the handler: HttpClient's constructor takes one, and a request goes through the real serialization, header and status code handling before it reaches that handler instead of the network.
The shortest version is a stub of a few lines:
// The last link in the chain, so it derives from HttpMessageHandler, not DelegatingHandler
public sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
: HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests.Add(request);
return respond(request, ct);
}
}
DelegatingHandler is for the links in the middle of the chain that pass the request on to the next handler (handlers that add a header, write a log, retry); a stub passes the request nowhere and produces the response itself. This class is enough when behavior has to be written as code, such as timing and cancellation (the timeout test below). But once different URLs, queries, headers and bodies need different responses, every test starts writing its own chain of ifs; at that point a library reads better.
RichardSzalay.MockHttp: When, Respond and Matchers
RichardSzalay.MockHttp provides a MockHttpMessageHandler that derives from HttpMessageHandler, with a fluent setup API on top. The latest version on the day of writing is 7.1.0 (August 8, 2026), licensed under MIT. The package is compiled for .NET 6 and .NET Standard and works in a .NET 10 test project with no extra settings.
public sealed class ParcelTrackingClientTests
{
private const string Parcel = "https://carrier.example.com/api/v2/parcels/BS2026000042TR";
// A new handler per test: When/Expect definitions never leak into another test
private readonly MockHttpMessageHandler carrier = new();
private ParcelTrackingClient CreateClient() =>
new(new HttpClient(carrier) { BaseAddress = new Uri("https://carrier.example.com/api/v2/") });
[Fact]
public async Task Delivered_parcel_is_read_with_its_events()
{
carrier.When(HttpMethod.Get, Parcel).Respond("application/json", CarrierResponses.Delivered);
ParcelStatus? status = await CreateClient().GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
Assert.NotNull(status);
Assert.Equal(new DateTimeOffset(2026, 11, 14, 15, 42, 0, TimeSpan.Zero), status.DeliveredAtUtc);
Assert.Equal(["Picked up", "Delivered to recipient"], status.Events.Select(e => e.Description));
}
[Fact]
public async Task Unknown_parcel_returns_null()
{
carrier.When(HttpMethod.Get, Parcel).Respond(HttpStatusCode.NotFound);
ParcelStatus? status = await CreateClient().GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
Assert.Null(status);
}
[Fact]
public async Task Tracking_number_is_escaped_in_the_path()
{
MockedRequest escaped = carrier.When("https://carrier.example.com/api/v2/parcels/BS%2042%2FTR")
.Respond(HttpStatusCode.NotFound);
await CreateClient().GetStatusAsync("BS 42/TR", TestContext.Current.CancellationToken);
Assert.Equal(1, carrier.GetMatchCount(escaped));
}
}
When(method, url) defines a "backend definition": every matching request gets the same response, however many times it arrives. * works as a wildcard in the URL (.../parcels/*). If the definition has no query string, URL matching ignores the query; the requests above went out with ?lang=en and still matched. To make the query a condition too, add WithQueryString("lang", "en") (ignores extra parameters) or WithExactQueryString(...) (does not match if there are extra parameters). The other matchers are WithHeaders, WithContent (the whole body), WithPartialContent (a fragment of the body), WithFormData, WithJsonContent<T> and the catch-all With(request => ...).
Respond overloads take different combinations of status code, content type and body; if you need a header, you build the response yourself with Respond(request => new HttpResponseMessage(...)). GetMatchCount(mockedRequest) tells you how many times a definition matched; the last test uses it to check that a tracking number containing a space and a / is escaped in the path as %20 and %2F. Without Uri.EscapeDataString, the / would split the address into two segments, parcels/BS 42 and TR.
Expect, VerifyNoOutstandingExpectation and JSON Bodies
Expect has the same API as When but works differently: each expectation matches only once and in the order it was defined, and VerifyNoOutstandingExpectation() fails the test if an unmatched expectation is left. To check the body sent when an order is handed over to the carrier:
[Fact]
public async Task Shipment_is_created_with_the_order_number_and_weight()
{
carrier.Expect(HttpMethod.Post, "https://carrier.example.com/api/v2/shipments")
.WithJsonContent<ShipmentRequest>(s => s.OrderNumber == "BS-2026-000042" && s.WeightKg == 1.2m)
.Respond(HttpStatusCode.Created, "application/json", """{ "trackingNumber": "BS2026000042TR" }""");
string tracking = await CreateClient().CreateShipmentAsync(
new ShipmentRequest("BS-2026-000042", "Ada Lovelace", "Ankara", "06100", 1.2m),
TestContext.Current.CancellationToken);
Assert.Equal("BS2026000042TR", tracking);
carrier.VerifyNoOutstandingExpectation();
}
As written, it failed:
Xunit.MicrosoftTestingPlatform.XunitException: RichardSzalay.MockHttp.MockHttpMatchException : Failed to match a mocked request for POST https://carrier.example.com/api/v2/shipments
The next request expectation failed to match:
[MATCHED] URL matches https://carrier.example.com/api/v2/shipments
AND [MATCHED] method matches POST
AND [FAILED] JSON request body matches custom ShipmentRequest predicate
The client writes the body with JsonContent.Create, which uses the web defaults: camelCase property names (orderNumber, weightKg). WithJsonContent<T>, on the other hand, reads the body with System.Text.Json's default settings; those are case-sensitive about property names, so OrderNumber stays null, WeightKg stays 0, and the predicate returns false. Passing the client's settings as the second argument is enough:
.WithJsonContent<ShipmentRequest>(
s => s.OrderNumber == "BS-2026-000042" && s.WeightKg == 1.2m, JsonSerializerOptions.Web)
The nice part of the message is that it shows each matcher's result on its own line: the URL and method matched, the body did not. Compared with the argument matchers of NSubstitute and Moq, the problem is easier to find.
Which one when? Use When for query-type calls (reading a parcel status): the test does not care how many times or in what order they happen. Use Expect and VerifyNoOutstandingExpectation for calls with an effect outside the system (opening a shipment, cancelling one): the HTTP counterpart of the rule from posts 16 and 17 to verify only calls with an outside effect. An important detail: according to MockHttp's documentation, When definitions never match while an expectation is outstanding (unless the handler was created with BackendDefinitionBehavior.Always); keep that in mind when you mix the two in one test.
Typed Clients and IHttpClientFactory
Most tests build the client directly with new ParcelTrackingClient(new HttpClient(carrier) { BaseAddress = ... }). The typed client's constructor asks for an HttpClient; creating that HttpClient yourself in the test and handing it the handler is both shorter and more honest than faking IHttpClientFactory. Even if you set up IHttpClientFactory.CreateClient with NSubstitute, you still have to return an HttpClient with a handler in the end; the mock only adds a layer in between.
The only thing that belongs to the factory is the registration: BaseAddress, Timeout, default headers and the handler chain. The Predict First bug lived exactly there, so the registration needs at least one test too. ConfigurePrimaryHttpMessageHandler replaces the handler at the bottom of the factory's chain; everything else in the registration stays real:
[Fact]
public async Task Registered_client_sends_the_account_header_and_the_language()
{
carrier.Expect(HttpMethod.Get, "https://carrier.example.com/api/v2/parcels/BS2026000042TR")
.WithQueryString("lang", "en")
.WithHeaders("X-Carrier-Account", "BOOKSTORE-TR")
.Respond("application/json", CarrierResponses.Delivered);
ParcelTrackingClient client = CreateRegisteredClient(out ServiceProvider provider);
using ServiceProvider _ = provider;
await client.GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
carrier.VerifyNoOutstandingExpectation();
}
private ParcelTrackingClient CreateRegisteredClient(out ServiceProvider provider)
{
ServiceCollection services = new();
services.AddParcelTracking(CarrierResponses.BaseUrl)
.ConfigurePrimaryHttpMessageHandler(() => carrier);
provider = services.BuildServiceProvider();
return provider.GetRequiredService<ParcelTrackingClient>();
}
The stack trace of the Predict First failure (the part I trimmed above) contained LoggingHttpMessageHandler and LoggingScopeHttpMessageHandler lines: the factory's own chain runs in the test as well. These tests need the Microsoft.Extensions.DependencyInjection package for ServiceCollection; the Shipping module itself depends only on Microsoft.Extensions.Http.
Error Scenarios: 429, 5xx, Timeout, Cancellation and Broken JSON
The client turns every problem on the carrier side into a single exception type; calling code does not have to know the difference between HttpRequestException, TaskCanceledException and JsonException:
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
HttpResponseMessage response;
try
{
response = await http.SendAsync(request, ct);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
throw new CarrierException($"Carrier did not respond within {http.Timeout.TotalSeconds}s.", innerException: ex);
}
if (response.StatusCode == HttpStatusCode.TooManyRequests || (int)response.StatusCode >= 500)
{
HttpStatusCode status = response.StatusCode;
TimeSpan? retryAfter = response.Headers.RetryAfter?.Delta;
response.Dispose();
throw new CarrierException($"Carrier returned {(int)status} {status}.", status, retryAfter);
}
return response;
}
For 429 (Too Many Requests) and the Retry-After header we build the response by hand; for 5xx codes a theory is enough:
private static ParcelTrackingClient CreateClient(HttpMessageHandler handler, TimeSpan? timeout = null) =>
new(new HttpClient(handler)
{
BaseAddress = new Uri("https://carrier.example.com/api/v2/"),
Timeout = timeout ?? TimeSpan.FromSeconds(10),
});
private Task<ParcelStatus?> GetStatus(HttpMessageHandler handler, TimeSpan? timeout = null) =>
CreateClient(handler, timeout).GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
[Fact]
public async Task Rate_limit_carries_the_retry_after_delay()
{
carrier.When(Parcel).Respond(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)
{
Headers = { RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(30)) },
});
CarrierException ex = await Assert.ThrowsAsync<CarrierException>(() => GetStatus(carrier));
Assert.Equal(HttpStatusCode.TooManyRequests, ex.StatusCode);
Assert.Equal(TimeSpan.FromSeconds(30), ex.RetryAfter);
}
[Theory]
[InlineData(HttpStatusCode.InternalServerError)]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
public async Task Server_errors_surface_as_carrier_exception(HttpStatusCode status)
{
carrier.When(Parcel).Respond(status);
CarrierException ex = await Assert.ThrowsAsync<CarrierException>(() => GetStatus(carrier));
Assert.Equal(status, ex.StatusCode);
Assert.Null(ex.RetryAfter);
}
Telling a timeout from a cancellation is the part of this client that breaks most easily. When HttpClient.Timeout elapses, .NET throws a TaskCanceledException with a TimeoutException inside; when the caller's own token is cancelled, there is no TimeoutException. With our own stub we write a carrier that waits forever:
[Fact]
public async Task Slow_carrier_hits_the_client_timeout()
{
StubHandler slow = new(async (_, ct) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
return new HttpResponseMessage(HttpStatusCode.OK);
});
CarrierException ex = await Assert.ThrowsAsync<CarrierException>(
() => GetStatus(slow, timeout: TimeSpan.FromMilliseconds(200)));
Assert.Equal("Carrier did not respond within 0.2s.", ex.Message);
Assert.IsType<TimeoutException>(ex.InnerException?.InnerException);
}
[Fact]
public async Task Caller_cancellation_is_not_reported_as_a_carrier_error()
{
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
StubHandler slow = new(async (_, ct) =>
{
await cts.CancelAsync();
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
return new HttpResponseMessage(HttpStatusCode.OK);
});
await Assert.ThrowsAsync<TaskCanceledException>(
() => CreateClient(slow).GetStatusAsync("BS2026000042TR", cts.Token));
}
Without the catch, the raw timeout looks like this:
System.Threading.Tasks.TaskCanceledException : The request was canceled due to the configured HttpClient.Timeout of 0.2 seconds elapsing.
---- System.TimeoutException : A task was canceled.
-------- System.Threading.Tasks.TaskCanceledException : A task was canceled.
The timeout test really takes 200 ms: HttpClient.Timeout runs on the real clock, and the FakeTimeProvider from post 11 cannot advance it. So keep the duration short in the test and do not use the production value (10 seconds) there. In the cancellation test, the cancel is triggered from inside the stub, after the request has actually reached the handler; that way the test covers "cancelled while in flight".
The last scenario is an unreadable body. When deliveredAtUtc arrives as "tomorrow", ReadFromJsonAsync throws this exception:
System.Text.Json.JsonException: The JSON value could not be converted to Bookstore.Shipping.ParcelStatus. Path: $.deliveredAtUtc | LineNumber: 0 | BytePositionInLine: 88.
The client wraps it in CarrierException and keeps the original as InnerException; the test checks both:
CarrierException ex = await Assert.ThrowsAsync<CarrierException>(() => GetStatus(carrier));
JsonException json = Assert.IsType<JsonException>(ex.InnerException);
Assert.Equal("$.deliveredAtUtc", json.Path);
There are 17 Shipping tests in total; all of them finish in under a second, and not a single request goes to the network.
In Production
Do not test the resilience handler in a unit test. When AddStandardResilienceHandler() from Microsoft.Extensions.Http.Resilience is added to the production registration, 429 and 5xx responses go through retry, timeout and circuit breaker. You can test that through the registration with ConfigurePrimaryHttpMessageHandler, but the tests then take as long as the real retry delays, and the outcome depends on Polly's default settings. The unit tests in this post verify what the client does with a single response; post 04 of the Integration and E2E Testing series (Faking External Dependencies at the Boundary) exercises the end-to-end behavior of the resilience policy against a real HTTP server with WireMock.Net. Keep the registration test to the address, the headers and the timeout.
Keep response samples together with their source. Take the JSON in CarrierResponses from the carrier's documentation or from a recorded real response, and write at the top of the file where it came from. As we said in post 08, these tests guarantee that the adapter reads the known format correctly; they do not guarantee that the format is still the same.
Failure output shows the real address. MockHttp's message prints the full URL; use only made-up values in test data so that no customer e-mail or token-carrying query string ends up in the CI log.
Common Pitfalls
A relative URL without BaseAddress. If you build the typed client in a test with new HttpClient(handler) and forget BaseAddress, the test fails before the request even reaches the handler:
System.InvalidOperationException : An invalid request URI was provided. Either the request URI must be an absolute URI or BaseAddress must be set.
That is a good failure; at least it fails loudly. The silent version from Predict First (the missing slash) needs a registration test and a handler that matches the URL.
Sharing the handler between tests. A test class that puts MockHttpMessageHandler in a static field because "creating a new handler in every test is wasteful":
public sealed class SharedCarrierTests
{
// One handler for the whole class: looks economical
private static readonly MockHttpMessageHandler Carrier = new();
...
}
One of two tests defines a 404 for the URL, the other a delivered parcel. Run one at a time, both are green; run together:
failed Bookstore.Shipping.Tests.SharedCarrierTests.Unknown_parcel_returns_null (2ms)
Assert.Null() Failure: Value is not null
Expected: null
Actual: ParcelStatus { TrackingNumber = BS2026000042TR, State = Delivered, DeliveredAtUtc = 11/14/2026 3:42:00 PM +00:00, Events = System.Collections.Generic.List`1[Bookstore.Shipping.ParcelEvent] }
The first matching When wins, and the previous test's definition is still in the handler. Which one stays depends on the order the tests run in. xUnit creates a new instance of the class for every test; keeping the handler in an instance field prevents this leak by itself.
Reading the body with different JSON settings. As in the WithJsonContent example, if the client writes camelCase while the matcher reads case-sensitively, the predicate never matches. Give the matcher the JsonSerializerOptions the client uses.
Mistaking a timeout for a cancellation. catch (TaskCanceledException) on its own swallows two different situations: the carrier is slow (worth trying again) and the user closed the page (do nothing). The InnerException is TimeoutException filter and two separate tests keep that distinction.
A Small Experiment
★☆☆ Add a fifth row to the Relative_uri_is_resolved_against_the_base_address theory: base address https://carrier.example.com/api/v2/, relative address ../v3/parcels/42. Predict the result, then run it.
★★☆ Add CancelShipmentAsync(string trackingNumber, CancellationToken ct) to ParcelTrackingClient (DELETE shipments/{trackingNumber}). Write its test with Expect and VerifyNoOutstandingExpectation; decide what it should do on a 404 and test that as well.
★★★ Add AddStandardResilienceHandler() to the registration and change Server_errors_surface_as_carrier_exception to run through the registration. What happened to the test duration, and how many requests did the handler see (GetMatchCount)? Would you keep this test in the unit test project?
In the next post we go back to test data: Faker<T> rules, the Turkish locale, seeds and strict mode in Bogus; Build, customizations and [AutoData] in AutoFixture. We will write a 1,000-book catalog and a Faker that produces valid 13-digit ISBNs.