BrandGhost
Mock HttpClient C#: DelegatingHandler, Mock Handlers, and Integration Testing

Mock HttpClient C#: DelegatingHandler, Mock Handlers, and Integration Testing

Mock HttpClient C#: DelegatingHandler, Mock Handlers, and Integration Testing

If you have written code that calls an external HTTP API, you have run into this problem. The production code works. But the tests either make real network calls -- slow, flaky, requiring live infrastructure -- or you skip them entirely. Neither answer is acceptable. Mock HttpClient in C# is the solution, and once you understand the mechanism behind it, the approach is remarkably clean.

This article covers the full testing toolkit for HttpClient in .NET 10. You will learn how to mock HttpClient C# -- the right way -- using MockHttpMessageHandler from scratch, stubbing response bodies and status codes, capturing and asserting on outgoing requests, wiring mock handlers into IHttpClientFactory, testing resilience pipelines, and running full integration tests with WebApplicationFactory. Everything here targets .NET 10 with C# 12+ syntax, using xUnit for the test framework.

If you are just starting with HttpClient and want to understand how it works before testing it, the HttpClient in C#: The Complete Guide is a solid foundation to read first. If you have already used HttpClient in production and are here specifically to learn how to mock HttpClient C# test suites need, you are in the right place.

Why HttpClient Is Hard to Test

HttpClient looks straightforward. You inject it, call GetAsync or PostAsJsonAsync, process the response. The issue surfaces the moment you try to test that code. There is no IHttpClient interface to swap out. The class is not abstract or sealed in a way that lets you override behavior. You cannot easily substitute a fake.

The deeper problem is what happens when a test runs. Without any interception, HttpClient opens a real TCP connection to whatever BaseAddress or URL you have configured. In a unit test, that means your tests are:

  • Dependent on the target server being up and reachable
  • Potentially slow due to DNS resolution, TLS handshakes, and network latency
  • Non-deterministic -- the server might return different data each time
  • Impossible to run offline or in isolated CI environments

Beyond that, you cannot control what the server returns. You cannot simulate a 503 Service Unavailable response to test retry logic. You cannot test what happens when the API returns malformed JSON. You cannot verify that your code sends the correct headers or the right request body. Real network calls lock you into testing only the happy path, and only when the server cooperates.

There is a better approach. It comes from understanding where HttpClient actually does its work. The technique to mock HttpClient C# developers rely on does not require any special test infrastructure -- just a well-placed seam that is already built into the framework.

The HttpMessageHandler as the Test Seam

Here is the key insight: HttpClient does not make network calls itself. It delegates every request to an HttpMessageHandler. By default, that handler is HttpClientHandler, which opens sockets and does the actual I/O. But that is just the default.

The HttpClient constructor accepts an HttpMessageHandler parameter:

var client = new HttpClient(myCustomHandler);

That is your test seam. Pass in a fake handler, and you control everything. The HttpClient code above your handler stays exactly the same -- it still calls SendAsync the same way. Your handler returns whatever response you want instead of going to the network.

This is a direct application of the Dependency Inversion Principle in C# -- the framework depends on the HttpMessageHandler abstraction, not the concrete network layer. Your tests exploit that same seam.

The HttpMessageHandler base class has one protected virtual method:

protected override Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request,
    CancellationToken cancellationToken);

Everything flows through that single method. Intercept it, and you intercept every HTTP call the client makes.

Building a Mock HttpClient C# Handler from Scratch

You do not need an external library to mock HttpClient C#. A minimal custom handler does the job clearly and without extra dependencies:

using System.Net.Http;

// A minimal mock HttpMessageHandler that delegates to a Func<>
// This gives full control over the response without external dependencies.
public sealed class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _sendAsync;

    public MockHttpMessageHandler(
        Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsync)
    {
        _sendAsync = sendAsync;
    }

    // Convenience constructor for simple synchronous stubs
    public MockHttpMessageHandler(Func<HttpResponseMessage> responseFactory)
        : this((_, _) => Task.FromResult(responseFactory()))
    {
    }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
        => _sendAsync(request, cancellationToken);
}

That is the whole thing. A Func<> receives the request and returns a response. The caller decides what to return. The handler just invokes it.

