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);
}
Stop wrapping HttpClient! There's a much better way to test HTTP calls. Let me show you!
The Problem with Wrapper Interfaces
You're testing your wrapper, not real behavior — Mocking
IHttpClientWrapper.GetAsync<User>()doesn't test serialization, headers, or error handling.You lose HttpClient features — Timeouts, handlers, resilience policies... all gone behind your abstraction.
It's unnecessary —
HttpClientis 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);
}
}
🎯 Fun Fact: The handler pattern is why
HttpClientimplementsIDisposablebut 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);
}
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));
}
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")
};
}
}
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);
}
💡 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);
}
}
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);
}
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);
}
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)