DEV Community

Nick
Nick

Posted on AI-assisted

Testing HttpClient the Right Way: Mock Handlers, Not Interfaces

Hey test-loving developers! 👋

I see this pattern ALL the time:

// ❌ The wrong way
public interface IHttpClientWrapper
{
    Task<T> GetAsync<T>(string url);
    Task<T> PostAsync<T>(string url, object data);
}
Enter fullscreen mode Exit fullscreen mode

Stop wrapping HttpClient! There's a much better way to test HTTP calls. Let me show you!

The Problem with Wrapper Interfaces

  1. You're testing your wrapper, not real behavior — Mocking IHttpClientWrapper.GetAsync<User>() doesn't test serialization, headers, or error handling.

  2. You lose HttpClient features — Timeouts, handlers, resilience policies... all gone behind your abstraction.

  3. It's unnecessaryHttpClient is already designed for testability!

The Right Way: Mock the Handler

HttpClient takes an HttpMessageHandler in its constructor. That's your test seam!

public class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;

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

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, 
        CancellationToken cancellationToken)
    {
        return _handler(request, cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

🎯 Fun Fact: The handler pattern is why HttpClient implements IDisposable but you're told not to dispose it frequently. The handler does the real work and is what's expensive to create/destroy!

Your First Mock Test

[Fact]
public async Task GetUser_ReturnsUser_WhenApiSucceeds()
{
    // Arrange
    var expectedUser = new User { Id = 1, Name = "John" };

    var handler = new MockHttpMessageHandler((request, ct) =>
    {
        Assert.Equal(HttpMethod.Get, request.Method);
        Assert.Equal("/api/users/1", request.RequestUri?.PathAndQuery);

        var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = JsonContent.Create(expectedUser)
        };
        return Task.FromResult(response);
    });

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

    var userClient = new UserApiClient(client);

    // Act
    var user = await userClient.GetByIdAsync(1);

    // Assert
    Assert.NotNull(user);
    Assert.Equal(expectedUser.Id, user.Id);
    Assert.Equal(expectedUser.Name, user.Name);
}
Enter fullscreen mode Exit fullscreen mode

Using Moq (More Flexible)

[Fact]
public async Task GetUser_Throws_WhenApiReturns500()
{
    // Arrange
    var handlerMock = new Mock<HttpMessageHandler>();

    handlerMock
        .Protected()
        .Setup<Task<HttpResponseMessage>>(
            "SendAsync",
            ItExpr.IsAny<HttpRequestMessage>(),
            ItExpr.IsAny<CancellationToken>())
        .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.InternalServerError)
        {
            Content = new StringContent("Server error")
        });

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

    var userClient = new UserApiClient(client);

    // Act & Assert
    await Assert.ThrowsAsync<HttpRequestException>(
        () => userClient.GetByIdAsync(1));
}
Enter fullscreen mode Exit fullscreen mode

A Reusable Test Helper

public static class HttpClientTestHelper
{
    public static HttpClient CreateMockClient<TResponse>(
        TResponse response,
        HttpStatusCode statusCode = HttpStatusCode.OK,
        Action<HttpRequestMessage>? requestValidator = null)
    {
        var handler = new MockHttpMessageHandler((request, ct) =>
        {
            requestValidator?.Invoke(request);

            return Task.FromResult(new HttpResponseMessage(statusCode)
            {
                Content = JsonContent.Create(response)
            });
        });

        return new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.example.com")
        };
    }

    public static HttpClient CreateErrorClient(
        HttpStatusCode statusCode,
        string? errorMessage = null)
    {
        var handler = new MockHttpMessageHandler((request, ct) =>
        {
            return Task.FromResult(new HttpResponseMessage(statusCode)
            {
                Content = errorMessage != null 
                    ? new StringContent(errorMessage) 
                    : null
            });
        });

        return new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.example.com")
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Clean Tests

[Fact]
public async Task GetUser_ReturnsUser()
{
    // Arrange
    var expected = new User { Id = 1, Name = "John" };
    var client = HttpClientTestHelper.CreateMockClient(expected);
    var userClient = new UserApiClient(client);

    // Act
    var user = await userClient.GetByIdAsync(1);

    // Assert
    Assert.Equal(expected.Id, user?.Id);
}

[Fact]
public async Task GetUser_ReturnsNull_WhenNotFound()
{
    // Arrange
    var client = HttpClientTestHelper.CreateErrorClient(HttpStatusCode.NotFound);
    var userClient = new UserApiClient(client);

    // Act
    var user = await userClient.GetByIdAsync(999);

    // Assert
    Assert.Null(user);
}
Enter fullscreen mode Exit fullscreen mode

💡 Testing with WebApplicationFactory (Integration)

For integration tests, use the real HTTP stack:

public class UserApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public UserApiIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetUsers_ReturnsUsers()
    {
        // Act
        var users = await _client.GetFromJsonAsync<List<User>>("/api/users");

        // Assert
        Assert.NotNull(users);
        Assert.NotEmpty(users);
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Resilience Policies

Want to verify your retry logic works?

[Fact]
public async Task Client_RetriesOnTransientFailure()
{
    // Arrange
    var callCount = 0;

    var handler = new MockHttpMessageHandler((request, ct) =>
    {
        callCount++;

        // Fail first 2 times, succeed on 3rd
        if (callCount < 3)
        {
            return Task.FromResult(
                new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
        }

        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = JsonContent.Create(new User { Id = 1 })
        });
    });

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

    // Add resilience (in real code, use IHttpClientFactory)
    var userClient = new UserApiClient(client);

    // Act
    var user = await userClient.GetByIdAsync(1);

    // Assert
    Assert.Equal(3, callCount); // Verified it retried
    Assert.NotNull(user);
}
Enter fullscreen mode Exit fullscreen mode

Testing Request Content

[Fact]
public async Task CreateUser_SendsCorrectPayload()
{
    // Arrange
    HttpRequestMessage? capturedRequest = null;

    var handler = new MockHttpMessageHandler(async (request, ct) =>
    {
        capturedRequest = request;
        return new HttpResponseMessage(HttpStatusCode.Created)
        {
            Content = JsonContent.Create(new User { Id = 1, Name = "John" })
        };
    });

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

    var userClient = new UserApiClient(client);

    // Act
    var createRequest = new CreateUserRequest { Name = "John", Email = "john@test.com" };
    var user = await userClient.CreateAsync(createRequest);

    // Assert
    Assert.NotNull(capturedRequest);
    Assert.Equal(HttpMethod.Post, capturedRequest.Method);

    var body = await capturedRequest.Content!.ReadFromJsonAsync<CreateUserRequest>();
    Assert.Equal("John", body?.Name);
    Assert.Equal("john@test.com", body?.Email);
}
Enter fullscreen mode Exit fullscreen mode

Cheat Sheet: What to Test

Test Approach
Happy path Mock handler returns expected data
Error handling Mock handler returns error codes
Request format Capture and inspect request
Retry logic Count handler invocations
Timeout behavior Mock handler with Task.Delay
Headers Inspect request.Headers in mock
Integration WebApplicationFactory

Wrapping Up

Stop creating wrapper interfaces around HttpClient. The built-in handler pattern is:

  • More realistic (tests actual serialization)
  • More flexible (test any HTTP behavior)
  • More maintainable (no fake abstraction layer)

Mock the handler, test the real client! 🎯

Happy testing! 🚀

Top comments (0)