This design keeps the handler itself logic-free -- no branching, no state. All test-specific behavior lives in the lambda you pass in. That makes the handler reusable across tests, and the intent of each test is clear at the call site.

Writing Response Stubs: Status Codes, Headers, JSON Bodies

With MockHttpMessageHandler in hand, you need a way to build realistic responses. The standard library provides everything you need. Here is a set of helper factories for the most common scenarios:

using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;

public static class HttpStubs
{
    // Returns a 200 OK with a JSON-serialized body
    public static MockHttpMessageHandler ReturnsJson<T>(
        T value,
        HttpStatusCode status = HttpStatusCode.OK)
    {
        var json = JsonSerializer.Serialize(value);
        return new MockHttpMessageHandler((_, _) =>
            Task.FromResult(new HttpResponseMessage(status)
            {
                Content = new StringContent(json, Encoding.UTF8, "application/json")
            }));
    }

    // Returns a response with a specific status code and no body
    public static MockHttpMessageHandler ReturnsStatus(HttpStatusCode status) =>
        new MockHttpMessageHandler((_, _) =>
            Task.FromResult(new HttpResponseMessage(status)));

    // Returns JSON plus custom headers -- useful for pagination or rate-limit testing
    public static MockHttpMessageHandler ReturnsJsonWithHeaders<T>(
        T value,
        IDictionary<string, string> headers,
        HttpStatusCode status = HttpStatusCode.OK)
    {
        var json = JsonSerializer.Serialize(value);
        return new MockHttpMessageHandler((_, _) =>
        {
            var response = new HttpResponseMessage(status)
            {
                Content = new StringContent(json, Encoding.UTF8, "application/json")
            };
            foreach (var (key, val) in headers)
                response.Headers.TryAddWithoutValidation(key, val);
            return Task.FromResult(response);
        });
    }
}

These stubs handle the vast majority of scenarios you will encounter when you need to mock HttpClient C#. Typed JSON responses, status-only responses for error conditions, and responses with custom headers for things like pagination cursors or rate-limit metadata. The HttpStubs factory means individual test methods stay focused on what they're testing rather than boilerplate response construction.

Verifying Request Behavior: Method, URL, Headers, Body Content

Stubbing the response is half the problem. The other half is verifying that your code sends the right request. The pattern here is to capture the request inside the handler and assert on it after the call completes:

[Fact]
public async Task CreateUser_SendsCorrectRequestToApi()
{
    // Arrange -- capture variables for post-call assertions
    HttpRequestMessage? capturedRequest = null;
    string? capturedBody = null;

    var handler = new MockHttpMessageHandler(async (req, ct) =>
    {
        capturedRequest = req;

        // Read the body NOW -- the stream is not seekable after the call completes
        capturedBody = await req.Content!.ReadAsStringAsync(ct);

        return new HttpResponseMessage(HttpStatusCode.Created)
        {
            Content = new StringContent(
                """{"id":1,"name":"Alice","email":"[email protected]"}""",
                Encoding.UTF8,
                "application/json")
        };
    });

    var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };
    var apiClient = new UserApiClient(httpClient);

    // Act
    await apiClient.CreateUserAsync(new User(0, "Alice", "[email protected]"));

    // Assert -- verify what was actually sent
    Assert.NotNull(capturedRequest);
    Assert.Equal(HttpMethod.Post, capturedRequest.Method);
    Assert.Equal("/users", capturedRequest.RequestUri?.PathAndQuery);
    Assert.Equal("application/json", capturedRequest.Content?.Headers.ContentType?.MediaType);
    Assert.NotNull(capturedBody);
    Assert.Contains("Alice", capturedBody);
}

One critical note: read req.Content inside the handler's delegate, not after SendAsync returns. The request body stream is consumed when the handler finishes. If you try to read the content afterward, you will get an empty string or an ObjectDisposedException. Capture what you need while you still have access to it.

DelegatingHandler for Test Interceptors (Spy/Capture Pattern)

A DelegatingHandler is a different beast from HttpMessageHandler. Where HttpMessageHandler sits at the end of the pipeline and generates the response, a DelegatingHandler sits in the middle -- it observes and optionally modifies requests and responses, then passes them along to the next handler in the chain.

