A small ASP.NET Core (.NET 10) minimal-API demo that tracks stock prices behind
authentication. The application itself is intentionally tiny and was "vibe coded" as
a demo β but its integration test suite is the point of this repository. The tests
are written deliberately, as a reusable model for testing minimal-API applications
against real infrastructure. If you're here to learn one thing, read
StockPriceTracker.Tests.Integration.
πΊ Stop Inheriting Chaos β A Better Way to Write Integration Tests (Part 1)
A minimal API with JWT and cookie authentication over ASP.NET Core Identity:
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/auth/register |
POST | Anonymous | Register an Identity user |
/auth/login |
POST | Anonymous | Log in, returns a JWT |
/antiforgery/token |
GET | Anonymous | Issue an antiforgery (CSRF) token |
/stocks/{ticker} |
GET | Authenticated | Look up a stock by ticker |
/stocks |
POST | administrator role |
Add a new stock |
Supporting pieces: EF Core with both SQLite and PostgreSQL providers, an injected
TimeProvider for deterministic timestamps, JWT issuance via TokenService, and role/admin
seeding on startup.
StockPriceTracker/ The application
Program.cs Composition root; exposes `partial class Program` for tests
Endpoints/ Auth + Stock minimal-API endpoint groups
Extensions/ServiceExtensions.cs AddSqlite / AddPostgreSql / AddIdentityAndAuth
Data/ AppDbContext + startup DatabaseInitializer
Services/TokenService.cs JWT creation
StockPriceTracker.Tests.Integration/ The main event (see below)
Requires the .NET 10 SDK. The PostgreSQL integration tests also need a running Docker engine (they use Testcontainers).
# Run the app (SQLite by default)
dotnet run --project StockPriceTracker
# Run the full integration suite (SQLite + PostgreSQL)
dotnet testThe app needs a
Jwt:Keyat startup (seeappsettings.Development.json/ user secrets). The tests supply their own key viaappsettings.Testing.json, sodotnet testworks out of the box.
These tests boot the real application in-memory with
WebApplicationFactory<Program> and drive it over HTTP. Several patterns here are
worth lifting into your own projects.
The test logic is written once in a generic base class and then executed against each database provider by declaring a thin concrete subclass per provider:
public abstract class AddStockTestsBase<TFixture> : WebAppTestBase<TFixture>
where TFixture : WebAppFixtureBase
{
[Fact]
public async Task AddStock_WithJwtAuth_CreatesANewStock_WhenDataIsValid() { /* ... */ }
}
// Same tests, two real databases β zero duplicated test code:
public class AddStockWithSqliteTests : AddStockTestsBase<SqliteFixture>, IClassFixture<SqliteFixture> { }
public class AddStockWithPostgreSqlTests : AddStockTestsBase<PostgreSqlFixture>, IClassFixture<PostgreSqlFixture> { }You get the speed of SQLite during development and the fidelity of a real PostgreSQL server β from the same assertions. Add a provider by adding one fixture and one one-line subclass.
PostgreSqlFixture starts one Testcontainers PostgreSQL container for the whole
test run (started exactly once, lazily), then hands each fixture its own uniquely-named
database inside that container:
private static readonly PostgreSqlContainer SharedContainer = new PostgreSqlBuilder().Build();
private static readonly Lazy<Task> ContainerStart = new(() => SharedContainer.StartAsync());
private readonly string _databaseName = $"StockPriceTracker_{Guid.NewGuid():N}";This is the sweet spot: pay the container startup cost once, but keep test classes
isolated from each other's data. SqliteFixture mirrors the same contract with a
throwaway per-fixture .sqlite file, so the two providers are interchangeable.
Tests never juggle real passwords or tokens. A test auth handler is swapped into the DI container and emits whatever claims you ask for, behind a fluent builder:
var client = CreateClient()
.WithJwtAuth(claims => claims.AsAdmin()) // or .WithCookieAuth(...)
.Build();WithJwtAuth/WithCookieAuthselect the scheme and replace the real handler withConfigurableTestAuthHandler.ClaimsBuilder(AsAdmin(),AsUser(id),WithRole(...),WithClaim(...)) makes the identity under test explicit and readable.- Because the handler injects claims directly, you test authorization (roles, policies) without standing up a login flow.
Cookie-authenticated tests exercise the real antiforgery pipeline. Clients are built with cookie handling enabled, and extension methods make the CSRF dance a one-liner β including the negative case:
await client.WithCsrfTokenAsync(); // fetch + attach the token like a browser would
client.WithoutCsrfToken(); // prove protected calls are rejected without itWebAppFixtureBase.InitializeAsync spells out its startup as four ordered, named phases
and then asserts its own postconditions, so a future refactor that accidentally makes
host construction lazy fails loudly in setup instead of mysteriously later:
await StartDatabaseAsync(); // 1. DB is up β¦
BuildFactory(); // 2. β¦ before the host reads its connection string
var host = MaterializeHost(); // 3. force the host to build now, deterministically
await SeedAsync(host); // 4. seed known dataThe base class is heavily commented with the why behind each step β it's meant to be
read. It also centralizes the helpers every test needs: ExecuteDbContextAsync(...) for
asserting against the database, GetService<T>() / CreateScope() for reaching into DI,
and a seeded Stocks array of known fixtures.
TimeProvider is injected and replaced in the test host, so timestamps are controllable
rather than wall-clock. Request payloads are generated with
AutoFixture, keeping tests focused on
behavior rather than hand-written sample data.
A complete test reads top-to-bottom as arrange the identity β act over HTTP β assert the result, with all the infrastructure hidden behind the base classes:
var request = CreateRequest();
var client = CreateClient().WithJwtAuth(claims => claims.AsAdmin()).Build();
var response = await client.PostAsJsonAsync("/stocks", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<Stock>();
Assert.Equal(request.Ticker, created!.Ticker);That same test just ran against real PostgreSQL and against SQLite.
Released under the MIT License β copy these testing patterns into your own projects freely.
Turn Organizational Trees Into Claims.
Most authorization rules are about groups, job roles, and people. It's been done before, why build it again?
The power of AuthorizationHub is that the tenants, groups, and roles a user is related to, become identity claims as the user's request gets processed inside the ASP.NET Core pipeline. Those claims are specific to your application and can be used in Authorization Policies. This neatly aligns with the security model in ASP.NET Core. It means you can change who can perform operations in your application by changing the user's role and group memberships. There's no need to make code changes and redeploy web applications.