A teammate asked me to add a test for the "product not found" case on one of our endpoints. Simple ask. The handler returned the product or a 404, and we had nothing covering the 404. Twenty minutes later I was staring at a test that booted a WebApplicationFactory, created an HttpClient, sent a request to /products/999, and asserted that response.StatusCode was 404. It worked. It also felt absurd. I'd started a web server, with DI and routing and the whole pipeline, to check one if branch.
That branch lives in my code. I wrote it. Why was I going through HTTP to reach it?
The return type was already telling me everything
The handler looked roughly like this, inlined on the route:
app.MapGet("/products/{id:int}", (int id, IProductStore store) =>
{
var product = store.Find(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
Results.Ok and Results.NotFound both return IResult. That's the problem in miniature. IResult is a sealed box. From the outside you can't ask it "are you a 404?" without executing it against an HttpContext, which is exactly why my test needed a server in the first place. The type told me nothing, so I had to run it to find out what it did.
The fix is to stop hiding behind IResult. ASP.NET Core has typed result unions, and once I pulled the handler into a named method the signature does the describing:
public static Results<Ok<Product>, NotFound> GetProduct(int id, IProductStore store)
{
var product = store.Find(id);
return product is null
? TypedResults.NotFound()
: TypedResults.Ok(product);
}
The supporting cast is deliberately boring, just enough to have something to find or not find:
public record Product(int Id, string Name, decimal Price);
public interface IProductStore { Product? Find(int id); }
public sealed class InMemoryStore : IProductStore
{
private readonly Dictionary<int, Product> _items = new()
{
[1] = new(1, "Mechanical keyboard", 119.00m),
[2] = new(2, "USB-C hub", 39.50m),
};
public Product? Find(int id) => _items.GetValueOrDefault(id);
}
TypedResults.Ok(product) gives back an Ok<Product>, not an IResult. The union Results<Ok<Product>, NotFound> says out loud: this endpoint returns one of exactly these two things. The route registration doesn't change at all. app.MapGet("/products/{id:int}", ProductHandlers.GetProduct) still binds id and store the same way, and the metadata it produces for OpenAPI actually gets better, because the framework now knows both response shapes without me annotating them.
The test stops being a test of HTTP
Here's the part that paid for the refactor. The method is just a method now. I can call it.
[Fact]
public void Missing_id_returns_404()
{
var result = ProductHandlers.GetProduct(999, _store);
Assert.IsType<NotFound>(result.Result);
}
[Fact]
public void Existing_id_returns_the_product()
{
var result = ProductHandlers.GetProduct(1, _store);
var ok = Assert.IsType<Ok<Product>>(result.Result);
Assert.Equal(200, ok.StatusCode);
Assert.Equal("Mechanical keyboard", ok.Value!.Name);
}
result.Result is the underlying IResult the union is holding, and now I can pattern-match it to the concrete type and read ok.Value straight off it. No client, no JSON round-trip, no deserialization, no port to pick. On my machine the whole suite finishes like this:
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 15 ms
Fifteen milliseconds for two tests, and most of that is the runner warming up. Compare that to standing a host up and tearing it down. More than the speed, though, I like that the test reads like the thing it's testing. "Missing id returns 404" calls the handler and checks for NotFound. That's the whole sentence.
And to be sure I wasn't fooling myself, I kept one foot in reality and ran the actual endpoint:
GET /products/1 -> 200 {"id":1,"name":"Mechanical keyboard","price":119.00}
GET /products/999 -> 404
Same two outcomes the unit tests assert, coming out of a real Kestrel this time. They agree, which is what I wanted to confirm before trusting the fast version.
Where this stops being enough
I don't want to oversell it. These unit tests cover the branching logic inside the handler and nothing else. They don't exercise routing, so they won't catch a typo in the route template. They don't run model binding, so they won't tell me the {id:int} constraint is doing its job. They don't touch serialization, auth, or any middleware. All of that still needs a real request through a real pipeline to mean anything, so I kept a thin slice of integration tests for the wiring and moved the per-branch assertions down to this level.
My honest take after doing this across a few endpoints: the win isn't really the speed, it's that the return type became documentation that the compiler checks. When a handler claims Results<Ok<Product>, NotFound> and someone later adds a BadRequest path, the code won't compile until they widen the union, and that's a prompt to go update the tests too. The server-based test never would've nudged them.
If you've got a pile of endpoint tests that each boot a host just to read a status code, which ones would actually survive being pulled down to this level? I'd bet more than you'd expect.
If you want to wire it up yourself, the whole endpoint is three lines in a Microsoft.NET.Sdk.Web project:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductStore, InMemoryStore>();
var app = builder.Build();
app.MapGet("/products/{id:int}", ProductHandlers.GetProduct);
app.Run();
The test project is a plain xunit SDK project that references the API project, with a private readonly IProductStore _store = new InMemoryStore(); field backing the two facts above. That's the entire moving-parts list.
— still writing tests for branches nobody asked me to cover
Top comments (0)