Skip to main content
SEMastery
Testingintermediate

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.

11 min readUpdated December 17, 2025

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.

The test pyramid: lots of fast unit tests, fewer integration tests, very few end-to-end tests.

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.

QuestionUnit testIntegration test
What is tested?One method or classA full request through the app
Database used?No, it is mockedYes, usually a real one
SpeedMillisecondsTens to hundreds of ms
Catches wiring bugs?NoYes
How many you writeManySome, 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

Boot app
Get client
Send request
Pipeline runs
Read response

Steps

1

Boot app

Factory starts your app in memory

2

Get client

You ask for an HttpClient

3

Send request

Client posts JSON to an endpoint

4

Pipeline runs

Routing, validation, handler, database

5

Read response

You assert on status and body

WebApplicationFactory boots your app, the client sends a request, and your real pipeline answers.

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.

OptionRealistic?SpeedWhen to use
EF Core in-memoryLowVery fastTiny smoke checks only
SQLite (in-memory)MediumFastLightweight, no Docker
Real DB via TestcontainersHighMediumThe 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 swaps the real connection string for a throwaway container before the app starts.

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

Start
Migrate
Run tests
Reset
Dispose

Steps

1

Start

Container boots once for the collection

2

Migrate

Schema created from EF migrations

3

Run tests

Many tests share the same client

4

Reset

Clean data between each test

5

Dispose

Container removed at the end

One container is started once, reused by all tests, and disposed at the very 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.

Each test moves through reset, act, and assert, always returning to a clean state.

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

PracticeWhy it matters
Use a real DB in a containerCatches real SQL and constraint bugs
Share one factory per collectionBooting apps is slow; reuse it
Reset data between testsStops tests from poisoning each other
Override with ConfigureTestServicesYour test wiring always wins
Assert through the HTTP APITests what a real user sees
Fake risky external callsNo 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 an HttpClient. Remember the public 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 use IAsyncLifetime to 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

Related Posts