๐Ÿงช

Integration Testing with TestContainers

Testing Intermediate 1 min read 200 words
ASP.NET Core Testing

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 HttpClient for 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

  1. Use IClassFixture - Shares the container across all tests in a class
  2. Seed once - Initialize test data in InitializeAsync
  3. Isolate tests - Use transactions to rollback changes between tests
  4. Use realistic data - Match production schema exactly
  5. Check Docker - Ensure Docker is running before tests

Sources

  • Arhitectura/integration testing.jpeg

๐Ÿ“š Related Articles