Backend Intermediate 15 min

Integration Testing in .NET with Testcontainers

Build reliable integration tests for .NET applications using Testcontainers, WebApplicationFactory, and real database instances.

By Victor Robin Updated:

When I first configured Testcontainers in our CI pipeline, the Docker socket permission issue nearly made me abandon the whole approach. Locally everything worked perfectly — containers spun up, tests ran, containers tore down. But in our GitHub Actions self-hosted runner, the test suite would fail immediately with a cryptic “Cannot connect to the Docker daemon” error. The runner process did not have access to /var/run/docker.sock, and fixing it required adjusting the runner’s group membership and restarting the service. Then came the next surprise: container startup times in CI were three times slower than local because the runner had no image cache. I ended up adding a pre-pull step in the workflow that ensures the PostgreSQL and Redis images are cached before the test job runs. These infrastructure hurdles are not documented in any testing guide, but they are exactly what you will hit when moving from local development to CI.

Introduction

Integration tests verify that components work together correctly. While unit tests verify isolation, integration tests ensure your code works with the database, cache, and other infrastructure.

[Testcontainers for .NET] — Testcontainers , 2024-08-15

This guide covers building maintainable integration tests with real dependencies using Testcontainers, avoiding the “it works on my machine” syndrome.

Architecture Overview

flowchart TB
    Test["🧪 Integration Test"] --> Factory["🏭 WebApplicationFactory"]
    Factory --> API["🚀 In-Memory API"]

    subgraph Containers["🐳 Docker Testcontainers"]
        API --> DB[(PostgreSQL)]
        API --> Cache[(Redis)]
    end

    classDef primary fill:#7c3aed,color:#fff
    classDef secondary fill:#06b6d4,color:#fff
    classDef db fill:#f43f5e,color:#fff
    classDef warning fill:#fbbf24,color:#000

    class Test,API primary
    class Factory secondary
    class DB,Cache db

Implementation

Test Infrastructure

Package References

<!-- Archives.Tests.Integration.csproj -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Testcontainers" Version="3.9.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.9.0" />
<PackageReference Include="Testcontainers.Redis" Version="3.9.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Bogus" Version="35.0.0" />

Custom WebApplicationFactory

The WebApplicationFactory<T> from the ASP.NET Core testing infrastructure creates an in-memory test server, which Testcontainers complements by providing real database and cache instances.

[Integration tests in ASP.NET Core] — Microsoft , 2024-10-20
// Tests/Integration/Infrastructure/CustomWebApplicationFactory.cs
public sealed class CustomWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres;
    private readonly RedisContainer _redis;

    public CustomWebApplicationFactory()
    {
        _postgres = new PostgreSqlBuilder()
            .WithImage("postgres:16-alpine")
            .WithDatabase("archives_test")
            .WithUsername("test")
            .WithPassword("test")
            .Build();

        _redis = new RedisBuilder()
            .WithImage("redis:7-alpine")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();
        await _redis.StartAsync();
    }

    public new async Task DisposeAsync()
    {
        await _postgres.DisposeAsync();
        await _redis.DisposeAsync();
        await base.DisposeAsync();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove existing DbContext registration
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor is not null)
            {
                services.Remove(descriptor);
            }

            // Add test database
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseNpgsql(_postgres.GetConnectionString());
            });

            // Configure Redis
            services.AddStackExchangeRedisCache(options =>
            {
                options.Configuration = _redis.GetConnectionString();
            });

            // Apply migrations
            var sp = services.BuildServiceProvider();
            using var scope = sp.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            db.Database.Migrate();
        });

        builder.ConfigureTestServices(services =>
        {
            // Mock external services
            services.AddSingleton<IMinioClient, FakeMinioClient>();
            services.AddSingleton<INatsConnection, FakeNatsConnection>();
        });
    }
}

Collection Fixture

Shared Test Infrastructure

// Tests/Integration/Infrastructure/IntegrationTestCollection.cs
[CollectionDefinition(Name)]
public sealed class IntegrationTestCollection : ICollectionFixture<CustomWebApplicationFactory>
{
    public const string Name = "Integration";
}

// Base class for all integration tests
public abstract class IntegrationTestBase : IClassFixture<CustomWebApplicationFactory>
{
    protected readonly HttpClient Client;
    protected readonly CustomWebApplicationFactory Factory;
    protected readonly IServiceScope Scope;
    protected readonly AppDbContext DbContext;

    protected IntegrationTestBase(CustomWebApplicationFactory factory)
    {
        Factory = factory;
        Client = factory.CreateClient();
        Scope = factory.Services.CreateScope();
        DbContext = Scope.ServiceProvider.GetRequiredService<AppDbContext>();
    }

    protected async Task<T> GetServiceAsync<T>() where T : notnull
    {
        return Scope.ServiceProvider.GetRequiredService<T>();
    }

    protected async Task AuthenticateAsync(string userId = "test-user")
    {
        Client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", GenerateTestToken(userId));
    }

