Testing Code That Calls Out
Swap in a fake HttpMessageHandler to test outbound calls, assert what was sent, and keep the real network out of unit tests.
using HttpClient client = new(fakeHandler) { BaseAddress = new Uri("https://api.example.com/") };Code that calls another service still needs tests. The test has to answer two questions: does your code send the right request, and does it handle the response correctly? Neither needs a real network.
The seam
HttpClient does not touch a socket itself. It delegates to an HttpMessageHandler, and the constructor new HttpClient(handler) lets you supply one. Write a small class deriving from HttpMessageHandler, override SendAsync, record the request and return whatever response the test needs. Nothing in the code under test changes.
new HttpClient(handler) is shorthand for new HttpClient(handler, disposeHandler: true): the client takes ownership and disposes the handler with itself. Pass false when the test reuses one fake handler across several clients.
Asserting what was sent
The captured HttpRequestMessage carries Method, RequestUri and Headers. Checking the URL is how you catch a missing trailing slash or a mis-encoded query string, which is otherwise a bug you find in production.
Why not the real service
A unit test that calls out over the network fails when the Wi-Fi drops, fails when the other team deploys, and takes a second instead of a millisecond. Worse, it cannot produce the cases you most need to test: a 503, a timeout, malformed JSON. A fake handler returns any of those on demand. Keep real calls for a separate, clearly marked integration test you choose to run.
Example
using System.Net;
using System.Net.Http.Json;
using System.Text;
// The fake decides the response, so the test controls every outcome.
StubHandler handler = new(HttpStatusCode.Created, """{"id":7,"name":"kettle"}""");
using HttpClient client = new(handler) { BaseAddress = new Uri("https://api.example.com/") };
using HttpResponseMessage response =
await client.PostAsJsonAsync("products", new { name = "kettle" });
response.EnsureSuccessStatusCode();
Product? product = await response.Content.ReadFromJsonAsync<Product>();
HttpRequestMessage sent = handler.LastRequest ?? throw new InvalidOperationException("nothing sent");
Console.WriteLine($"method: {sent.Method}");
Console.WriteLine($"url: {sent.RequestUri}");
Console.WriteLine($"body: {handler.LastBody}");
Console.WriteLine($"id: {product?.Id}");
// Output:
// method: POST
// url: https://api.example.com/products
// body: {"name":"kettle"}
// id: 7
record Product(int Id, string Name);
sealed class StubHandler(HttpStatusCode status, string json) : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
// HttpClient calls this instead of opening a connection.
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
// Read the body now: the content is disposed once this call returns.
if (request.Content is not null)
{
LastBody = await request.Content.ReadAsStringAsync(cancellationToken);
}
return new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
}When to use it
- Check that a client builds the right URL before the wrong one reaches production.
- Test the handling of a 503 or a malformed JSON body without asking anyone to break a service.
- Prove an outbound request carries the authorization or idempotency header it must.
- Cut a test suite's runtime by taking the network out of unit tests entirely.
More examples
The same check as an xUnit test
This is the everyday form: one assertion about the request, one about the result. Asserting the full absolute URL is deliberate. It is the only way to catch the classic mistake where BaseAddress has no trailing slash, or the relative path has a leading one, and the request quietly goes to the wrong place.
using System.Net;
using Xunit;
public class CatalogClientTests
{
[Fact]
public async Task GetProductAsync_sends_a_GET_to_the_right_path()
{
StubHandler handler = new(HttpStatusCode.OK, """{"id":42,"name":"kettle"}""");
using HttpClient http = new(handler)
{
BaseAddress = new Uri("https://api.example.com/")
};
CatalogClient catalog = new(http);
Product? product = await catalog.GetProductAsync(42);
Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method);
Assert.Equal("https://api.example.com/products/42", handler.LastRequest.RequestUri?.ToString());
Assert.Equal("kettle", product?.Name);
}
}A reusable fake driven by a function
Use this shape once you have more than two or three tests against the same client. It also covers the cases a real service will not produce on request: a 503 on the first two attempts, a 429 with a Retry-After header, or a body that is not valid JSON. Keeping the list of requests lets you assert how many attempts were made, which is how you test a retry policy without waiting on real backoff delays.
// One handler, no new subclass per test. The test supplies the behaviour.
public sealed class FakeHandler(
Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(respond(request));
}
}
// In a test: fail twice, then succeed, and check the retry policy coped.
int calls = 0;
FakeHandler handler = new(_ =>
{
calls++;
return new HttpResponseMessage(calls < 3
? System.Net.HttpStatusCode.ServiceUnavailable
: System.Net.HttpStatusCode.OK);
});Asserting a header the caller must not forget
Some requirements live entirely in the request. A missing idempotency key does not break any code path you could otherwise assert on; it only shows up as a double charge months later, when a retry happens in production. A test over the captured request is the only cheap place to pin it down. Note the fixed Guid: a test that generated one would not be repeatable.
using System.Net;
using Xunit;
public class PaymentsClientTests
{
[Fact]
public async Task ChargeAsync_sends_the_idempotency_key()
{
StubHandler handler = new(HttpStatusCode.OK, """{"id":"rcp_1","amount":9.99}""");
using HttpClient http = new(handler)
{
BaseAddress = new Uri("https://payments.example.com/")
};
PaymentsClient payments = new(http);
Guid key = new("11111111-1111-1111-1111-111111111111");
await payments.ChargeAsync(key, 9.99m);
Assert.True(handler.LastRequest!.Headers.TryGetValues("Idempotency-Key", out IEnumerable<string>? values));
Assert.Equal(key.ToString(), Assert.Single(values!));
}
}
Discussion