DEV Community

Cover image for Integration Testing In .NET Using Docker Testcontainers
Cheryl Mataitini
Cheryl Mataitini

Posted on

Integration Testing In .NET Using Docker Testcontainers

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

BaseIntegrationTests.cs

  • Contains shared test setup logic for tests.
  • Implements IClassFixture<SqlServerContainerFixture> and IAsyncLifetime.
  • 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
...
Enter fullscreen mode Exit fullscreen mode

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)