This makes DelegatingHandler ideal for the spy pattern. You want to record what happened without replacing the handler that produces the response. This is especially useful when testing middleware behavior like authentication header injection, request correlation IDs, or logging, where you care about what passes through, not about fabricating a response:

public sealed class SpyDelegatingHandler : DelegatingHandler
{
    private readonly List<HttpRequestMessage> _requests = [];
    private readonly List<HttpResponseMessage> _responses = [];

    public IReadOnlyList<HttpRequestMessage> Requests => _requests;
    public IReadOnlyList<HttpResponseMessage> Responses => _responses;
    public int CallCount => _requests.Count;

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        _requests.Add(request);

        // Pass through to the next handler in the pipeline
        var response = await base.SendAsync(request, cancellationToken);

        _responses.Add(response);
        return response;
    }
}

You compose the spy with a mock inner handler to create a complete test pipeline:

[Fact]
public async Task AuthorizationHeader_IsSentWithEveryRequest()
{
    // Arrange -- spy sits in front, mock produces responses behind it
    var innerHandler = HttpStubs.ReturnsJson(new User(1, "Alice", "[email protected]"));
    var spy = new SpyDelegatingHandler { InnerHandler = innerHandler };

    var client = new HttpClient(spy) { BaseAddress = new Uri("https://api.example.com") };
    var apiClient = new UserApiClient(client);

    // Act
    await apiClient.GetUserAsync(1);
    await apiClient.GetUsersAsync();

    // Assert -- verify cross-cutting behavior on both calls
    Assert.Equal(2, spy.CallCount);
    Assert.All(spy.Requests, req =>
        Assert.True(
            req.Headers.Contains("Authorization"),
            "Every outgoing request must carry an Authorization header."));
}

The spy gives you full visibility into the pipeline without replacing the handler that produces the response. It is a clean way to verify cross-cutting concerns independently of business logic.

Testing with IHttpClientFactory -- Wiring the Mock Handler

Direct construction works well for unit tests against a typed client class. Production code, however, typically uses IHttpClientFactory via AddHttpClient. The challenge is that the factory builds HttpClient instances internally -- you cannot pass in a handler the same way you do when constructing directly.

The solution is ConfigurePrimaryHttpMessageHandler. It hooks into the factory's builder pipeline at the point where the outermost handler is configured:

using Microsoft.Extensions.DependencyInjection;

[Fact]
public async Task UserApiClient_ViaFactory_ReturnsMockedUser()
{
    // Arrange -- wire the mock handler through the factory
    var expected = new User(7, "Bob", "[email protected]");
    var mockHandler = HttpStubs.ReturnsJson(expected);

    var services = new ServiceCollection();
    services
        .AddHttpClient<IUserApiClient, UserApiClient>(client =>
        {
            client.BaseAddress = new Uri("https://api.example.com");
        })
        .ConfigurePrimaryHttpMessageHandler(() => mockHandler);

    await using var provider = services.BuildServiceProvider();
    var apiClient = provider.GetRequiredService<IUserApiClient>();

    // Act
    var user = await apiClient.GetUserAsync(7);

    // Assert
    Assert.NotNull(user);
    Assert.Equal("Bob", user.Name);
    Assert.Equal(7, user.Id);
}

This approach tests the full IHttpClientFactory integration. The client is resolved from the DI container exactly as it would be in production. The only substitution is the HTTP handler at the bottom of the pipeline. Any DelegatingHandler layers added above it -- like retry or logging -- still run, which means this test also covers those layers implicitly.

Integration Testing with WebApplicationFactory -- Real HTTP Within the Test Process

When you need to test the full request path through your ASP.NET Core application -- routing, middleware, controllers, the services they call, and finally the outgoing HTTP calls those services make -- WebApplicationFactory is the right tool. It starts your application in-process and lets you replace any registered service, including the HTTP handlers for named or typed clients.

If you are not yet familiar with WebApplicationFactory itself, the Testing ASP.NET Core Web API: WebApplicationFactory and Integration Tests article covers the fundamentals. The approach here layers on top of that foundation.

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;

