When you’re building .NET applications that interact with external APIs, writing reliable unit tests for your HTTP clients can feel like threading a needle. Most developers reach for mocking frameworks like Moq or NSubstitute to simulate HTTP responses. But what if I told you there’s a cleaner, more deterministic way to test your HTTP clients without relying on any mocking libraries?
In this post, I’ll walk you through how to unit test HTTP clients in .NET using built-in tools only. We’ll build a simple client, make it testable, and write robust tests using HttpMessageHandler and HttpClient without any mocking frameworks.
Understanding the Challenge
Unit testing HTTP clients is tricky because HttpClient is designed to make real network calls. You don’t want your tests to hit external services. You want them fast, isolated, and predictable.
The key insight is this: HttpClient delegates all its work to an internal HttpMessageHandler. If you control that handler, you control the response. By injecting a custom HttpMessageHandler, we can intercept requests and return canned responses.
Setting Up a Test Project in .NET
Let’s start with a clean test setup. You’ll need:
- .NET SDK (any recent version)
- xUnit, NUnit or TUnit (we’ll use xUnit here)
- A test project referencing your main project
Run the following in your terminal:
dotnet new classlib -n HttpClientDemo
dotnet new xunit -n HttpClientDemo.Tests
dotnet add HttpClientDemo.Tests reference ../HttpClientDemoNow you’re ready to build and test.
Writing a Simple HTTP Client
Let’s write a basic client that fetches a GitHub user’s profile.
public class GitHubClient
{
private readonly HttpClient _httpClient;
public GitHubClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> GetUserAsync(string username)
{
var response = await _httpClient.GetAsync(
$"https://api.github.com/users/{username}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}This client is simple, but it’s already testable. Why? Because we’re injecting HttpClient, not creating it inside the class.
Creating a Testable HTTP Client Using DelegatingHandler
To test this client, we’ll create a custom HttpMessageHandler that returns fake responses.
Here’s a reusable handler:
public class FakeHttpMessageHandler : DelegatingHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handlerFunc;
public FakeHttpMessageHandler(
Func<HttpRequestMessage, HttpResponseMessage> handlerFunc)
{
_handlerFunc = handlerFunc;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_handlerFunc(request));
}
}This handler intercepts requests and returns whatever response you define. It’s lightweight, deterministic, and doesn’t require any third-party libraries.
Writing Unit Tests for the HTTP Client
Let’s write a test that verifies our client returns the expected JSON.
public class GitHubClientTests
{
[Fact]
public async Task GetUserAsync_ReturnsExpectedJson()
{
var expectedJson = """{ "login": "octocat" }""";
var handler = new FakeHttpMessageHandler(req =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
expectedJson,
Encoding.UTF8,
"application/json")
};
return response;
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://api.github.com/")
};
var client = new GitHubClient(httpClient);
var result = await client.GetUserAsync("octocat");
Assert.Equal(expectedJson, result);
}
}This test is fast, isolated, and doesn’t touch the network. You’re asserting behavior, not implementation.
Using IHttpClientFactory in Unit Tests
In modern .NET applications, you typically register typed clients through IHttpClientFactory. This factory manages the lifecycle of HttpClient instances and integrates with dependency injection. The good news is that the same testing approach works seamlessly with your GitHubClient.
Here is how you might register it in production:
services.AddHttpClient<GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClientFactory-Sample");
});
In tests, you can override this registration to inject your stub handler:
public static IServiceProvider BuildTestServices(
Func<HttpRequestMessage, HttpResponseMessage> handlerFunc)
{
var services = new ServiceCollection();
services.AddHttpClient<GitHubClient>()
.ConfigurePrimaryHttpMessageHandler(
() => new FakeHttpMessageHandler(handlerFunc));
return services.BuildServiceProvider();
}Now your test can resolve the GitHubClient from the service provider, and it will use the fake handler:
public class GitHubClientTests
{
[Fact]
public async Task GetUserAsync_UsesFactoryAndReturnsExpectedResponse()
{
var provider = BuildTestServices(req =>
{
Assert.Equal(
"https://api.github.com/users/octocat",
req.RequestUri?.ToString());
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{ "login": "octocat" }""")
};
});
var client = provider.GetRequiredService<GitHubClient>();
var result = await client.GetUserAsync("octocat");
Assert.Contains("octocat", result);
}
}
Conclusion
Here are a few takeaways to keep your HTTP client tests clean and maintainable:
- Inject
HttpClient, notHttpMessageHandler. This keeps your client flexible and testable. - Use
DelegatingHandlerto intercept requests. It’s simple and doesn’t require mocking libraries. - Avoid real network calls in unit tests. Use integration tests for end-to-end scenarios.
- Set
BaseAddressexplicitly in tests to avoid hardcoding URLs in your client.
This approach gives you full control over HTTP behavior without introducing external dependencies. It’s fast, reliable, and works across .NET versions.

