ASP.NET Core Integration Testing Best Practices (.NET 10)
A friendly .NET 10 guide to ASP.NET Core integration testing: WebApplicationFactory, real databases with Testcontainers, clean test isolation, and CI tips.
The shared kitchen test
Picture a big joint family kitchen. Each person can cook well on their own. Your mother makes perfect dal. Your uncle makes great rice. Your sister is brilliant with the rotis. If you taste each dish alone, everything is fine.
But dinner is not one dish. Dinner is everyone cooking at the same time, sharing the same stove, the same sink, and the same gas cylinder. The real question is not "can mother make dal?" It is "can the whole family make a full meal together without the gas running out or two people fighting over one burner?"
A unit test is tasting the dal on its own. An integration test is sitting down to the full dinner and checking that everything actually works together. In ASP.NET Core, that means starting your real app, sending a real request, and seeing if routing, validation, your code, and the database all cooperate to put the right plate on the table.
This guide shows you how to do that well in .NET 10, in simple steps.
Where integration tests sit
Unit tests and integration tests are friends, not rivals. You want many small unit tests at the bottom and fewer, slower integration tests above them. This shape is often called the test pyramid.
A good rule of thumb: a unit test checks one decision in your code. An integration test checks that a whole feature behaves correctly when the parts join hands. The table below makes the difference clear.
| Question | Unit test | Integration test |
|---|---|---|
| What is tested? | One method or class | A full request through the app |
| Database used? | No, it is mocked | Yes, usually a real one |
| Speed | Milliseconds | Tens to hundreds of ms |
| Catches wiring bugs? | No | Yes |
| How many you write | Many | Some, for key flows |
Meet WebApplicationFactory
The heart of ASP.NET Core integration testing is a class called WebApplicationFactory<TEntryPoint>. It starts your real application inside the test process. There is no real network port and no real web server. Instead, it uses an in-memory TestServer, and hands you an HttpClient wired straight to your app.
Here is the flow when a test runs.
What happens when an integration test runs
Steps
Boot app
Factory starts your app in memory
Get client
You ask for an HttpClient
Send request
Client posts JSON to an endpoint
Pipeline runs
Routing, validation, handler, database
Read response
You assert on status and body
One small thing first. To use WebApplicationFactory<Program>, the test project needs to see your Program class. With top-level statements, that class is internal by default. The classic fix is to add one line at the bottom of your Program.cs:
// Program.cs — your normal app setup above...
var app = builder.Build();
app.MapControllers();
app.Run();
// This makes Program visible to the test project.
public partial class Program { }Now your test project can reference it. A first, simple test looks like this:
public class BooksApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public BooksApiTests(WebApplicationFactory<Program> factory)
{
// One app instance is shared across the tests in this class.
_client = factory.CreateClient();
}
[Fact]
public async Task Get_books_returns_200()
{
var response = await _client.GetAsync("/api/books");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}IClassFixture tells xUnit to create the factory once for the whole class, not once per test. Booting an app is not free, so sharing it keeps your suite fast.
The database problem
Most real apps talk to a database. So your integration tests must decide what database to use. You have three common choices, and they are not equal.
| Option | Realistic? | Speed | When to use |
|---|---|---|---|
| EF Core in-memory | Low | Very fast | Tiny smoke checks only |
| SQLite (in-memory) | Medium | Fast | Lightweight, no Docker |
| Real DB via Testcontainers | High | Medium | The recommended default |
The EF Core in-memory provider feels easy, but it lies to you. It does not enforce foreign keys, it does not run real SQL, and unique constraints behave differently. A test can pass there and still break in production. That is the worst kind of green tick.
The trustworthy path is to run the same database engine you use in production inside a throwaway Docker container. The tool for this is Testcontainers. It starts a real PostgreSQL or SQL Server container for your tests, then deletes it when they finish.
A custom factory with a real database
To swap the database, you make your own factory by inheriting from WebApplicationFactory<Program> and overriding ConfigureWebHost. Inside, you remove the app's normal DbContext registration and add a new one pointing at the container.
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:17")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Remove the app's real DbContext registration.
var descriptor = services.Single(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
services.Remove(descriptor);
// Add one that points at the test container.
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_db.GetConnectionString()));
});
}
// IAsyncLifetime: start the container before tests, stop it after.
public async Task InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync(); // create the schema
}
public new async Task DisposeAsync() => await _db.DisposeAsync();
}Two important details here. First, prefer ConfigureTestServices over ConfigureServices. It runs after your app has registered its own services, so your override always wins. Second, IAsyncLifetime gives you InitializeAsync and DisposeAsync, which is where you start and stop the container. Starting a container is an async, slow job, so it must not happen in a normal constructor.
Sharing the factory and keeping tests clean
Starting a container for every single test would be painfully slow. So share one container across many tests using an xUnit collection fixture. All tests in that collection use the same app and the same database container.
Lifetime of a shared test collection
Steps
Start
Container boots once for the collection
Migrate
Schema created from EF migrations
Run tests
Many tests share the same client
Reset
Clean data between each test
Dispose
Container removed at the end
But sharing a database brings a danger: one test can leave data behind that confuses the next test. This is the shared-kitchen problem again. You must give every test a clean, known starting point.
The cleanest tool for this is Respawn. Between tests, it quickly deletes all rows (while keeping your schema) so each test starts from an empty, predictable database.
[Collection("Database collection")]
public class CreateBookTests
{
private readonly HttpClient _client;
private readonly Func<Task> _resetDatabase;
public CreateBookTests(ApiFactory factory)
{
_client = factory.CreateClient();
_resetDatabase = factory.ResetDatabaseAsync; // uses Respawn inside
}
[Fact]
public async Task Posting_a_valid_book_saves_it()
{
await _resetDatabase(); // start from empty
var newBook = new { title = "The Jungle Book", author = "Kipling" };
var response = await _client.PostAsJsonAsync("/api/books", newBook);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var saved = await _client.GetFromJsonAsync<List<BookDto>>("/api/books");
Assert.Single(saved);
Assert.Equal("The Jungle Book", saved![0].Title);
}
}Notice the test reads the data back through the API. It does not peek into the database directly. This keeps the test honest: it checks what a real user would actually see.
How test isolation flows
Isolation is the single most common thing teams get wrong. Here is the safe pattern as a small state machine.
If you follow this loop, the order in which tests run stops mattering. A test that runs first and a test that runs last both begin from the same empty database. That is what makes a suite trustworthy.
Faking the things you should not call
A real database is good to test against. But some external services are not good to call for real. You should never charge a real credit card, send a real SMS, or call a paid third-party API during a test.
For these, swap the real service for a fake one inside ConfigureTestServices, exactly the way you swapped the database. Your test then controls what that service "returns".
builder.ConfigureTestServices(services =>
{
// Remove the real payment gateway.
services.RemoveAll<IPaymentGateway>();
// Add a fake that always says "payment succeeded".
services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
});A simple rule helps you decide. If a dependency is owned by you and safe to run locally, like your own database, test against the real thing in a container. If it is external, slow, paid, or has side effects in the real world, replace it with a fake. This keeps tests both realistic and safe.
Note: some popular libraries changed their licensing recently. MediatR and MassTransit are now commercially licensed for many uses. If your handlers go through MediatR, your integration tests still exercise them normally through the HTTP pipeline, but be aware of the licence when you adopt these libraries in a new project.
Running them in CI
Integration tests shine in your continuous integration pipeline, because that is where wiring bugs get caught before users see them. Testcontainers needs a Docker engine available on the build agent. Most hosted CI runners (GitHub Actions Linux runners, for example) already provide Docker, so your tests "just work" there.
A few practical tips keep CI happy:
- Pin container image versions, like
postgres:17, so a surprise update does not break the build. - Run database-touching tests in one collection so only one container is needed.
- Keep total test time reasonable; if it grows, run integration tests in a separate, parallel CI job.
- Let failing test names describe the feature, so a red build points straight at the broken behaviour.
A short, complete checklist
| Practice | Why it matters |
|---|---|
| Use a real DB in a container | Catches real SQL and constraint bugs |
| Share one factory per collection | Booting apps is slow; reuse it |
| Reset data between tests | Stops tests from poisoning each other |
| Override with ConfigureTestServices | Your test wiring always wins |
| Assert through the HTTP API | Tests what a real user sees |
| Fake risky external calls | No real charges or messages sent |
Quick recap
- An integration test starts your real app and sends real requests, checking that all the parts work together, like a full family dinner rather than one dish.
WebApplicationFactory<Program>boots your app in memory and gives you anHttpClient. Remember thepublic partial class Program { }line so tests can see it.- Avoid the EF Core in-memory provider for serious tests. Use a real database in a Docker container with Testcontainers so your tests behave like production.
- Make a custom factory, override
ConfigureTestServices, and useIAsyncLifetimeto start and stop the container. - Keep tests isolated: share one container per collection, then reset the data (for example with Respawn) before each test so order never matters.
- Fake anything external, paid, or with real-world side effects. Test against your own database for real.
- Run them in CI with Docker available, pin image versions, and let clear test names guide you to bugs.
References and further reading
- Integration tests in ASP.NET Core — Microsoft Learn
- ASP.NET Core — Testcontainers for .NET
- Testing an ASP.NET Core web app with Testcontainers — Docker Docs
- Testing an ASP.NET Core web app — Testcontainers Guides
- ASP.NET Core Integration Testing Best Practises — antondevtips
Related Posts
5 Architecture Tests You Should Add to Your .NET Projects
Five simple architecture tests for .NET using NetArchTest. Protect layers, naming, and dependencies with code that fails the build when rules break.
Clean Architecture Folder Structure in .NET: A Simple Guide
Learn how to organise a .NET solution with Clean Architecture. Understand the Domain, Application, Infrastructure, and Presentation layers, the dependency rule, and the exact folders, with diagrams and examples.
Vertical Slice Architecture in .NET: The Easy Guide
Learn Vertical Slice Architecture in .NET in simple words. Organise code by feature instead of by layer, with diagrams, real examples, a comparison with Clean Architecture, and when to use each.
Testcontainers: Integration Testing Using Docker in .NET
A friendly .NET 10 guide to Testcontainers: spin up real databases in Docker for trustworthy integration tests, with xUnit, WebApplicationFactory, and clean cleanup.
Testcontainers Best Practices for .NET Integration Testing (.NET 10)
A friendly .NET 10 guide to Testcontainers: real databases in Docker, shared container fixtures, clean test isolation, faster CI, and the mistakes to avoid.
Testing Modular Monoliths: System Integration Testing in .NET 10
A friendly .NET 10 guide to system integration testing for modular monoliths: test module boundaries, cross-module flows, real databases with Testcontainers.