public sealed class WeatherForecastIntegrationTests
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public WeatherForecastIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetForecast_ReturnsOk_WhenWeatherApiIsAvailable()
    {
        // Arrange -- replace the external weather API handler for this test only
        var fakeForecast = new[] { new Forecast("Sunny", 24), new Forecast("Cloudy", 18) };
        var mockHandler = HttpStubs.ReturnsJson(fakeForecast);

        var testClient = _factory
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureServices(services =>
                {
                    services
                        .AddHttpClient<IWeatherService, WeatherService>()
                        .ConfigurePrimaryHttpMessageHandler(() => mockHandler);
                });
            })
            .CreateClient();

        // Act -- this flows through the full ASP.NET Core pipeline
        var response = await testClient.GetAsync("/api/forecast");

        // Assert
        response.EnsureSuccessStatusCode();
        var body = await response.Content.ReadAsStringAsync();
        Assert.Contains("Sunny", body);
    }
}

The WithWebHostBuilder pattern creates a scoped override that applies only to this test's client, without affecting other tests sharing the same IClassFixture. The real ASP.NET Core request pipeline runs end-to-end -- routing, middleware, model binding, and all. Only the outgoing call to the external weather API is intercepted by the mock handler.

Testing Resilience Behavior -- Simulating Transient Failures and Verifying Retry

.NET 8 introduced AddStandardResilienceHandler() from Microsoft.Extensions.Http.Resilience, which wraps HttpClient calls with a Polly-based pipeline covering retry, circuit breaker, and timeout. Testing that your resilience configuration actually fires requires simulating failures. The call-counting pattern makes this straightforward:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;

[Fact]
public async Task GetUser_RetriesOnTransientFailure_AndSucceedsOnThirdAttempt()
{
    // Arrange -- return 503 twice, then 200 on the third call
    var callCount = 0;
    var handler = new MockHttpMessageHandler((_, _) =>
    {
        callCount++;
        var statusCode = callCount < 3
            ? HttpStatusCode.ServiceUnavailable   // 503 triggers retry
            : HttpStatusCode.OK;

        var response = new HttpResponseMessage(statusCode);
        if (statusCode == HttpStatusCode.OK)
        {
            response.Content = new StringContent(
                """{"id":1,"name":"Alice","email":"[email protected]"}""",
                Encoding.UTF8, "application/json");
        }
        return Task.FromResult(response);
    });

    var services = new ServiceCollection();
    services
        .AddHttpClient<IUserApiClient, UserApiClient>(c =>
            c.BaseAddress = new Uri("https://api.example.com"))
        .ConfigurePrimaryHttpMessageHandler(() => handler)
        .AddStandardResilienceHandler();

    await using var provider = services.BuildServiceProvider();
    var apiClient = provider.GetRequiredService<IUserApiClient>();

    // Act
    var user = await apiClient.GetUserAsync(1);

    // Assert -- the call was retried, and the final result is correct
    Assert.Equal(3, callCount);
    Assert.NotNull(user);
    Assert.Equal("Alice", user.Name);
}

This test proves the retry pipeline is wired up and actually firing for your specific client configuration. Without it, you are trusting AddStandardResilienceHandler to work but not verifying it is hooked to the right client. One practical note: the standard resilience handler's default retry strategy includes real backoff delays (exponential, ~2 seconds base). For fast test suites, configure shorter delays via AddStandardResilienceHandler(o => o.Retry.Delay = TimeSpan.Zero) or inject a FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing.

NSubstitute Alternative for Simple Scenarios

If your project uses NSubstitute across the board, you can integrate it with mock HttpClient C# testing. The challenge is that HttpMessageHandler.SendAsync is a protected method -- NSubstitute cannot configure protected members on a concrete type directly.

The solution is a thin abstract adapter that exposes a public method and bridges it to SendAsync:

// Adapter that exposes a substitutable public method
// Use this as the base when you need NSubstitute's argument matchers
public abstract class TestableHttpMessageHandler : HttpMessageHandler
{
    // NSubstitute can configure this public abstract method
    public abstract Task<HttpResponseMessage> HandleRequestAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken);

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
        => HandleRequestAsync(request, cancellationToken);
}

With this base class, NSubstitute works exactly as expected:

using NSubstitute;

