Applications often depend on external HTTP APIs for payment processing, email delivery, customer information, shipping calculations, and other services. These integrations work well when the external service is available, but they can make automated tests slow, unreliable, and difficult to repeat.

For example, an integration test that calls a real payment API may fail because of a network timeout, an API rate limit, expired credentials, or a temporary service outage. It may also create real transactions or consume paid API credits.

A better approach is to test your application's HTTP integration without depending on the actual external service. In .NET, you can do this by configuring HttpClient to use a custom HttpMessageHandler that returns controlled responses.

This article explains how to mock external HTTP APIs in .NET integration tests, test successful and failed responses, and avoid common testing mistakes.

Why Mock External HTTP APIs?

Consider an ASP.NET Core application that retrieves customer information from an external service.

The application might send an HTTP request to an endpoint such as /customers/123. Your code needs to handle the response correctly, including successful responses, missing records, server errors, and timeouts.

Calling the real service for every test creates several problems:

  • Tests depend on network connectivity and the external service's availability.

  • Test results can change when the external service changes its data.

  • Requests may require real credentials or paid API access.

  • Tests can become slower because of network latency.

  • Failures become harder to diagnose because the problem may be outside your application.

Mocking the HTTP boundary allows tests to control the response and verify how the application behaves.

It is important to understand what this approach tests. A mocked HTTP handler can test your request construction, response handling, serialization, error handling, and application behavior. It does not prove that the external service is available or that its real API contract has not changed. Those concerns may require a small number of separate contract or sandbox tests.

Create a Service That Uses HttpClient

Before writing the test, create a service that communicates with the external API.

Suppose the external service returns customer details in JSON.

C#
public sealed class Customer
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public string Email { get; set; } = string.Empty;
}

Create a service that retrieves a customer by ID.

C#
using System.Net;
using System.Net.Http.Json;

public sealed class CustomerApiClient
{
    private readonly HttpClient _httpClient;

    public CustomerApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<Customer?> GetCustomerAsync(
        int customerId,
        CancellationToken cancellationToken = default)
    {
        using var response = await _httpClient.GetAsync(
            $"customers/{customerId}",
            cancellationToken);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<Customer>(
            cancellationToken: cancellationToken);
    }
}

The service receives HttpClient through its constructor. This makes it easy to configure a real HTTP client in the application and a test-specific client during testing.

The service also distinguishes between a missing customer and an unsuccessful HTTP response. A 404 Not Found returns null, while other unsuccessful status codes cause EnsureSuccessStatusCode() to throw an exception.

Keeping this behavior in a dedicated client makes it easier to test and reuse the integration.

Mock HttpClient with a Custom HttpMessageHandler

HttpClient delegates its HTTP operations to an HttpMessageHandler. By supplying a custom handler, a test can intercept requests and return predefined responses without sending anything over the network.

Create a test handler:

C#
using System.Net;
using System.Net.Http;

public sealed class FakeHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<
        HttpRequestMessage,
        CancellationToken,
        Task<HttpResponseMessage>> _sendAsync;

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

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

This handler accepts a delegate that determines how each request should be handled. A test can inspect the request and return any response required for the scenario.

The handler is intentionally small. It does not need to implement DNS resolution, sockets, TLS, or actual network communication.

Write a Test for a Successful Response

The following example uses xUnit to verify that the client retrieves and deserializes a customer response.

C#
using System.Net;
using System.Text;
using Xunit;

public sealed class CustomerApiClientTests
{
    [Fact]
    public async Task GetCustomerAsync_ReturnsCustomer_WhenRequestSucceeds()
    {
        var handler = new FakeHttpMessageHandler(
            (request, cancellationToken) =>
            {
                Assert.Equal(HttpMethod.Get, request.Method);
                Assert.Equal(
                    "/customers/123",
                    request.RequestUri!.AbsolutePath);

                var response = new HttpResponseMessage(
                    HttpStatusCode.OK)
                {
                    Content = new StringContent(
                        """
                        {
                            "id": 123,
                            "name": "Aarav Patel",
                            "email": "[email protected]"
                        }
                        """,
                        Encoding.UTF8,
                        "application/json")
                };

                return Task.FromResult(response);
            });

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

        var customerApiClient = new CustomerApiClient(httpClient);

        var customer = await customerApiClient.GetCustomerAsync(123);

        Assert.NotNull(customer);
        Assert.Equal(123, customer.Id);
        Assert.Equal("Aarav Patel", customer.Name);
        Assert.Equal("[email protected]", customer.Email);
    }
}

The test verifies both the HTTP request and the resulting customer object.

The base address is a test-only URL. Because the custom handler handles the request, the client does not contact an actual external server.

This is useful for checking whether the application uses the correct HTTP method, builds the expected URL, and correctly handles the JSON response.

Test Not Found and Server Error Responses

A reliable HTTP client must handle unsuccessful responses correctly. Testing only the success path leaves important behavior unverified.

Test a missing customer

The external API might return 404 Not Found when a customer does not exist.

