Lesson 39 of 45 advanced

Integration Testing

Run real routing, DI and a real database together, and fake only what is outside your control

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

What does WebApplicationFactory<Program> give you?

Open interactive version (quiz + challenge)

Real-world analogy

After testing each bicycle part on a bench, a mechanic still rides the whole bike round the car park. The ride finds problems the bench cannot: a chain that slips under load, a brake cable that rubs the wheel. But each ride takes minutes, and a puncture could be the tyre or the pump. The ride is the integration test, and the parts are routing, DI, middleware and the database.

What is it?

An integration test checks that several real parts of your system work together. In ASP.NET Core, WebApplicationFactory hosts the whole app in memory. A test sends an HTTP request through routing, model binding, DI, middleware, EF Core and a real database. Then it checks the response and the stored data. You replace only what lives outside your system, such as a payment provider. These tests are slower than unit tests, so you write fewer of them. Aim them at the paths where the wiring matters.

Real-world relevance

A team had all unit tests green, yet the product page crashed in production. A query used a method that the in-memory test database accepted but SQL Server could not translate. One integration test against a real database would have caught it. The usual trap is tests that share a database and pass or fail depending on which ran first. That teaches the team to ignore red builds.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

Why can a test pass on the EF in-memory provider and still fail in production?

Two tests pass alone but one fails when they run together. What is the most likely cause?

Code example

public class ApiFactory : WebApplicationFactory<Program>
{
    // A throwaway test database, for example one started by Testcontainers
    private const string TestDb = "Host=localhost;Database=shop_test;Username=test;Password=test";

    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder.ConfigureTestServices(services =>
        {
            // Program.cs already registered ShopContext: remove it, then add the test one
            services.RemoveAll<DbContextOptions<ShopContext>>();
            services.RemoveAll<IDbContextOptionsConfiguration<ShopContext>>();
            services.AddDbContext<ShopContext>(options => options.UseNpgsql(TestDb));
        });
}

public class ProductsApiTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    [Fact]
    public async Task PostProduct_Returns201_AndStoresRow()
    {
        var client = factory.CreateClient();
        var name = $"Mug-{Guid.NewGuid()}";

        var response = await client.PostAsJsonAsync("/api/products", new { Name = name, Price = 9.5m });

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);

        using var scope = factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ShopContext>();
        Assert.True(await db.Products.AnyAsync(p => p.Name == name));
    }
}

Line-by-line walkthrough

  1. 1. This is a fragment. ApiFactory inherits WebApplicationFactory<Program>, which hosts the real app in memory. Program must be visible to the test project.
  2. 2. ConfigureTestServices runs after Program.cs has registered its services. A changed connection string can arrive too late if Program.cs already read it. So the test replaces the ShopContext registration itself.
  3. 3. RemoveAll drops the app's options. In EF Core 9 and later, AddDbContext also registers an IDbContextOptionsConfiguration, so that goes too. AddDbContext then points at the test database.
  4. 4. ProductsApiTests receives the factory from xUnit through IClassFixture, so one factory is shared by the tests in this class.
  5. 5. CreateClient gives an HttpClient wired to the in-memory app. A unique name keeps this test independent of rows left by other tests. PostAsJsonAsync sends a real request through routing, binding and the endpoint.
  6. 6. The test asserts 201 Created. Then it opens a scope on the app's services, takes the ShopContext, and checks the row was stored.

Spot the bug

public class ProductListTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    private readonly HttpClient client = factory.CreateClient();
    private async Task<int> AddThenCountAsync(string name)
    {
        await client.PostAsJsonAsync("/api/products", new { Name = name, Price = 5m });
        var all = await client.GetFromJsonAsync<List<ProductDto>>("/api/products");
        return all!.Count;
    }
    [Fact]
    public async Task Mug_IsTheOnlyProduct() => Assert.Equal(1, await AddThenCountAsync("Mug"));

    [Fact]
    public async Task Cup_IsTheOnlyProduct() => Assert.Equal(1, await AddThenCountAsync("Cup"));
}
Need a hint?
Both tests write to the same database and each assumes it is the only writer. What happens when both run?
Show answer
The tests share one database and never clean it, so each result depends on what ran before. Whichever test runs second sees two products and fails. Fix: reset the data before each test, such as with a cleanup step or a fresh database. Then each test starts empty.

Explain like I'm 5

After you check each piece of a toy, you play with the whole toy to see if it fits together. This takes longer, so you do it a few times, not a hundred. You also start from a tidy toy box each time, or the last game could spoil the next one.

Fun fact

The EF Core docs say the in-memory provider was first built to test EF Core itself. Microsoft now discourages using it for testing, and supports it only for legacy applications.

Hands-on challenge

Say it out loud in 30 seconds: "How do you integration test an ASP.NET Core API, and what do you do about the database?" Then check: did you mention WebApplicationFactory, replacing only external services, a real database through Testcontainers rather than the in-memory provider, and resetting data between tests?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook