Integration Testing with WebApplicationFactory and Testcontainers
An integration test runs several real parts of the system together. With WebApplicationFactory, we start the whole ASP.NET Core app inside the test. With Testcontainers, we create a real database inside Docker, so the test sees the same behavior that Production sees.
Author: bezzad
The problem: all tests are green, but Production is broken
In our online shop, we have an API for placing orders. We have hundreds of unit tests, and all are green. But after a deploy, these errors happen:
- A LINQ query cannot be translated to SQL in the real database, and it throws an error.
- A new column was forgotten in a Migration.
- A service is not registered in DI, and the app fails on the first request.
- The name of a field in the output JSON changed, and the mobile app stops working.
- A Unique Constraint in the database rejects a duplicate order, but our code does not handle this error.
None of these show up in a unit test. Because a unit test leaves out the database, DI and HTTP on purpose.
The idea: run the real pieces together
An integration test runs several real parts together. In a normal API, this means:
- A real HTTP request, with the same route, the same Middleware and the same Validation rules.
- The same DI setup that the app uses in Production.
- A real database, with the same type and version we have in Production.
We replace only the things that are not under our control, like the bank’s payment gateway.
Tool one: WebApplicationFactory
The WebApplicationFactory class is in the Microsoft.AspNetCore.Mvc.Testing package. This class:
- Runs the whole app from Program. The same code that runs in Production.
- Creates an in-memory server (TestServer). No network port is opened. So it is fast, and tests do not fight over ports.
- Gives a ready HttpClient. With the CreateClient method, requests go directly to this in-memory server.
- Lets us replace some services. With the ConfigureTestServices method we can, for example, replace the real payment gateway with a Fake.
Tool two: Testcontainers
For the database, two wrong ways are common: the EF Core in-memory provider, or SQLite instead of the real database. Both have problems:
- Different behavior. The in-memory provider does not check unique and foreign key rules like a real database.
- Different query translation. A query that works in the test may fail in PostgreSQL, or the other way around.
- Different features. JSON columns, transactions and locks are different in each database.
The Testcontainers library starts a real database inside a Docker container. The container gets a random port and gives us the connection string. At the end, it removes the container. The only requirement is that Docker is available on your machine and on the CI server.
Code: a shared Factory for the tests
This class starts both the app and the database. In this example we use xUnit v3. In this version, the methods of the IAsyncLifetime interface return ValueTask.
public sealed class ShopApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db =
new PostgreSqlBuilder("postgres:17").Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Point the app to the database inside the container.
builder.UseSetting("ConnectionStrings:Shop", _db.GetConnectionString());
builder.ConfigureTestServices(services =>
{
// The real bank gateway is outside our control: use a fake.
services.RemoveAll<IPaymentGateway>();
services.AddSingleton<IPaymentGateway, AlwaysApprovePaymentGateway>();
});
}
public async ValueTask InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
await db.Database.MigrateAsync();
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _db.DisposeAsync();
}
}
A few points about this code:
- The Migrate method runs the project’s real Migrations. So if a Migration is missing, you find out right here.
- The container starts first, then the app. Because the app needs the connection string.
- At the end, the app closes first, and then the database.
Now the test. The IClassFixture interface makes all tests in this class use one Factory and one container:
public sealed class OrdersApiTests(ShopApiFactory factory) : IClassFixture<ShopApiFactory>
{
[Fact]
public async Task Posting_an_order_saves_it_and_returns_201()
{
var ct = TestContext.Current.CancellationToken;
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/orders", new { ProductId = 7, Quantity = 2 }, ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var order = await client.GetFromJsonAsync<OrderDto>(response.Headers.Location, ct);
Assert.Equal(2, order!.Quantity);
}
[Fact]
public async Task Order_with_zero_quantity_returns_400()
{
var ct = TestContext.Current.CancellationToken;
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/orders", new { ProductId = 7, Quantity = 0 }, ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
This test looks at the API from the outside, like a real user. It does not touch the internal classes. So if we rewrite the internal code, the test does not break.
Keeping tests apart from each other
Starting a container takes a few seconds. So we do not create a new container for each test. But if all tests share one database, the data of one test may break another test.
We have three common ways:
- Each test creates its own data and checks only that. For example, with a unique id. This is the simplest way, and it is also good for parallel tests.
- Clear the tables between tests. The Respawn library does this fast. It empties the tables but keeps the structure.
- Each test inside a transaction that is rolled back at the end. It is fast, but it does not work when the code opens its own transaction, or when the request goes through several connections.
External services
The payment gateway, the SMS service and other companies’ APIs are not under our control. If we call them in a test:
- The test gets slow.
- If that service is down, our test turns red for no reason.
- Real money may move, or a real text message may be sent.
So we have two ways. Either we replace that service’s interface with a Fake in ConfigureTestServices, like the code above. Or, if we also want to test our own HTTP code, we start a fake HTTP server like WireMock.Net that returns ready-made answers.
Key rules
- The test database must be the same type and version as Production. If Production runs PostgreSQL version 17, the test must use the same.
- Test from the outside. Through HTTP, not by calling internal classes directly.
- Share the container, not the data. One container for a group of tests, but each test with predictable data.
- Replace only the external boundaries. If you also fake the database, it is no longer an integration test.
- Keep the number reasonable. These tests are slower than unit tests. Cover the main paths and the important error cases. Put all the combinations of business rules in unit tests.
- Run them in CI too. A test that runs only on one person’s laptop saves nobody.
Common mistakes
| Mistake | Result | Right way |
|---|---|---|
| Using the EF Core in-memory provider | The test is green, but the query fails in Production. | A real database with Testcontainers. |
| A new container for each test | Tests get very slow. | One container per class or Collection. |
| Tests depend on each other’s data | When the run order changes, tests randomly turn red. | Unique data for each test, or clearing between tests. |
| Calling the real external service | The test is unstable, and real money may move. | A Fake or a fake HTTP server. |
| A fixed connection string to a shared team database | Two people’s tests break each other’s data. | Each run gets its own container. |
| Testing all business rules through HTTP | The test suite gets slow and fragile. | Rules in unit tests, the main path in integration tests. |
What to check with integration tests?
Good fit
- EF Core queries and Migrations.
- The full path of an Endpoint, from the request to saving in the database.
- DI setup, authentication and Middleware.
- The exact shape of the input and output JSON.
Poor fit
- All the cases of a discount rule. A unit test is faster and more precise.
- The behavior of other companies’ services.
- Long flows across several services. For those, an E2E test or a Contract Test fits better.
Summary in six lines
- A unit test does not see the database, DI and HTTP. An integration test runs exactly these together.
- The WebApplicationFactory class starts the whole app in memory and gives you an HttpClient.
- The Testcontainers library creates a real database in Docker. The in-memory provider is not a good replacement.
- Share the container between tests, but keep each test’s data separate.
- Replace only external services with a Fake.
- Cover the main paths with integration tests, and the details of the rules with unit tests.