C#
[Fact]
public async Task GetCustomerAsync_ReturnsNull_WhenCustomerDoesNotExist()
{
    var handler = new FakeHttpMessageHandler(
        (request, cancellationToken) =>
            Task.FromResult(
                new HttpResponseMessage(HttpStatusCode.NotFound)));

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

    var client = new CustomerApiClient(httpClient);

    var customer = await client.GetCustomerAsync(999);

    Assert.Null(customer);
}

This test confirms that the client follows its defined behavior for a missing customer.

Test an unsuccessful server response

A 500 Internal Server Error should not be treated as a successful response.

C#
[Fact]
public async Task GetCustomerAsync_Throws_WhenServerReturnsError()
{
    var handler = new FakeHttpMessageHandler(
        (request, cancellationToken) =>
            Task.FromResult(
                new HttpResponseMessage(
                    HttpStatusCode.InternalServerError)));

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

    var client = new CustomerApiClient(httpClient);

    await Assert.ThrowsAsync<HttpRequestException>(
        () => client.GetCustomerAsync(123));
}

EnsureSuccessStatusCode() throws an HttpRequestException for this response. The test confirms that the failure reaches the caller rather than being silently converted into an empty result.

If your application translates HTTP errors into domain-specific exceptions or API responses, test that behavior at the appropriate application boundary as well.

Test Timeouts and Cancellation

External HTTP calls can take too long or be cancelled when the caller no longer needs the result. The client should pass cancellation tokens to its HTTP operations, as shown in the earlier implementation.

A test handler can simulate a cancellation-aware operation:

C#
[Fact]
public async Task GetCustomerAsync_PropagatesCancellation()
{
    var handler = new FakeHttpMessageHandler(
        async (request, cancellationToken) =>
        {
            await Task.Delay(
                Timeout.InfiniteTimeSpan,
                cancellationToken);

            return new HttpResponseMessage(HttpStatusCode.OK);
        });

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

    var client = new CustomerApiClient(httpClient);

    using var cancellationSource = new CancellationTokenSource();

    cancellationSource.Cancel();

    await Assert.ThrowsAnyAsync<OperationCanceledException>(
        () => client.GetCustomerAsync(
            123,
            cancellationSource.Token));
}

The cancellation token is already cancelled when the request begins, so the simulated operation stops without waiting for a real network timeout.

For timeout-specific behavior, configure a short HttpClient.Timeout or simulate a delayed handler and verify how your application handles cancellation. Remember that HttpClient timeouts can also surface as cancellation-related exceptions, so avoid assuming every OperationCanceledException means the caller explicitly cancelled the operation.

Register the Client with Dependency Injection

In an ASP.NET Core application, register the client with IHttpClientFactory rather than creating a new HttpClient for every request.

JavaScript
builder.Services.AddHttpClient<CustomerApiClient>(client =>
{
    client.BaseAddress = new Uri(
        builder.Configuration["CustomerApi:BaseUrl"]
        ?? throw new InvalidOperationException(
            "Customer API base URL is not configured."));
});

The application can then inject CustomerApiClient wherever customer data is needed.

For a unit test, construct the client with the fake handler as shown earlier. For an integration test that exercises ASP.NET Core's dependency injection and endpoint behavior, use a test host and replace the registered HTTP handler or client configuration for the test.

This distinction matters. A test that constructs CustomerApiClient directly verifies the client in isolation. A test that runs an ASP.NET Core endpoint through a test host can also verify routing, dependency injection, application services, and response serialization.

When to Use a Mocking Library

A custom HttpMessageHandler is enough for many tests. However, mocking libraries such as Moq can reduce boilerplate when a project already uses them.

The main requirement is the same: intercept the handler's SendAsync method and return a controlled HttpResponseMessage.

Do not mock every internal method of HttpClient. The handler is the appropriate interception point because it represents the transport boundary used by the client.

For a service that performs many different HTTP operations, consider creating a reusable test handler that supports response queues, request recording, and configurable exceptions. Keep that helper focused on test needs rather than building a full HTTP server inside the test project.

Common Mistakes to Avoid

One common mistake is mocking the HTTP response but never checking the outgoing request. A client could call the wrong endpoint, use an incorrect HTTP method, or omit a required header while the test still passes. Assert the request details that matter to the integration.

Another mistake is sharing mutable response objects across tests. Each request should receive a fresh HttpResponseMessage and content instance. Dispose responses after use, and avoid reusing disposed response content.

It is also easy to over-test implementation details. Tests should verify the behavior that callers depend on, not the exact internal sequence of every method call.

Finally, do not remove all tests against the real external API. Mocked tests provide fast, deterministic coverage, but they cannot detect every contract change or authentication problem. Where appropriate, maintain a small set of controlled tests against a sandbox or a contract-testing environment.

Conclusion

Mocking external HTTP APIs in .NET integration tests helps isolate application behavior from network failures, service outages, and changing external data.

A custom HttpMessageHandler gives you direct control over HTTP responses while allowing the application to use the same HttpClient-based code it uses in production. Test successful responses, missing records, server errors, request construction, and cancellation.

Use these tests for fast, repeatable feedback, and complement them with a smaller set of contract or sandbox tests when you need confidence that the external service still behaves as expected.