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?
Real-world analogy
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
- Several real parts together — An integration test sends a real HTTP request through routing, DI, middleware and your database code. It finds wiring mistakes that unit tests cannot see.
- WebApplicationFactory hosts the API — WebApplicationFactory<Program> starts your app in memory and gives you an HttpClient. There is no port to open and no server to deploy.
- Replace only the outside world — Swap services you do not control, such as an email sender or a payment API, with fakes in the test. Keep your own code real.
- A real database beats in-memory — The EF in-memory provider does not enforce foreign keys or unique indexes. It runs LINQ in memory instead of as SQL, so a query can pass there and fail in production.
- Testcontainers gives a real one — Testcontainers starts a throwaway database in Docker for the test run. The cost is Docker on the machine and a slower start.
- Reset data between tests — Tests that share rows depend on run order. Clear the data after each test, or use a fresh database per class.
- Test protected endpoints — Register a test authentication handler that signs in a fake user with chosen roles. Then you can check both 200 for the right role and 403 for the wrong one.
- Check the stored result — Assert on the status code, and also read the database to see the row. A 201 with nothing saved is still a bug.
- Fewer, slower, higher value — Integration tests take seconds, not milliseconds. Cover the main paths and tricky queries, and leave the many small rule checks to unit tests.
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. This is a fragment. ApiFactory inherits WebApplicationFactory<Program>, which hosts the real app in memory. Program must be visible to the test project.
- 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. 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. ProductsApiTests receives the factory from xUnit through IClassFixture, so one factory is shared by the tests in this class.
- 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. 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Integration tests in ASP.NET Core (Microsoft Learn)
- Testing EF Core applications (Microsoft Learn)
- Choosing a testing strategy for EF Core (Microsoft Learn)
Lock it in