    private string GenerateTestToken(string userId)
    {
        // Generate test JWT token
        var claims = new[]
        {
            new Claim("sub", userId),
            new Claim("app_user_id", "testuser1"),
            new Claim("email", "test@example.com")
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-secret-key-minimum-length-256-bits"));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: "test",
            audience: "test",
            claims: claims,
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

xUnit’s fixture model provides a clean way to share expensive resources like database containers across test classes without sacrificing test isolation.

[xUnit Shared Context] — xUnit.net , 2024-05-10

API Integration Tests

Document Endpoint Tests

// Tests/Integration/Endpoints/DocumentEndpointTests.cs
[Collection(IntegrationTestCollection.Name)]
public sealed class DocumentEndpointTests : IntegrationTestBase
{
    private readonly Faker<CreateDocumentRequest> _requestFaker;

    public DocumentEndpointTests(CustomWebApplicationFactory factory) : base(factory)
    {
        _requestFaker = new Faker<CreateDocumentRequest>()
            .RuleFor(x => x.Name, f => f.System.FileName("pdf"))
            .RuleFor(x => x.Description, f => f.Lorem.Sentence());
    }

    [Fact]
    public async Task CreateDocument_WithValidRequest_ReturnsCreated()
    {
        // Arrange
        await AuthenticateAsync();
        var request = _requestFaker.Generate();

        // Act
        var response = await Client.PostAsJsonAsync("/api/documents", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);

        var document = await response.Content.ReadFromJsonAsync<DocumentResponse>();
        document.Should().NotBeNull();
        document!.Name.Should().Be(request.Name);
        document.Id.Should().NotBeEmpty();

        // Verify in database
        var dbDocument = await DbContext.Documents
            .FirstOrDefaultAsync(d => d.Id == DocumentId.From(document.Id));
        dbDocument.Should().NotBeNull();
    }

    [Fact]
    public async Task GetDocument_WhenNotFound_ReturnsNotFound()
    {
        // Arrange
        await AuthenticateAsync();
        var nonExistentId = Guid.NewGuid();

        // Act
        var response = await Client.GetAsync($"/api/documents/{nonExistentId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task GetDocuments_WithPagination_ReturnsPagedResults()
    {
        // Arrange
        await AuthenticateAsync();
        await SeedDocumentsAsync(25);

        // Act
        var response = await Client.GetAsync("/api/documents?page=2&limit=10");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var result = await response.Content.ReadFromJsonAsync<PagedResult<DocumentResponse>>();
        result.Should().NotBeNull();
        result!.Items.Should().HaveCount(10);
        result.TotalCount.Should().Be(25);
        result.Page.Should().Be(2);
    }

    private async Task SeedDocumentsAsync(int count)
    {
        var userId = CustomId.From("testuser1");
        var documents = Enumerable.Range(0, count)
            .Select(_ => Document.Create(_requestFaker.Generate().Name, userId))
            .ToList();

        DbContext.Documents.AddRange(documents);
        await DbContext.SaveChangesAsync();
    }
}

Repository Integration Tests

Testing with Real Database

// Tests/Integration/Repositories/DocumentRepositoryTests.cs
[Collection(IntegrationTestCollection.Name)]
public sealed class DocumentRepositoryTests : IntegrationTestBase
{
    private readonly IDocumentRepository _repository;

    public DocumentRepositoryTests(CustomWebApplicationFactory factory) : base(factory)
    {
        _repository = Scope.ServiceProvider.GetRequiredService<IDocumentRepository>();
    }

    [Fact]
    public async Task AddAsync_WithValidDocument_PersistsToDatabase()
    {
        // Arrange
        var userId = CustomId.From("testuser1");
        var document = Document.Create("test-document.pdf", userId);

        // Act
        await _repository.AddAsync(document, CancellationToken.None);

        // Assert
        var retrieved = await DbContext.Documents
            .FirstOrDefaultAsync(d => d.Id == document.Id);

        retrieved.Should().NotBeNull();
        retrieved!.Name.Should().Be("test-document.pdf");
        retrieved.OwnerId.Should().Be(userId);
    }

    [Fact]
    public async Task GetByOwnerAsync_ReturnsOnlyUserDocuments()
    {
        // Arrange
        var user1 = CustomId.From("user1111");
        var user2 = CustomId.From("user2222");

        await _repository.AddAsync(Document.Create("doc1.pdf", user1), CancellationToken.None);
        await _repository.AddAsync(Document.Create("doc2.pdf", user1), CancellationToken.None);
        await _repository.AddAsync(Document.Create("doc3.pdf", user2), CancellationToken.None);

        // Act
        var user1Documents = await _repository.GetByOwnerAsync(user1, CancellationToken.None);

        // Assert
        user1Documents.Should().HaveCount(2);
        user1Documents.Should().AllSatisfy(d => d.OwnerId.Should().Be(user1));
    }

    [Fact]
    public async Task UpdateAsync_WithOptimisticConcurrency_ThrowsOnConflict()
    {
        // Arrange
        var userId = CustomId.From("testuser1");
        var document = Document.Create("test.pdf", userId);
        await _repository.AddAsync(document, CancellationToken.None);

        // Simulate concurrent modification
        await DbContext.Database.ExecuteSqlRawAsync(
            "UPDATE \"Documents\" SET \"Name\" = 'modified.pdf' WHERE \"Id\" = {0}",
            document.Id.Value);

        // Act & Assert
        document.Rename("new-name.pdf");
        await Assert.ThrowsAsync<DbUpdateConcurrencyException>(
            () => _repository.UpdateAsync(document, CancellationToken.None));
    }
}

FluentAssertions provides a rich, readable assertion API that makes test failures easier to diagnose compared to the default xUnit assertions.

[FluentAssertions] — Dennis Doomen , 2024-06-20

Service Integration Tests

Testing Service Composition

// Tests/Integration/Services/DocumentProcessingServiceTests.cs
[Collection(IntegrationTestCollection.Name)]
public sealed class DocumentProcessingServiceTests : IntegrationTestBase
{
    private readonly IDocumentProcessingService _service;

    public DocumentProcessingServiceTests(CustomWebApplicationFactory factory) : base(factory)
    {
        _service = Scope.ServiceProvider.GetRequiredService<IDocumentProcessingService>();
    }

    [Fact]
    public async Task ProcessDocumentAsync_WithValidDocument_UpdatesStatus()
    {
        // Arrange
        var userId = CustomId.From("testuser1");
        var document = Document.Create("test.pdf", userId);
        DbContext.Documents.Add(document);
        await DbContext.SaveChangesAsync();

        // Act
        await _service.ProcessDocumentAsync(document.Id, CancellationToken.None);

        // Assert
        DbContext.Entry(document).Reload();
        document.Status.Should().Be(DocumentStatus.Processed);
        document.ProcessedAt.Should().NotBeNull();
    }
}

Test Data Builders

Fluent Builder Pattern

// Tests/Integration/Builders/DocumentBuilder.cs
public sealed class DocumentBuilder
{
    private string _name = "default.pdf";
    private CustomId _ownerId = CustomId.From("testuser1");
    private DocumentStatus _status = DocumentStatus.Pending;
    private string? _content;

    public DocumentBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    public DocumentBuilder WithOwner(string ownerId)
    {
        _ownerId = CustomId.From(ownerId);
        return this;
    }

    public DocumentBuilder WithStatus(DocumentStatus status)
    {
        _status = status;
        return this;
    }

    public DocumentBuilder WithContent(string content)
    {
        _content = content;
        return this;
    }

    public Document Build()
    {
        var document = Document.Create(_name, _ownerId);

        if (_status != DocumentStatus.Pending)
        {
            // Use reflection or internal method to set status for testing
            typeof(Document)
                .GetProperty(nameof(Document.Status))!
                .SetValue(document, _status);
        }

        if (_content is not null)
        {
            document.SetContent(_content, Array.Empty<float>());
        }

        return document;
    }
}

// Usage
var document = new DocumentBuilder()
    .WithName("report.pdf")
    .WithOwner("user1234")
    .WithStatus(DocumentStatus.Processed)
    .Build();

Bogus combined with the builder pattern provides both randomized and deterministic test data construction, which is essential for covering edge cases while keeping tests readable.

[Bogus - Fake Data Generator for .NET] — Brian Chavez , 2024-04-15

Test Cleanup

Database Reset Between Tests

// Tests/Integration/Infrastructure/DatabaseReset.cs
public sealed class DatabaseReset : IAsyncLifetime
{
    private readonly AppDbContext _context;

    public DatabaseReset(AppDbContext context) => _context = context;

    public Task InitializeAsync() => Task.CompletedTask;

    public async Task DisposeAsync()
    {
        // Clean up test data
        _context.Documents.RemoveRange(_context.Documents);
        _context.Users.RemoveRange(_context.Users.Where(u => u.Email.Contains("test")));
        await _context.SaveChangesAsync();
    }
}

Conclusion

Integration testing with Testcontainers fundamentally changed our confidence in deployments. Before Testcontainers, our integration tests used an in-memory SQLite database that behaved differently from PostgreSQL in subtle but critical ways — array columns, JSON operators, and index behavior all diverged. Bugs would slip through the test suite and only appear in staging. With Testcontainers, every test runs against real PostgreSQL and real Redis, and the “works on my machine” problem is gone because the containers are identical in every environment. The initial infrastructure setup — Docker socket permissions, image caching, container startup optimization — took a few days to get right, but the payoff has been enormous. Our deployment failure rate dropped from roughly one-in-five to nearly zero, and the team now trusts the test suite enough to merge with confidence.

ComponentApproach
DatabaseTestcontainers with PostgreSQL
CacheTestcontainers with Redis
External APIsFake implementations
AuthenticationTest JWT tokens
DataBogus + Builder pattern

Next Steps

Further Reading

[Testcontainers for .NET getting started guide] — Testcontainers , 2024 [Microsoft: Integration tests in ASP.NET Core] — Microsoft , 2024 [xUnit.net shared context and fixtures] — xUnit.net Authors , 2024 [FluentAssertions documentation] — Fluentassertions , 2024 [Bogus fake data generator for .NET] — GitHub Community , 2024 [Testcontainers for .NET] — Testcontainers , 2024-08-15