Testing Code That Calls Out

Test typed clients with a fake HttpMessageHandler, stub them behind interfaces in service tests, and guard the wire format with contract tests.

Syntaxservices.AddHttpClient<InventoryClient>(...) .ConfigurePrimaryHttpMessageHandler(() => new StubHandler( _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)));

Code that calls another service has three things worth testing, and each needs a different seam. The typed client: does it build the right request and map every response, including 404, 503 and timeouts, to the right result? The code that uses it: does the order service do the right thing when inventory says no? The contract: does the JSON the provider really sends still match what the client expects?

Seam 1: a fake HttpMessageHandler

HttpClient delegates everything to its handler, so the cleanest fake is a handler that returns canned HttpResponseMessage objects and records what it was asked. Do not try to mock HttpClient itself: GetFromJsonAsync and friends are extension methods and its SendAsync is not designed for substitution. With the factory, swap only the primary handler with ConfigurePrimaryHttpMessageHandler(() => stub); your delegating handlers, base address and timeout still run, so the test covers the real registration.

Cover at least: the success path (path, query, headers, body), the documented error statuses, a malformed or empty body, and a timeout. For the timeout, the stub awaits Task.Delay with the token it receives; the client must tell its own HttpClient.Timeout (a TaskCanceledException while the caller's token is not cancelled) from the caller giving up.

Seam 2: an interface over the typed client

For business logic tests, the HTTP layer is noise. Put the typed client behind an interface such as IInventoryClient and register it with AddHttpClient<IInventoryClient, InventoryClient>(). Unit tests then substitute it with NSubstitute and assert on decisions, not on JSON. Keep the interface in your terms (ReserveAsync(sku, quantity)), not a mirror of the remote API.

Seam 3: in integration tests

With WebApplicationFactory, override the downstream client in ConfigureTestServices: call AddHttpClient<InventoryClient>() again and set a stub primary handler. Because typed clients are named after their type, this reconfigures the existing client instead of adding another. Do it once in a WebApplicationFactory<Program> subclass used as the class fixture and let each test set the shared stub's response; calling WithWebHostBuilder inside every test builds a new host each time. Never let integration tests call a shared staging dependency: they become slow, flaky and order-dependent.

TestFakeCatchesMisses
Typed client unit testStub handlerURL building, status mapping, timeoutsProvider changes
Service unit testNSubstitute interfaceBusiness decisions on each outcomeSerialization
API integration testStub primary handlerWiring, handlers, error responses end to endProvider changes
Contract testShared fixture or Pact fileField renames, type changes, removed fieldsBehaviour under load

Contract tests

Stubs only prove the client agrees with the stub. When the inventory team renames available to availableQuantity, every stub-based test stays green and production gets zeros. A contract test closes that gap: the consumer records the responses it relies on (as JSON fixtures, or with a consumer-driven tool such as Pact), and the provider's CI verifies its real responses against them. Deserialising fixtures with UnmappedMemberHandling.Disallow and RespectRequiredConstructorParameters turns silent drift into a failing test.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Prove the inventory client turns a 503 or a 300 ms timeout into InventoryUnavailableException without starting any server.
  • Run checkout integration tests with the payment provider replaced by a canned handler, keeping the app's real handlers in the chain.
  • Fail the build when the inventory team renames available to availableQuantity instead of finding out from zero-stock orders.

More examples

Replacing the downstream API once, in a WebApplicationFactory subclass

The override lives in <code>ConfigureWebHost</code> of a <code>WebApplicationFactory&lt;Program&gt;</code> subclass used as the class fixture, so every test shares one host instead of paying start-up per test as a <code>WithWebHostBuilder</code> call inside each test would. Calling <code>AddHttpClient&lt;InventoryClient&gt;()</code> again configures the same named client the app registered and the later primary handler wins; each test sets the shared stub's response and the constructor resets it. Tests in one class run sequentially, so the mutable stub is safe there.

Example · csharp
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Xunit;

// Built once per test class by xUnit, so the host starts once, not once per test.
public sealed class CheckoutApiFactory : WebApplicationFactory<Program>
{
    public CannedHandler Inventory { get; } = new();

    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder.ConfigureTestServices(services =>
            services.AddHttpClient<InventoryClient>()
                .ConfigurePrimaryHttpMessageHandler(() => Inventory));
}

public sealed class CannedHandler : HttpMessageHandler
{
    private HttpStatusCode status = HttpStatusCode.OK;
    private string json = "{}";

    public void Reset() => Respond(HttpStatusCode.OK, "{}");

    public void Respond(HttpStatusCode status, string json) => (this.status, this.json) = (status, json);

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
        Task.FromResult(new HttpResponseMessage(status)
        {
            Content = new StringContent(json, Encoding.UTF8, "application/json")
        });
}

public class CheckoutTests : IClassFixture<CheckoutApiFactory>
{
    private readonly CheckoutApiFactory factory;

    public CheckoutTests(CheckoutApiFactory factory)
    {
        this.factory = factory;
        // xUnit creates a new instance per test: reset the shared stub so no test sees another's response.
        factory.Inventory.Reset();
    }

    [Fact]
    public async Task Checkout_returns_409_when_inventory_has_no_stock()
    {
        factory.Inventory.Respond(HttpStatusCode.OK, """{"sku":"KETTLE-BLUE-1L","available":0}""");

        var response = await factory.CreateClient().PostAsJsonAsync("/checkout",
            new { sku = "KETTLE-BLUE-1L", quantity = 1 }, TestContext.Current.CancellationToken);

        Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
    }

    [Fact]
    public async Task Checkout_returns_503_when_inventory_is_down()
    {
        factory.Inventory.Respond(HttpStatusCode.ServiceUnavailable, """{"title":"Maintenance"}""");

        var response = await factory.CreateClient().PostAsJsonAsync("/checkout",
            new { sku = "KETTLE-BLUE-1L", quantity = 1 }, TestContext.Current.CancellationToken);

        Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
    }
}

// Stand-ins for the API project's types.
public partial class Program;
public sealed class InventoryClient(HttpClient http)
{
    public HttpClient Http { get; } = http;
}

Stubbing the client interface in a service test

The service test does not care about status codes or JSON; the typed client's own tests already cover the mapping from 503 to <code>InventoryUnavailableException</code>. Register the real implementation with <code>AddHttpClient&lt;IInventoryClient, InventoryClient&gt;()</code> so production code depends only on the interface. NSubstitute's <code>ThrowsAsync</code> comes from <code>NSubstitute.ExceptionExtensions</code>.

Example · csharp
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;

public interface IInventoryClient
{
    Task<bool> TryReserveAsync(string sku, int quantity, CancellationToken ct);
}

public sealed class InventoryUnavailableException(string message) : Exception(message);

public enum CheckoutOutcome { Reserved, OutOfStock, RetryLater }

public sealed class CheckoutService(IInventoryClient inventory)
{
    public async Task<CheckoutOutcome> ReserveAsync(string sku, int quantity, CancellationToken ct)
    {
        try
        {
            return await inventory.TryReserveAsync(sku, quantity, ct) ? CheckoutOutcome.Reserved : CheckoutOutcome.OutOfStock;
        }
        catch (InventoryUnavailableException)
        {
            return CheckoutOutcome.RetryLater;
        }
    }
}

public class CheckoutServiceTests
{
    [Fact]
    public async Task Asks_the_customer_to_retry_when_inventory_is_down()
    {
        var inventory = Substitute.For<IInventoryClient>();
        inventory.TryReserveAsync("KETTLE-BLUE-1L", 2, Arg.Any<CancellationToken>())
            .ThrowsAsync(new InventoryUnavailableException("503 from inventory"));

        var outcome = await new CheckoutService(inventory)
            .ReserveAsync("KETTLE-BLUE-1L", 2, TestContext.Current.CancellationToken);

        Assert.Equal(CheckoutOutcome.RetryLater, outcome);
    }
}

A consumer contract test on a shared fixture

Strict options make the consumer side fail when the fixture has a field the model does not know, lacks a constructor parameter, or carries a null for a non-nullable property. The other half lives in the provider's pipeline: it requests the real endpoint and compares it to the same fixture, so a rename breaks the provider's build before it breaks this client in production.

Example · csharp
using System.Text.Json;
using System.Text.Json.Serialization;
using Xunit;

public sealed record StockLevel(string Sku, int Available, DateTimeOffset UpdatedAt);

public class InventoryContractTests
{
    // Copied from contracts/inventory/get-stock.200.json, which the inventory team's CI
    // checks against the real GET /v2/stock/{sku} response on every build.
    private const string GetStock200 = """
        {
          "sku": "KETTLE-BLUE-1L",
          "available": 12,
          "updatedAt": "2026-10-20T08:15:00+00:00"
        }
        """;

    private static readonly JsonSerializerOptions Strict = new(JsonSerializerDefaults.Web)
    {
        UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
        RespectRequiredConstructorParameters = true,
        RespectNullableAnnotations = true
    };

    [Fact]
    public void Client_model_matches_the_recorded_response_exactly()
    {
        var stock = JsonSerializer.Deserialize<StockLevel>(GetStock200, Strict);

        Assert.NotNull(stock);
        Assert.Equal("KETTLE-BLUE-1L", stock.Sku);
        Assert.Equal(12, stock.Available);
    }
}

Discussion

  • Be the first to comment on this lesson.