Unit Testing Services and Handlers
Test endpoint handlers and services as plain method calls, fake their dependencies with NSubstitute and assert on typed results.
var r = await OrderHandlers.GetOrder(41, repo, ct);
var ok = Assert.IsType<Ok<OrderDto>>(r.Result);
stock.AvailableAsync("MUG", Arg.Any<CancellationToken>()).Returns(10);
await stock.Received(1).ReserveAsync("MUG", 2, Arg.Any<CancellationToken>());A unit test calls your code directly: no server, no routing, no JSON. For an API that means two kinds of target. Domain services (pricing, refund rules, state transitions) are ordinary classes. Endpoint handlers become testable the moment they are named methods instead of lambdas inside Program.cs: MapGet("/{id:int}", OrderHandlers.GetOrder) maps the same static method the test calls. Dependencies arrive as parameters or constructor arguments, so the test passes fakes.
Asserting on typed results
A handler that returns Results<Ok<OrderDto>, NotFound> hands the test real objects. result.Result is the IResult actually chosen, and Assert.IsType<Ok<OrderDto>>(result.Result) both checks the branch and returns it typed, so .Value, .StatusCode and Created<T>.Location are plain properties. ValidationProblem.ProblemDetails.Errors and ProblemHttpResult.ProblemDetails expose the error body. Handlers that return untyped IResult can still be tested through IStatusCodeHttpResult and IValueHttpResult<T>, but the compiler no longer tells you which outcomes exist. To see the bytes a result would write, call ExecuteAsync on a DefaultHttpContext whose RequestServices has logging registered.
Fakes with NSubstitute
Substitute.For<IInventory>() creates a fake; .Returns(10) configures a call (the Task<int> is wrapped for you); Received(1) and DidNotReceive() verify calls; Arg.Any<CancellationToken>() and Arg.Is<T>(predicate) match arguments; ThrowsAsync (namespace NSubstitute.ExceptionExtensions) makes an async call fail. Substitutes are loose: an unconfigured call returns a default, which for Task<Order?> is a completed task holding null. Classes can be substituted, but only virtual members are intercepted; everything else runs real code. Substitute interfaces.
| Dependency | Use in unit tests |
|---|---|
| Repository, gateway, notifier you own | NSubstitute, or a small hand-written in-memory fake |
TimeProvider | FakeTimeProvider |
ILogger<T> | NullLogger<T>.Instance, or FakeLogger<T> when the log entry is the behaviour |
DbContext, HttpClient | Do not substitute: integration test, or a fake HttpMessageHandler |
What a unit test cannot see
Calling the method skips everything the pipeline does around it: route constraints, parameter binding, AddValidation, endpoint filters, authorization, JSON naming, and ProblemDetails customisation. A handler test that passes says nothing about whether POST /orders with "quantity": "two" returns 400. Keep a thin layer of WebApplicationFactory tests for that and spend unit tests on decision logic.
The failure mode: tests that pin implementation
Verifying every call (Received on each repository read) produces tests that break when someone adds a cache or merges two queries, although no behaviour changed. Assert on the returned result and on state first; verify interactions only when the call is the outcome: the card was charged once, the stock was reserved, the email was not sent.
Example
When to use it
- Check that PlaceOrder returns 409 and reserves no stock when the warehouse has 1 kettle left and the customer asks for 2.
- Prove a refund that fails at the payment provider returns 502 and logs exactly one warning carrying the payment id.
- Cover all twelve state transitions of an order (Pending, Paid, Shipped, Cancelled) in milliseconds without starting a host.
More examples
The same handler tested with xUnit and NSubstitute
Each test gets fresh substitutes because xUnit creates a new class instance per test. <code>Arg.Any<CancellationToken>()</code> matters: the handler passes the token through, and a literal <code>CancellationToken.None</code> in <code>Received</code> would not match the test's token. The 409 test verifies that nothing was reserved or saved, because that side effect, not the call count of reads, is the behaviour that matters. The block repeats the handler so it compiles on its own.
using Microsoft.AspNetCore.Http.HttpResults;
using NSubstitute;
using Xunit;
public sealed class PlaceOrderTests
{
private readonly IOrderRepository _orders = Substitute.For<IOrderRepository>();
private readonly IInventory _inventory = Substitute.For<IInventory>();
[Fact]
public async Task Reserves_stock_and_returns_201_with_location()
{
_inventory.AvailableAsync("KETTLE-BLUE", Arg.Any<CancellationToken>()).Returns(10);
_orders.AddAsync("CUST-1042", 69.00m, Arg.Any<CancellationToken>())
.Returns(new Order(7, "CUST-1042", 69.00m, "Pending"));
var result = await OrderHandlers.PlaceOrder(
new PlaceOrderRequest("CUST-1042", "KETTLE-BLUE", 2), _orders, _inventory,
TestContext.Current.CancellationToken);
var created = Assert.IsType<Created<OrderDto>>(result.Result);
Assert.Equal("/orders/7", created.Location);
Assert.Equal(69.00m, created.Value!.Total);
await _inventory.Received(1).ReserveAsync("KETTLE-BLUE", 2, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Out_of_stock_returns_409_and_changes_nothing()
{
_inventory.AvailableAsync("KETTLE-BLUE", Arg.Any<CancellationToken>()).Returns(1);
var result = await OrderHandlers.PlaceOrder(
new PlaceOrderRequest("CUST-1042", "KETTLE-BLUE", 2), _orders, _inventory,
TestContext.Current.CancellationToken);
var problem = Assert.IsType<ProblemHttpResult>(result.Result);
Assert.Equal(StatusCodes.Status409Conflict, problem.StatusCode);
await _inventory.DidNotReceiveWithAnyArgs().ReserveAsync(default!, default, default);
await _orders.DidNotReceiveWithAnyArgs().AddAsync(default!, default, default);
}
}
public sealed record Order(int Id, string CustomerId, decimal Total, string Status);
public sealed record OrderDto(int Id, string CustomerId, decimal Total, string Status);
public sealed record PlaceOrderRequest(string CustomerId, string Sku, int Quantity);
public interface IOrderRepository
{
Task<Order?> FindAsync(int id, CancellationToken ct);
Task<Order> AddAsync(string customerId, decimal total, CancellationToken ct);
}
public interface IInventory
{
Task<int> AvailableAsync(string sku, CancellationToken ct);
Task ReserveAsync(string sku, int quantity, CancellationToken ct);
}
// Mapped in Program.cs with orders.MapGet("/{id:int}", OrderHandlers.GetOrder) and
// orders.MapPost("/", OrderHandlers.PlaceOrder): the endpoint and the test call the same method.
public static class OrderHandlers
{
private const decimal UnitPrice = 34.50m;
public static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
int id, IOrderRepository orders, CancellationToken ct)
{
var order = await orders.FindAsync(id, ct);
return order is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(order));
}
public static async Task<Results<Created<OrderDto>, ValidationProblem, ProblemHttpResult>> PlaceOrder(
PlaceOrderRequest request, IOrderRepository orders, IInventory inventory, CancellationToken ct)
{
if (request.Quantity is < 1 or > 20)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["quantity"] = ["Quantity must be between 1 and 20."],
});
}
var available = await inventory.AvailableAsync(request.Sku, ct);
if (available < request.Quantity)
{
return TypedResults.Problem(
title: "Insufficient stock",
detail: $"Only {available} of {request.Sku} left.",
statusCode: StatusCodes.Status409Conflict);
}
await inventory.ReserveAsync(request.Sku, request.Quantity, ct);
var order = await orders.AddAsync(request.CustomerId, request.Quantity * UnitPrice, ct);
return TypedResults.Created($"/orders/{order.Id}", ToDto(order));
}
private static OrderDto ToDto(Order order) => new(order.Id, order.CustomerId, order.Total, order.Status);
}A failing dependency and an asserted log entry
<code>ThrowsAsync</code> returns a faulted task, which is how a real async gateway fails. <code>Throws</code> would throw synchronously from the call. Here both reach the same <code>catch</code>, but code that starts the task and awaits it later only behaves correctly with <code>ThrowsAsync</code>. <code>FakeLogger<T></code> comes from the <code>Microsoft.Extensions.Diagnostics.Testing</code> package and records every entry with its level and structured values, so the test checks the <code>PaymentId</code> property that dashboards and alerts query, not the rendered message text.
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Logging.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
public interface IPaymentGateway
{
Task<string> RefundAsync(string paymentId, decimal amount, CancellationToken ct);
}
public sealed class PaymentGatewayException(string message) : Exception(message);
public sealed record RefundResponse(string RefundId, decimal Amount);
public sealed class RefundHandler(IPaymentGateway gateway, ILogger<RefundHandler> logger)
{
public async Task<Results<Ok<RefundResponse>, ProblemHttpResult>> HandleAsync(
string paymentId, decimal amount, CancellationToken ct)
{
try
{
var refundId = await gateway.RefundAsync(paymentId, amount, ct);
return TypedResults.Ok(new RefundResponse(refundId, amount));
}
catch (PaymentGatewayException ex)
{
logger.LogWarning(ex, "Refund of {Amount} for payment {PaymentId} failed at the gateway", amount, paymentId);
return TypedResults.Problem(title: "Payment provider unavailable", statusCode: StatusCodes.Status502BadGateway);
}
}
}
public sealed class RefundHandlerTests
{
[Fact]
public async Task Gateway_failure_becomes_502_and_one_warning()
{
var gateway = Substitute.For<IPaymentGateway>();
gateway.RefundAsync("pay_8Kd2", 25.00m, Arg.Any<CancellationToken>())
.ThrowsAsync(new PaymentGatewayException("upstream timeout"));
var logger = new FakeLogger<RefundHandler>();
var handler = new RefundHandler(gateway, logger);
var result = await handler.HandleAsync("pay_8Kd2", 25.00m, TestContext.Current.CancellationToken);
var problem = Assert.IsType<ProblemHttpResult>(result.Result);
Assert.Equal(StatusCodes.Status502BadGateway, problem.StatusCode);
var entry = Assert.Single(logger.Collector.GetSnapshot());
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Equal("pay_8Kd2", entry.GetStructuredStateValue("PaymentId"));
}
}Testing a handler that returns untyped IResult
<code>Results.Ok</code> and <code>TypedResults.Ok</code> build the same object; only the declared return type differs. With <code>IResult</code> the test has to go through the result interfaces in <code>Microsoft.AspNetCore.Http</code>, and nothing stops the handler from gaining a third outcome the tests never check. Changing the return type to <code>Results<Ok<InvoiceDto>, NotFound></code> lets the tests use <code>Assert.IsType</code> and improves the OpenAPI document at the same time.
using Xunit;
public sealed record InvoiceDto(string Number, decimal Amount);
public static class InvoiceHandlers
{
// Older style: Results.Ok returns IResult, so neither the compiler nor OpenAPI knows the outcomes.
public static IResult GetInvoice(string number) =>
number.StartsWith("INV-", StringComparison.Ordinal)
? Results.Ok(new InvoiceDto(number, 129.90m))
: Results.NotFound();
}
public sealed class InvoiceHandlerTests
{
[Fact]
public void Known_invoice_returns_200_with_body()
{
var result = InvoiceHandlers.GetInvoice("INV-2026-0042");
var status = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(StatusCodes.Status200OK, status.StatusCode);
var value = Assert.IsAssignableFrom<IValueHttpResult<InvoiceDto>>(result);
Assert.Equal(129.90m, value.Value!.Amount);
}
[Fact]
public void Unknown_number_returns_404()
{
var status = Assert.IsAssignableFrom<IStatusCodeHttpResult>(InvoiceHandlers.GetInvoice("ABC"));
Assert.Equal(StatusCodes.Status404NotFound, status.StatusCode);
}
}
Discussion