Integration Testing with TestContainers
How to set up integration tests with real database containers using TestContainers and ASP.NET Core.
Overview
Integration testing verifies that different parts of your application work together correctly. Using TestContainers, you can spin up real database containers for more realistic tests.
Complete Example
public class IntegrationTestWebAppFactory
: WebApplicationFactory<Program>,
IAsyncLifetime
{
private readonly MsSqlContainer _dbContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("Strong_password_123!")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(_dbContainer.GetConnectionString()));
});
}
public Task InitializeAsync() => _dbContainer.StartAsync();
public new Task DisposeAsync() => _dbContainer.StopAsync();
}
Key Components Explained
1. WebApplicationFactory
public class IntegrationTestWebAppFactory : WebApplicationFactory<Program>
WebApplicationFactory<TEntryPoint> is provided by ASP.NET Core for integration testing. It:
- Creates an in-memory test server
- Allows configuration customization
- Provides an
HttpClientfor making requests
2. TestContainers Setup
private readonly MsSqlContainer _dbContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("Strong_password_123!")
.Build();
Why TestContainers?
- Real database behavior (not mocks)
- Isolation between tests
- Reproducible environment
- Same database engine as production
3. IAsyncLifetime Implementation
public Task InitializeAsync() => _dbContainer.StartAsync();
public new Task DisposeAsync() => _dbContainer.StopAsync();
Ensures the container starts before tests and stops after.
4. Service Replacement
builder.ConfigureTestServices(services =>
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(_dbContainer.GetConnectionString()));
});
Replaces the production database connection with the test container.
Complete Test Class
public class OrdersControllerTests : IClassFixture<IntegrationTestWebAppFactory>
{
private readonly HttpClient _client;
private readonly IntegrationTestWebAppFactory _factory;
public OrdersControllerTests(IntegrationTestWebAppFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task GetOrders_ReturnsSuccessStatusCode()
{
// Act
var response = await _client.GetAsync("/api/orders");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.NotEmpty(content);
}
[Fact]
public async Task CreateOrder_WithValidData_ReturnsCreated()
{
// Arrange
var order = new CreateOrderRequest
{
CustomerId = 1,
Items = new[]
{
new OrderItem { ProductId = 1, Quantity = 2 }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/orders", order);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
[Fact]
public async Task GetOrder_WithInvalidId_ReturnsNotFound()
{
// Act
var response = await _client.GetAsync("/api/orders/999999");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
Database Seeding
public class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
// ... container setup ...
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
await SeedDatabaseAsync();
}
private async Task SeedDatabaseAsync()
{
using var scope = Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await context.Database.MigrateAsync();
// Seed test data
if (!await context.Products.AnyAsync())
{
context.Products.AddRange(
new Product { Name = "Test Product 1", Price = 10.00m },
new Product { Name = "Test Product 2", Price = 20.00m }
);
await context.SaveChangesAsync();
}
}
}
Parallel Test Execution
// Each test class gets its own database container
[Collection("Database")]
public class OrdersControllerTests { }
[Collection("Database")]
public class ProductsControllerTests { }
// Or use separate containers per class
public class OrdersTests : IClassFixture<IntegrationTestWebAppFactory>
public class ProductsTests : IClassFixture<IntegrationTestWebAppFactory>
Additional Container Options
PostgreSQL
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.Build();
Redis
private readonly RedisContainer _redisContainer = new RedisBuilder()
.WithImage("redis:7-alpine")
.Build();
Multiple Containers
public class IntegrationTestFactory : IAsyncLifetime
{
private readonly MsSqlContainer _db;
private readonly RedisContainer _redis;
public async Task InitializeAsync()
{
await Task.WhenAll(
_db.StartAsync(),
_redis.StartAsync()
);
}
}
NuGet Packages
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="Testcontainers.MsSql" Version="3.6.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.6.0" />
<PackageReference Include="Testcontainers.Redis" Version="3.6.0" />
Best Practices
- Use IClassFixture - Shares the container across all tests in a class
- Seed once - Initialize test data in
InitializeAsync - Isolate tests - Use transactions to rollback changes between tests
- Use realistic data - Match production schema exactly
- Check Docker - Ensure Docker is running before tests
Sources
Arhitectura/integration testing.jpeg