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.
services.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.
| Test | Fake | Catches | Misses |
|---|---|---|---|
| Typed client unit test | Stub handler | URL building, status mapping, timeouts | Provider changes |
| Service unit test | NSubstitute interface | Business decisions on each outcome | Serialization |
| API integration test | Stub primary handler | Wiring, handlers, error responses end to end | Provider changes |
| Contract test | Shared fixture or Pact file | Field renames, type changes, removed fields | Behaviour 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
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<Program></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<InventoryClient>()</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.
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<IInventoryClient, InventoryClient>()</code> so production code depends only on the interface. NSubstitute's <code>ThrowsAsync</code> comes from <code>NSubstitute.ExceptionExtensions</code>.
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.
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