[Fact]
public async Task GetUser_UsesNSubstitute_ToReturnStubResponse()
{
    // Arrange
    var handler = Substitute.For<TestableHttpMessageHandler>();
    handler
        .HandleRequestAsync(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
        .Returns(new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(
                """{"id":5,"name":"Carol","email":"[email protected]"}""",
                Encoding.UTF8, "application/json")
        });

    var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };
    var apiClient = new UserApiClient(httpClient);

    // Act
    var user = await apiClient.GetUserAsync(5);

    // Assert
    Assert.NotNull(user);
    Assert.Equal("Carol", user.Name);

    // Verify via NSubstitute received check
    await handler.Received(1)
        .HandleRequestAsync(
            Arg.Is<HttpRequestMessage>(r => r.Method == HttpMethod.Get),
            Arg.Any<CancellationToken>());
}

The NSubstitute approach is useful when you need the richer argument matching and received-call verification that Received() provides. For most scenarios, though, the MockHttpMessageHandler with a lambda is lighter weight. Both are valid tools and can coexist in the same test suite.

Common Pitfalls When Mocking HttpClient

Even with the right approach, a few mistakes show up repeatedly in codebases. Here are the ones most likely to waste your time:

  • Disposing HttpResponseMessage before the handler captures it. If you construct a new HttpResponseMessage(...) inside the mock delegate, it is fine. If you create it outside and share it across multiple invocations, the first successful read disposes the content stream and subsequent calls silently return empty bodies.

  • Forgetting to set BaseAddress. When you build an HttpClient with a handler directly (not via factory), there is no BaseAddress unless you set it. If your typed client uses relative URLs like GetAsync("users/1"), those calls will throw InvalidOperationException at runtime. Always set BaseAddress on the HttpClient in your test setup.

  • Not reading the request body inside the handler. If you want to assert on the request payload, read await req.Content!.ReadAsStringAsync() inside the mock delegate. Once SendAsync returns, the content stream is consumed. Trying to read it after the fact gives you an empty string or an ObjectDisposedException.

  • Testing resilience pipelines without a handler counter. A mock that always returns 200 cannot prove your retry policy works. Track the number of times the handler is called -- retries should increment that counter -- and assert on both the call count and the final response.

Complete Example: Mock HttpClient C# in a Full Typed Client Test Suite

Let's put everything together. Here is a realistic typed REST client implementation followed by a complete test suite, covering happy path, 404 handling, error propagation, and request shape verification.

First, the client under test:

using System.Net;
using System.Net.Http;
using System.Net.Http.Json;

namespace MyApp.ApiClients;

public sealed record User(int Id, string Name, string Email);

public interface IUserApiClient
{
    Task<User?> GetUserAsync(int id, CancellationToken cancellationToken = default);
    Task<IReadOnlyList<User>> GetUsersAsync(CancellationToken cancellationToken = default);
    Task<User> CreateUserAsync(User user, CancellationToken cancellationToken = default);
}

// Primary constructor syntax -- C# 12+ (available since .NET 8)
public sealed class UserApiClient(HttpClient client) : IUserApiClient
{
    public async Task<User?> GetUserAsync(int id, CancellationToken cancellationToken = default)
    {
        var response = await client.GetAsync($"/users/{id}", cancellationToken);
        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<User>(cancellationToken: cancellationToken);
    }

    public async Task<IReadOnlyList<User>> GetUsersAsync(CancellationToken cancellationToken = default)
    {
        var response = await client.GetAsync("/users", cancellationToken);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<List<User>>(cancellationToken: cancellationToken) ?? [];
    }

    public async Task<User> CreateUserAsync(User user, CancellationToken cancellationToken = default)
    {
        var response = await client.PostAsJsonAsync("/users", user, cancellationToken);
        response.EnsureSuccessStatusCode();
        return (await response.Content.ReadFromJsonAsync<User>(cancellationToken: cancellationToken))!;
    }
}

Now the full test suite:

using System.Net;
using System.Net.Http;
using System.Text;
using Xunit;

namespace MyApp.Tests.ApiClients;

