Testing services that make HTTP calls to external APIs (SendGrid for email, Azure Blob Storage SDK, weather APIs) requires controlling the HTTP layer without real network calls. The standard approach is to mock HttpMessageHandler — the base class that HttpClient delegates actual request execution to. A custom MockHttpMessageHandler is cleaner and more flexible than using Moq’s Protected() API for this purpose.
MockHttpMessageHandler
// ── Custom MockHttpMessageHandler — cleaner than Moq Protected() ──────────
public class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new();
private readonly List<HttpRequestMessage> _requests = new();
public IReadOnlyList<HttpRequestMessage> SentRequests => _requests;
public MockHttpMessageHandler RespondWith(
HttpStatusCode status, string json = "{}",
string contentType = "application/json")
{
_responses.Enqueue(new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, contentType)
});
return this; // fluent
}
public MockHttpMessageHandler RespondWithError(HttpStatusCode status)
=> RespondWith(status, @"{""error"": ""Error""}");
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
_requests.Add(request);
if (_responses.Count == 0)
throw new InvalidOperationException("No responses configured.");
return Task.FromResult(_responses.Dequeue());
}
}
// ── Using MockHttpMessageHandler ─────────────────────────────────────────
public class EmailServiceTests
{
[Fact]
public async Task SendPasswordReset_OnSuccess_SendsCorrectRequest()
{
var handler = new MockHttpMessageHandler()
.RespondWith(HttpStatusCode.OK, @"{""id"":""msg-123""}");
var httpClient = new HttpClient(handler)
{ BaseAddress = new Uri("https://api.sendgrid.com/") };
var factoryMock = new Mock<IHttpClientFactory>();
factoryMock.Setup(f => f.CreateClient("SendGrid")).Returns(httpClient);
var sut = new EmailService(factoryMock.Object, _configMock.Object);
await sut.SendPasswordResetAsync("alice@test.com", "reset-token", default);
// Verify the request was correct
handler.SentRequests.Should().HaveCount(1);
var request = handler.SentRequests[0];
request.Method.Should().Be(HttpMethod.Post);
request.RequestUri!.AbsolutePath.Should().Be("/v3/mail/send");
var body = await request.Content!.ReadAsStringAsync();
body.Should().Contain("alice@test.com");
body.Should().Contain("reset-token");
}
[Fact]
public async Task SendPasswordReset_When429_ThrowsRateLimitException()
{
var handler = new MockHttpMessageHandler()
.RespondWith(HttpStatusCode.TooManyRequests)
.RespondWith(HttpStatusCode.TooManyRequests)
.RespondWith(HttpStatusCode.TooManyRequests);
var httpClient = new HttpClient(handler)
{ BaseAddress = new Uri("https://api.sendgrid.com/") };
var factoryMock = new Mock<IHttpClientFactory>();
factoryMock.Setup(f => f.CreateClient("SendGrid")).Returns(httpClient);
var sut = new EmailService(factoryMock.Object, _configMock.Object);
Func<Task> act = () => sut.SendPasswordResetAsync(
"alice@test.com", "reset-token", default);
await act.Should().ThrowAsync<RateLimitException>();
handler.SentRequests.Should().HaveCount(3); // retried twice
}
}
MockHttpMessageHandler uses a Queue<HttpResponseMessage> to support sequences of responses — the first call dequeues the first response, the second call dequeues the second, and so on. This makes testing retry logic natural: add three 429 responses followed by a 200, and the test verifies the service retried exactly twice before succeeding. The SentRequests list lets you assert on every request that was made — what URL, what method, what body.MockHttpMessageHandler with the real IHttpClientFactory using services.AddHttpClient("SendGrid").ConfigurePrimaryHttpMessageHandler(() => handler) instead of manually creating an HttpClient. This tests the IHttpClientFactory configuration (named client setup, timeout, retry policies from Polly) in addition to the handler response. This is closer to the real production setup and catches configuration bugs that pure handler mocking misses.HttpResponseMessage objects in the mock handler — they are consumed by the calling code after being returned. Creating them in the handler and returning them is correct. The caller (HttpClient) reads the content and may dispose the response. If you reuse the same HttpResponseMessage instance across multiple calls, the second caller finds the content already disposed. The queue-based approach (creating a new response per dequeue) avoids this correctly.Common Mistakes
Mistake 1 — Not testing HTTP error responses (only happy path tested)
❌ Wrong — tests only mock 200 OK; service error handling for 429, 503, timeout is untested and likely broken.
✅ Correct — test each error status code the service is expected to handle; verify correct exception type is thrown.
Mistake 2 — Sharing MockHttpMessageHandler instance across tests (queue state bleeds between tests)
❌ Wrong — shared handler; first test’s unused responses remain in queue; second test dequeues wrong response.
✅ Correct — create a new handler instance per test; each test starts with an empty queue.