DEV Community

zahid23saim
zahid23saim

Posted on

Building a Tested ASP.NET Core Minimal API in One File

Minimal APIs are the fastest way I know to stand up a real HTTP service in .NET.
No controllers, no ceremony — you map routes to small lambdas and you are done.
But "fast to write" often turns into "quietly wrong": the happy path works in a
browser, and the status codes, validation, and edge cases are never actually
checked. This walks through a small task-tracker API in a single Program.cs, and
then the part people skip — integration tests that boot the whole app in memory
and hit the real endpoints
.

Everything here is .NET 8, and the full project (with all tests passing) is on
GitHub: aspnet-minimal-api.

The API

The whole service fits on one screen. An in-memory ConcurrentDictionary stands
in for a database so it runs with zero setup; swapping it for EF Core later leaves
the endpoint shapes untouched.

using System.Collections.Concurrent;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var store = new ConcurrentDictionary<int, TodoTask>();
var nextId = 0;

app.MapGet("/tasks", () => store.Values.OrderBy(t => t.Id));

app.MapGet("/tasks/{id:int}", (int id) =>
    store.TryGetValue(id, out var task) ? Results.Ok(task) : Results.NotFound());

app.MapPost("/tasks", (CreateTask input) =>
{
    if (string.IsNullOrWhiteSpace(input.Title))
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["title"] = new[] { "Title is required." }
        });

    var id = Interlocked.Increment(ref nextId);
    var task = new TodoTask(id, input.Title.Trim(), Done: false);
    store[id] = task;
    return Results.Created($"/tasks/{id}", task);
});

app.MapPut("/tasks/{id:int}/complete", (int id) =>
{
    if (!store.TryGetValue(id, out var task)) return Results.NotFound();
    store[id] = task with { Done = true };
    return Results.Ok(store[id]);
});

app.MapDelete("/tasks/{id:int}", (int id) =>
    store.TryRemove(id, out _) ? Results.NoContent() : Results.NotFound());

app.Run();

public record TodoTask(int Id, string Title, bool Done);
public record CreateTask(string? Title);
public partial class Program { }
Enter fullscreen mode Exit fullscreen mode

A few decisions in here matter more than they look:

  • Results.Created(...) returns 201 and a Location header pointing at the new resource — the correct REST response to a create, and one a hand-rolled return task; silently gets wrong.
  • Validation returns 400, not an exception. Results.ValidationProblem produces a proper problem-details body, so a blank title is a clean client error rather than a 500.
  • record types give value semantics and the with expression, so task with { Done = true } is a one-liner that returns a new, updated task.
  • {id:int} route constraints mean /tasks/abc never even reaches the handler.
  • That last line — public partial class Program { } — exists only so the test project can reference Program. It is the one non-obvious requirement for what comes next.

The part people skip: testing it

The temptation is to test the handlers as plain functions. The problem is that
skips everything that makes it a web API — routing, model binding, status codes,
serialization. ASP.NET Core ships WebApplicationFactory<Program>, which boots the
entire app in memory and hands you an HttpClient wired to it. No mocks, no
running server, no ports.

public class TaskApiTests : IDisposable
{
    private readonly WebApplicationFactory<Program> _factory = new();
    public void Dispose() => _factory.Dispose();

    private record TaskDto(int Id, string Title, bool Done);

    [Fact]
    public async Task Create_then_fetch_roundtrips()
    {
        var client = _factory.CreateClient();

        var created = await client.PostAsJsonAsync("/tasks", new { title = "write tests" });
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        Assert.NotNull(created.Headers.Location);

        var task = await created.Content.ReadFromJsonAsync<TaskDto>();
        var fetched = await client.GetFromJsonAsync<TaskDto>($"/tasks/{task!.Id}");
        Assert.Equal(task.Id, fetched!.Id);
    }

    [Fact]
    public async Task Blank_title_is_rejected_with_400()
    {
        var client = _factory.CreateClient();
        var res = await client.PostAsJsonAsync("/tasks", new { title = "   " });
        Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
    }
}
Enter fullscreen mode Exit fullscreen mode

These are real HTTP requests against the real pipeline. The full suite also covers
completing a task, trimming a title, a 404 on a missing id, and deleting the same
task twice (204 then 404).

The isolation trap I hit writing this

My first version shared one WebApplicationFactory across the whole test class
with IClassFixture. That reuses a single app instance — and since the store is
in-memory, one test's writes leaked into the next. The "empty store returns an
empty list" test failed because earlier tests had already added rows.

The fix is the shape above: create a fresh factory per test (in the constructor,
disposed in Dispose) so each test gets its own store. For a stateful in-memory
service this is the correct isolation, and it is worth knowing before a flaky
suite sends you debugging the API when the API was never the problem.

Takeaway

Minimal APIs make the endpoints tiny, which is exactly why the discipline has to
move into the tests. WebApplicationFactory lets you verify the whole HTTP surface
— status codes, headers, validation, edge cases — in a few dozen lines and no
infrastructure. The complete, passing project is
here if you want to run it.

Top comments (0)