public sealed class UserApiClientTests
{
    // Factory helper -- keeps individual tests focused on their scenario
    private static UserApiClient CreateClient(MockHttpMessageHandler handler)
    {
        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://api.example.com")
        };
        return new UserApiClient(httpClient);
    }

    [Fact]
    public async Task GetUserAsync_ReturnsUser_WhenApiReturns200()
    {
        var expected = new User(42, "Alice", "[email protected]");
        var client = CreateClient(HttpStubs.ReturnsJson(expected));

        var user = await client.GetUserAsync(42);

        Assert.NotNull(user);
        Assert.Equal(42, user.Id);
        Assert.Equal("Alice", user.Name);
        Assert.Equal("[email protected]", user.Email);
    }

    [Fact]
    public async Task GetUserAsync_ReturnsNull_WhenApiReturns404()
    {
        var client = CreateClient(HttpStubs.ReturnsStatus(HttpStatusCode.NotFound));

        var user = await client.GetUserAsync(999);

        Assert.Null(user);
    }

    [Fact]
    public async Task GetUserAsync_ThrowsHttpRequestException_OnServerError()
    {
        var client = CreateClient(HttpStubs.ReturnsStatus(HttpStatusCode.InternalServerError));

        await Assert.ThrowsAsync<HttpRequestException>(() => client.GetUserAsync(1));
    }

    [Fact]
    public async Task GetUsersAsync_ReturnsAllUsers_WhenApiResponds()
    {
        var expected = new List<User>
        {
            new(1, "Alice", "[email protected]"),
            new(2, "Bob", "[email protected]")
        };
        var client = CreateClient(HttpStubs.ReturnsJson(expected));

        var users = await client.GetUsersAsync();

        Assert.Equal(2, users.Count);
        Assert.Equal("Alice", users[0].Name);
        Assert.Equal("Bob", users[1].Name);
    }

    [Fact]
    public async Task GetUsersAsync_ReturnsEmptyList_WhenNoUsersExist()
    {
        var client = CreateClient(HttpStubs.ReturnsJson(Array.Empty<User>()));

        var users = await client.GetUsersAsync();

        Assert.Empty(users);
    }

    [Fact]
    public async Task CreateUserAsync_SendsJsonBody_WithPostMethodAndCorrectPath()
    {
        // Arrange -- capture the request for assertion
        HttpRequestMessage? captured = null;
        string? capturedBody = null;

        var handler = new MockHttpMessageHandler(async (req, ct) =>
        {
            captured = req;
            capturedBody = await req.Content!.ReadAsStringAsync(ct);
            return new HttpResponseMessage(HttpStatusCode.Created)
            {
                Content = new StringContent(
                    """{"id":99,"name":"Dave","email":"[email protected]"}""",
                    Encoding.UTF8, "application/json")
            };
        });

        var client = CreateClient(handler);

        // Act
        var created = await client.CreateUserAsync(new User(0, "Dave", "[email protected]"));

        // Assert request shape
        Assert.Equal(HttpMethod.Post, captured?.Method);
        Assert.Equal("/users", captured?.RequestUri?.PathAndQuery);
        Assert.NotNull(capturedBody);
        Assert.Contains("Dave", capturedBody);
        Assert.Contains("[email protected]", capturedBody);

        // Assert response mapping
        Assert.Equal(99, created.Id);
        Assert.Equal("Dave", created.Name);
    }

    [Fact]
    public async Task CreateUserAsync_ThrowsHttpRequestException_OnConflict()
    {
        var client = CreateClient(HttpStubs.ReturnsStatus(HttpStatusCode.Conflict));

        await Assert.ThrowsAsync<HttpRequestException>(() =>
            client.CreateUserAsync(new User(0, "Alice", "[email protected]")));
    }
}

This is what a complete test suite looks like when you mock HttpClient in C# for a typed REST client. Every meaningful behavior is covered: success cases, not-found handling, error propagation, and request shape verification. No external server. No flakiness. No network dependency. Each test is deterministic, fast, and clear about what it is proving.

Wrapping Up

Testing code that uses HttpClient in C# comes down to one key insight: intercept HttpMessageHandler.SendAsync. The ability to mock HttpClient C# test suites rely on entirely depends on that seam, and the framework hands it to you for free. Everything else -- response stubs, request capture, factory wiring, resilience verification, integration test overrides -- builds on top of that single seam. Once you have MockHttpMessageHandler, the rest follows naturally and composably.

