Overview
This article covers how to use Docker for integration testing in an ASP.NET Core Web API project. We'll use the Testcontainers library to spin up SQL Server containers for our integration tests.
We'll see how to set up an integration test class that uses a database container and resets its state between tests.
Tooling
- xUnit
- Docker
- TestContainers.MsSql NuGet package (other packages support containers for PostgreSQL, Redis, RabbitMQ, and other dependencies)
- EF Core and migrations
Integration Test Setup
We're testing a demo ASP.NET Core Web API project called BlindDate
, and we want to run our integration tests against a real database. To do this, we use a test project BlindDate.Tests
, which includes:
BlindDate.Tests/
└── IntegrationTests/
├── BaseIntegrationTest.cs
├── BlindDateControllerTests.cs
├── IntegrationTestWebAppFactory.cs
└── SqlServerContainerFixture.cs
Here's how the pieces fit together:
SqlServerContainerFixture.cs
- Responsible for starting a disposable SQL Server container using Testcontainers.
- Implements
IAsyncLifetime
to manage container lifecycle.
ing Testcontainers.MsSql;
namespace BlindDate.Tests.IntegrationTests
{
/// <summary>
/// SQL Server container fixture.
/// Provides an isolated, disposable SQL Server instance for integration testing.
/// </summary>
public class SqlServerContainerFixture : IAsyncLifetime
{
private const string SqlPassword = "P@ssword123!";
private const string SqlImage = "mcr.microsoft.com/mssql/server:2022-latest";
public MsSqlContainer Container { get; }
public SqlServerContainerFixture()
{
Container = new MsSqlBuilder()
.WithImage(SqlImage)
.WithPassword(SqlPassword)
.WithCleanUp(true) // Ensures Docker cleans up the container after tests
.WithPortBinding(1433, assignRandomHostPort: true) // Allows multiple containers to run in parallel without port collisions.
.Build();
}
public async Task InitializeAsync() => await Container.StartAsync();
public async Task DisposeAsync() => await Container.DisposeAsync();
}
}
IntegrationTestWebAppFactory.cs
A custom WebApplicationFactory
class that boots our application in-memory.
- Overrides service configuration to inject the SQL Server connection string.
- Contains logic to reset the database using EF Core (
EnsureDeleted
+Migrate
).
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace BlindDate.Tests.IntegrationTests
{
/// <summary>
/// WebApplicationFactory for integration testing.
/// Overrides the real application's service configuration to use a test database.
/// </summary>
internal class IntegrationTestWebAppFactory : WebApplicationFactory<Program>
{
private readonly string _connString;
public IntegrationTestWebAppFactory(string connString)
{
_connString = connString;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll(typeof(DbContextOptions<BlindDateDbContext>));
services.AddSqlServer<BlindDateDbContext>($"{_connString};Database=BlindDateTestDb;");
});
}
public async Task ResetDatabaseAsync()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BlindDateDbContext>();
await db.Database.EnsureDeletedAsync();
await db.Database.MigrateAsync();
}
}
}
BaseIntegrationTests.cs
- Contains shared test setup logic for tests.
- Implements
IClassFixture<SqlServerContainerFixture>
andIAsyncLifetime
. - Instantiates
IntegrationTestWebAppFactory
using the SQL Server container’s connection string. - Automatically resets the database before each test via
IAsyncLifetime
- Exposes a
HttpClient
for calling the in-memory API.
namespace BlindDate.Tests.IntegrationTests
{
public class BaseIntegrationTest : IClassFixture<SqlServerContainerFixture>, IAsyncLifetime
{
protected readonly HttpClient _httpClient;
internal readonly IntegrationTestWebAppFactory _app;
private readonly SqlServerContainerFixture _fixture;
protected BaseIntegrationTest(SqlServerContainerFixture fixture)
{
_fixture = fixture;
_app = new IntegrationTestWebAppFactory(_fixture.Container.GetConnectionString());
_httpClient = _app.CreateClient();
}
public async Task InitializeAsync() => await _app.ResetDatabaseAsync();
public Task DisposeAsync() => Task.CompletedTask;
}
}
Test Class
- Inherits from BaseIntegrationTest to reuse test setup logic.
using BlindDate.Api.Contracts;
using BlindDate.Api.Entities;
using FluentAssertions;
using System.Net.Http.Json;
namespace BlindDate.Tests.IntegrationTests
{
public class BlindDateControllerTests : BaseIntegrationTest
{
public BlindDateControllerTests(SqlServerContainerFixture fixture) : base(fixture) { }
[Fact]
public async Task JoinBlindDateRequest_AddsPersonToMatch()
{
// Arrange
JoinBlindDateRequest request = new("Jane");
// Act
var response = await _httpClient.PostAsJsonAsync("/api/blindDate", request);
// Assert
response.EnsureSuccessStatusCode();
var matchResponse = await response.Content.ReadFromJsonAsync<BlindDateMatchResponse>();
matchResponse?.Id.Should().BePositive();
matchResponse?.Person1.Should().Contain("Jane");
matchResponse?.State.Should().Be(nameof(BlindDateMatchState.WaitingForDate));
}
[Fact]
public async Task JoinBlindDateRequest_JoinsOpenMatchAsSecondPerson()
...
This setup gives you fast, reliable, and isolated integration tests that work against real infrastructure - without the overhead of managing shared test environments.
Top comments (0)