Testing & Production
You've grown the products API from a single endpoint into a real REST service with validation, dependency injection, a middleware pipeline, and JWT auth. Now comes the part that decides whether anyone trusts it: proving it works, and running it somewhere real without it falling over at 3am. The good news, the same one we keep coming back to in this guide: both turn out to be small once you see the one fact that makes them small.
The mental model: integration testing runs the whole app in memory
Here's the thing that makes ASP.NET Core genuinely pleasant to test. There's a class — WebApplicationFactory<TEntryPoint>, from the Microsoft.AspNetCore.Mvc.Testing package — whose entire job is to start your real application in memory and hand you an HttpClient wired straight into it.
💡 An integration test is nothing more than: spin up your app in-process, ask the factory for an
HttpClient, and make requests as if you were a caller out on the network. Except there is no network. No real socket, no port, no Kestrel listening, nodotnet runin another terminal. The request travels the entire pipeline — middleware, routing, model binding, your endpoint, the lot — exactly as it would in production, but it never leaves the process. It runs in milliseconds.
That's the whole idea. You're not mocking the framework or testing one method in isolation; you're exercising the assembled app the way a real client would, and reading back what it returns.
xUnit is the common test framework in .NET (dotnet new xunit scaffolds a project), and WebApplicationFactory plugs into it through IClassFixture<T> — xUnit's way of building one expensive thing once and sharing it across the tests in a class. Here's a test against the products API:
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductsApiTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();
[Fact]
public async Task Get_products_returns_ok()
{
var res = await _client.GetAsync("/api/v1/products");
Assert.True(res.IsSuccessStatusCode);
}
}
What just happened: IClassFixture<WebApplicationFactory<Program>> tells xUnit to construct the factory once and inject it into the constructor. factory.CreateClient() boots the app in memory and gives back an HttpClient pointed at it. The [Fact] is one test; inside it we GET /api/v1/products and assert a success status. That single GetAsync ran your whole app — the middleware pipeline, routing to the endpoint, the handler pulling products out of DI — and came back, all without a port ever opening. To check the body too, you'd add var products = await res.Content.ReadFromJsonAsync<List<Product>>(); and assert on the shape.
⚠️ For the test project to reference
Program, your minimal-APIProgram.csneeds one extra line at the very end:public partial class Program { }Top-level statements (the
var builder = WebApplication.CreateBuilder(args);style you've used all guide) compile into aninternalclass namedProgramby default — which a separate test project can't see. Addingpublic partial class Program { }makes itpublicsoWebApplicationFactory<Program>can find your entry point. Forget this and you get a confusing "Programis inaccessible due to its protection level" error; this is the fix.
This is the heart of testing an ASP.NET Core app. Wiring it into CI so it runs on every push — and the general discipline of test layers, fixtures, and pipelines — is covered in testing in CI.
Overriding services: swap in a fake repository for tests
The test above hit the real DI container, which means it used whatever your Program.cs registered for the product store. For a fast, deterministic test you usually don't want the real database — you want to swap in a fake or in-memory implementation. WebApplicationFactory lets you reconfigure the container before the app starts, through WithWebHostBuilder:
[Fact]
public async Task Get_products_uses_the_fake_store()
{
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IProductRepository>();
services.AddSingleton<IProductRepository, FakeProductRepository>();
});
}).CreateClient();
var res = await client.GetAsync("/api/v1/products");
var products = await res.Content.ReadFromJsonAsync<List<Product>>();
Assert.Single(products);
}
What just happened: WithWebHostBuilder gives you a chance to run extra configuration after Program.cs has registered everything but before the app starts handling requests. We RemoveAll<IProductRepository>() to drop whatever the real app registered, then AddSingleton a FakeProductRepository we control — perhaps one seeded with a single known product. Now the endpoint, routing, and pipeline are all real, but the data source is a fake we can make assertions against. Because the override happens last, it wins. This is the standard trick for testing against an in-memory store instead of a live database.
📝 Not every test needs the factory. A
WebApplicationFactorytest is an integration test — it runs the HTTP pipeline. If you only want to test the logic inside one service or repository — say, aProductServicemethod that calculates a discount — that's a plain unit test:newup the class (passing a fake repository to its constructor), call the method, assert the result. No factory, noHttpClient, no pipeline. Reach for the factory when you're testing the app; reach for a unit test when you're testing a piece.
Configuration: appsettings, environments, and IConfiguration
A test database is one example of "this differs between environments." Configuration is how ASP.NET Core handles all of them, and the model is a stack of layers that override each other.
When your app starts, the builder reads configuration from several sources and merges them, with later sources winning over earlier ones:
appsettings.json— your base settings, committed to the repo.appsettings.{Environment}.json— environment-specific overrides, e.g.appsettings.Development.jsonorappsettings.Production.json.- Environment variables — what the host or container injects.
- User secrets — local-only secrets in development (never committed), for things like a JWT signing key you don't want in source control.
The "{Environment}" piece is driven by one environment variable: ASPNETCORE_ENVIRONMENT, which is conventionally Development, Staging, or Production. Set it to Production and ASP.NET Core layers appsettings.Production.json on top of appsettings.json; leave it at Development and you get appsettings.Development.json instead. That's how the same build picks up different settings in different places.
You read merged values through IConfiguration, which the builder exposes as builder.Configuration:
var builder = WebApplication.CreateBuilder(args);
// A single value, by key (":" walks into nested JSON):
var connectionString = builder.Configuration["ConnectionStrings:Products"];
// Or bind a whole section to a typed options object:
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
var app = builder.Build();
What just happened: builder.Configuration["ConnectionStrings:Products"] pulls one value out of the merged configuration — the colon walks into nested JSON, so it reads { "ConnectionStrings": { "Products": "..." } }. The value comes from whichever layer set it last: it might live in appsettings.json for local dev but be overridden by an environment variable in production, and your code doesn't change either way. Configure<JwtOptions>(...GetSection("Jwt")) binds the whole "Jwt" block to a strongly-typed JwtOptions class, which you can then inject anywhere via IOptions<JwtOptions> — the same JWT settings from the auth phase, now sourced from config instead of hardcoded. The rule that makes this all work: base file for defaults, environment file for per-environment overrides, environment variables and secrets for the things that change per deploy or must stay out of source control.
Production: publish, Kestrel, and a small Docker image
You've been running with dotnet run, which compiles and launches in one step — perfect for development, not for production. For a real deploy you produce an optimized build with dotnet publish:
What just happened: -c Release builds in Release configuration — optimizations on, debug symbols and dev-time checks off — instead of the default Debug. -o ./publish drops the result, your DLLs plus everything needed to run, into a publish folder. You can then launch it with dotnet ./publish/ProductsApi.dll. This is the artifact you ship, not your source tree.
Inside that artifact runs Kestrel — the cross-platform, high-performance web server built into ASP.NET Core. It's already what served your requests during dotnet run; in production it's the thing actually listening for connections. Kestrel is fast and perfectly capable of facing the internet, but the common, recommended shape is to put a reverse proxy in front of it — nginx, IIS, or YARP. The proxy terminates TLS (handles HTTPS), can load-balance across multiple instances of your app, and shields Kestrel from the rough edges of the public internet. Your app speaks plain HTTP to the proxy; the proxy speaks HTTPS to the world.
The cleanest way to package all of this is a multi-stage Docker image: one stage with the full .NET SDK to build and publish, a second tiny stage with only the runtime to actually run.
# Build stage — has the full SDK
# Run stage — just the runtime, much smaller
What just happened: the first stage uses the sdk image — the whole toolchain — to dotnet publish your Release build into /app. The second stage starts from the much smaller aspnet runtime image (it has the .NET runtime but not the compilers and build tools you no longer need), and copies only the published output from the build stage with COPY --from=build. The result is a leaner image with a smaller attack surface — you're not shipping the SDK to production. We set ASPNETCORE_ENVIRONMENT=Production so the app layers in appsettings.Production.json and turns off developer conveniences like the detailed exception page, and ASPNETCORE_URLS to tell Kestrel which address and port to bind. Configuration that differs per environment — connection strings, the JWT key, the real database URL — comes in as environment variables, exactly the layering from the previous section, so the image stays identical across staging and production.
That's the full deploy shape: publish a Release build, run it on Kestrel inside a small multi-stage container, configure it through environment variables, and put a TLS-terminating reverse proxy in front. Taking it the rest of the way to a live URL — picking a host, wiring CI, the domain and certificate specifics — is covered in ship your side project.
Recap
- Integration tests run the whole app in memory.
WebApplicationFactory<Program>fromMicrosoft.AspNetCore.Mvc.Testingboots your real app in-process and gives you anHttpClientviaCreateClient()— full pipeline, no network, no port. Use it with xUnit'sIClassFixture<T>. - Your minimal-API
Program.csmust end withpublic partial class Program { }so the test project can reference the entry point — otherwiseProgramisinternaland inaccessible. - Override services for tests with
factory.WithWebHostBuilder(b => b.ConfigureServices(...))—RemoveAll<T>()the real registration and add a fake/in-memory one. Unit tests of a single service or repository need no factory at all; justnewit up with a fake dependency. - Configuration layers:
appsettings.json→appsettings.{Environment}.json→ environment variables → user secrets, later sources winning. The environment comes fromASPNETCORE_ENVIRONMENT. Read values withbuilder.Configuration["Key"]or bind a section to typed options. - Production:
dotnet publish -c Release, run on Kestrel behind a reverse proxy (nginx/IIS/YARP) that terminates TLS, packaged as a multi-stage Docker image (sdkto build,aspnetruntime to run), with config supplied via environment variables.
Quick check
Lock in the core fact (in-memory testing) and the two production must-knows:
[
{
"q": "How does WebApplicationFactory<Program> let you test an ASP.NET Core app without a real port?",
"choices": ["It mocks every endpoint so no real code runs", "It starts your real app in memory and gives you an HttpClient wired straight into the full pipeline", "It launches Kestrel on a random free port in the background", "It only works for unit tests of individual services"],
"answer": 1,
"explain": "The factory boots the actual application in-process and hands back an HttpClient. Requests travel the entire pipeline — middleware, routing, binding, your endpoint — but never leave the process, so there's no socket or port involved."
},
{
"q": "Why must a minimal-API Program.cs end with `public partial class Program { }` for integration tests?",
"choices": ["It registers the test framework", "Top-level statements compile to an internal Program class, so the test project can't reference it until you make it public", "It enables Release-mode optimizations", "It starts the Kestrel server"],
"answer": 1,
"explain": "Top-level statements generate an internal Program by default. WebApplicationFactory<Program> needs to reference that type from a separate project, so you add `public partial class Program { }` to make it public."
},
{
"q": "Which environment variable selects which appsettings.{Environment}.json file is layered on top of appsettings.json?",
"choices": ["DOTNET_ENV", "ASPNETCORE_URLS", "ASPNETCORE_ENVIRONMENT", "NODE_ENV"],
"answer": 2,
"explain": "ASPNETCORE_ENVIRONMENT (Development/Staging/Production) drives which environment-specific appsettings file is merged in. ASPNETCORE_URLS sets the bind address, not the environment."
}
]
← Phase 7: Authentication & Authorization · Guide overview · Phase 9: Where to Go Next →
Check your understanding
1. How does WebApplicationFactory<Program> let you test an ASP.NET Core app without a real port?
2. Why must a minimal-API Program.cs end with `public partial class Program { }` for integration tests?
3. Which environment variable selects which appsettings.{Environment}.json file is layered on top of appsettings.json?