The patterns in this article apply to any HttpClient-based code in .NET 10. Typed clients, named clients from IHttpClientFactory, or directly injected clients -- the handler-based approach works for all of them. Add a SpyDelegatingHandler for cross-cutting concerns, use WebApplicationFactory overrides for end-to-end integration tests, and count handler invocations to verify resilience behavior.

For building the APIs that these clients consume, the ASP.NET Core Web API in .NET: The Complete Guide covers the server side in depth -- useful context for understanding what your mock handler is simulating.

Frequently Asked Questions

What is the best way to mock HttpClient in C#?

A clean, dependency-free approach is to create a custom MockHttpMessageHandler that extends HttpMessageHandler and overrides SendAsync. Pass this handler to the HttpClient constructor. This gives you full control over responses without external mocking libraries, and the intent of each test is immediately clear at the call site. Libraries like MockHttp or WireMock.Net are also popular choices if your team prefers richer built-in matchers or needs HTTP-level simulation at a server level.

Can I use Moq or NSubstitute directly with HttpMessageHandler?

Not directly, because SendAsync is a protected method and standard mocking frameworks cannot configure protected members on concrete types. The workaround is to create an abstract base class that exposes a public abstract method and delegates to SendAsync. You can then substitute or mock that public method using your preferred framework.

How do I test HttpClient code that uses IHttpClientFactory?

Use ConfigurePrimaryHttpMessageHandler on the IHttpClientBuilder when configuring the service collection in your test. This replaces the bottom-most handler in the factory's pipeline with your mock, while keeping any DelegatingHandler layers -- like retry or logging middleware -- intact above it.

How do I verify that my retry policy is actually working?

Use a MockHttpMessageHandler with an invocation counter. Return failure status codes (503, 429, etc.) for the first N calls, then return success. Wire this handler via ConfigurePrimaryHttpMessageHandler and add your resilience pipeline. After the call, assert on both the counter value and the final result to confirm retries fired and the correct response was returned.

What is the difference between MockHttpMessageHandler and DelegatingHandler in tests?

MockHttpMessageHandler sits at the end of the pipeline and generates responses -- it is the leaf node that replaces the real network handler. DelegatingHandler sits in the middle of the pipeline and observes or modifies traffic while passing requests through to the next handler. In tests, you typically combine both: a SpyDelegatingHandler wraps a MockHttpMessageHandler, giving you visibility into what passes through while the mock produces the stub response.

How do I read the request body inside a MockHttpMessageHandler?

Read req.Content.ReadAsStringAsync() inside the handler's Func<> delegate before returning the response. The request body stream is consumed when SendAsync returns. If you attempt to read the content after the method completes, the stream will be empty or disposed. Capture everything you need to assert on while the handler is still executing.

When should I use WebApplicationFactory versus direct unit tests for HttpClient testing?

Use direct unit tests with MockHttpMessageHandler when testing a typed client class in isolation -- verifying how it maps requests and handles different response scenarios. Use WebApplicationFactory when you need to test the full request pipeline end-to-end, from an incoming HTTP request through your controllers and services to the outgoing HTTP calls they make. Both are valuable. They test different things and should be used alongside each other.

WebApplicationFactory in ASP.NET Core: Practical Tips for C# Developers

Learn about WebApplicationFactory in ASP.NET Core and leveraging it for testing. Use an HttpClient and implement the tips in this article for better tests!

Weekly Recap: EF Core, HttpClient, and C# Design Patterns [Jun 2026]

This week goes deep on Entity Framework Core -- relationships, LINQ querying, performance tuning, testing strategies, and how it compares to Dapper -- plus HttpClient and IHttpClientFactory patterns in .NET. There's also a full run of C# design patterns covering Visitor, Interpreter, and Memento, alongside new videos on debugging, AI in your development workflow, and engineering leadership.

HttpClient in C#: The Complete Guide for .NET Developers

The definitive guide to HttpClient in C# and .NET 10 -- covering correct usage patterns, IHttpClientFactory, DNS pitfalls, resilience, streaming, HTTP/3, testing, and observability. No other article you'll need.

An error has occurred. This application may no longer respond until reloaded. Reload