• Nerdigy
  • \
  • Integration Tests: Testcontainers & Respawn

frontmatter.coverAlt

Integration Tests: Testcontainers & Respawn

How do you write fast, repeatable integration tests in .NET—without flakiness or leftover state slowing you down? In this post, we’ll walk through a modern approach that uses containerized databases and automated resets to ensure clean and consistent test runs every time.

🧭 What You’ll Learn

You’ll learn the following concepts in this post:

  • 🐳 How to set up Postgres in Docker using Testcontainers
  • 🧪 Write and run isolated integration tests
  • 🧹 Use Respawn to reset the database between tests

💡 Integration Tests Meets Modern Tooling

Two tools that significantly simplify integeration tests in the .NET ecosystem are Testcontainers and Respawn. Testcontainers allows you to spin up containerized dependencies (like databases) on demand using Docker, ensuring consistent and isolated environments for each test run. Meanwhile, Respawn helps you maintain a clean database state between tests by resetting data without the need to rebuild the schema from scratch.

🛠️ Setting Up a Test Project

Testcontainers have a wide variety of container types available, including SQL Server, MySQL, and more. For this post we’ll focus on an example using Postgres as our database, but the principles apply to any supported database. You can find the complete example code for this post on GitHub at: https://github.com/nerdigy/TestContainersExample.

The API endpoint we will test is a simple minimal API that registers a user:

public sealed class RegisterRequest
{
    public string Email { get; set; } = string.Empty;
}
 
public sealed record RegisterResponse(Guid Id, string Email);
 
public sealed class RegisterEndpoint : IEndpoint
{
    public void MapEndpoint(IEndpointRouteBuilder app)
    {
        app.MapPost("/register", async (ApplicationDbContext db, RegisterRequest request) =>
            {
                var email = request.Email.Normalize().ToUpper();
                var user = new User { Email = email };
                
                if (await db.Users.AnyAsync(x => x.Email == email))
                {
                    return Results.BadRequest("Email already exists.");
                }
 
                db.Users.Add(user);
 
                await db.SaveChangesAsync();
 
                return Results.Ok(new RegisterResponse(user.Id, user.Email));
            })
            .WithSummary("Register")
            .Produces<RegisterResponse>()
            .Produces(StatusCodes.Status400BadRequest)
            .WithTags("Users");
    }
}

This endpoint:

  • Allows users to register by providing an email address.
  • If a user with the same email already exists, it returns a 400 Bad Request response.

🐳 Configuring Testcontainers for a Database

To run integration tests against a real database, we’ll use Testcontainers to spin up a disposable Postgres Server instance in Docker. This ensures every test run starts with a clean, isolated environment.

To get started we’ll need to add a few dependencies to our test project. First, ensure you have the following NuGet packages installed:

dotnet add package Testcontainers.PostgreSql
dotnet add package Microsoft.AspNetCore.Mvc.Testing

🏗️ Building a Custom Test Host with WebApplicationFactory

With our dependencies in place, the next step is to create a custom WebApplicationFactory. This factory is a powerful test helper provided by ASP.NET Core that allows us to spin up a full application host in memory. By customizing it, we can inject our own services and configuration—like starting a Postgres container using Testcontainers and wiring up an HttpClient to send requests directly to our in-memory API for testing.

// A custom WebApplicationFactory that sets up a PostgreSQL container using Testcontainers for integration testing.
// Implements IAsyncLifetime to manage async setup and teardown logic.
public sealed class ExampleWebApplicationFactory :
    WebApplicationFactory<IApiMarker>,
    IAsyncLifetime
{
    // Define a PostgreSQL container with configuration using the Testcontainers library.
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithImage("postgres:alpine") // Use a lightweight PostgreSQL Docker image.
        .WithDatabase("example") 
        .WithPortBinding(hostPort: 5433, containerPort: 5432)
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();
 
    // HttpClient used to send requests to the test server.
    public HttpClient HttpClient { get; private set; } = null!;
    
    // Connection used to reset the database between tests.
    private DbConnection _dbConnection = null!;
 
    // Override ConfigureWebHost to inject the connection string into the application under test.
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        Environment.SetEnvironmentVariable("ConnectionStrings:Database", _dbContainer.GetConnectionString());
    }
 
    // Called once before any tests run. Starts the container, runs DB migration, sets up HttpClient
    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
        await MigrateDatabaseAsync();
        
        HttpClient = CreateClient(); 
    }
 
    // Applies any pending EF Core migrations to the test database.
    private async Task MigrateDatabaseAsync()
    {
        await using var scope = Services.CreateAsyncScope();
        
        var services = scope.ServiceProvider;
        var context = services.GetRequiredService<ApplicationDbContext>();
        
        await context.Database.MigrateAsync();
    }
 
    // Called once after all tests have run. Cleans up resources.
    public new async Task DisposeAsync()
    {
        await _dbContainer.DisposeAsync();
    }
}

Next, we need to define a test collection using CollectionDefinition. In xUnit, collections allow you to share setup and cleanup logic—like our custom WebApplicationFactory—across multiple test classes. By declaring a collection, we ensure that all tests using this factory run within the same shared context, which helps coordinate shared resources like containers or database connections.

[CollectionDefinition("ExampleTests")]
public class ExampleCollection : ICollectionFixture<ExampleWebApplicationFactory>;

Lastly, we’ll create a reusable base class for our integration tests. This class will leverage our custom WebApplicationFactory to provide consistent setup and shared access to an HttpClient, making it easier to write clean, focused test classes.

// Base class for integration tests that use the ExampleWebApplicationFactory.
// Ensures consistent HttpClient access
[Collection("ExampleTests")]
public abstract class ExampleIntegrationTest : IAsyncLifetime
{
    // Factory used to create a test server and manage the PostgreSQL container.
    private readonly ExampleWebApplicationFactory _webApplicationFactory;
 
    // Expose the HttpClient from the factory to derived test classes.
    protected HttpClient ApiClient => _webApplicationFactory.HttpClient;
    
    // Constructor accepts a shared factory instance for all tests.
    protected ExampleIntegrationTest(ExampleWebApplicationFactory factory)
    {
        _webApplicationFactory = factory;  
    }
 
    // Optionally initialize test-specific setup logic. Currently no-op.
    public virtual Task InitializeAsync()
    {
        // Nothing to do here
        return Task.CompletedTask;
    }
 
    // Placeholder for cleanup logic after each test.
    public Task DisposeAsync()
    {
        return Task.CompletedTask;
    }
}

🧪 Writing Integration Tests

To test our registration endpoint, we will create a test class that inherits from ExampleIntegrationTest. We are going to test two scenarios:

  1. Registering a user with valid data should return a success response.
  2. Attempting to register the same user again should return a 400 Bad Request response.
public sealed class RegisterTests : ExampleIntegrationTest
{
    // Constructor passes the shared factory to the base integration test class.
    public RegisterTests(ExampleWebApplicationFactory factory) : base(factory)
    {
    }
 
    // Tests that a user can be registered successfully with valid data.
    [Fact]
    public async Task RegisterUser_WithValidData_ReturnsSuccess_One()
    {
        var request = new RegisterRequest { Email = "user@example.com" };
        var response = await ApiClient.PostAsJsonAsync("/register", request);
        
        Assert.Equal(HttpStatusCode.OK, response.StatusCode); // Expect a 200 OK response.
 
        var responseBody = await response.Content.ReadFromJsonAsync<RegisterResponse>();
        Assert.Equal("USER@EXAMPLE.COM", responseBody.Email);
    }
    
    // Tests that registering the same user twice results in a BadRequest on the second attempt.
    [Fact]
    public async Task RegisterUser_WithValidData_ReturnsSuccess_Two()
    {
        var request1 = new RegisterRequest { Email = "user@example.com" };
        var response1 = await ApiClient.PostAsJsonAsync("/register", request1);
        
        Assert.Equal(HttpStatusCode.OK, response1.StatusCode); // First registration should succeed.
        
        var request2 = new RegisterRequest { Email = "user@example.com" };
        var response2 = await ApiClient.PostAsJsonAsync("/register", request2);
        
        Assert.Equal(HttpStatusCode.BadRequest, response2.StatusCode); // Second registration should fail.
    }
}

If you run these tests, you’ll notice they might fail due to the database state not being reset between tests. To ensure a clean database state, we can use Respawn to reset the database after each test.

🧹 Resetting Database State with Respawn

Respawn is a library that allows you to reset the state of your database between tests without having to rebuild the schema. To use Respawn, we need to add the following NuGet package to our test project:

dotnet add package Respawn

Next, we can modify our ExampleWebApplicationFactory to include Respawn for resetting the database state.

// A custom WebApplicationFactory that sets up a PostgreSQL container using Testcontainers for integration testing.
// Implements IAsyncLifetime to manage async setup and teardown logic.
public sealed class ExampleWebApplicationFactory :
    WebApplicationFactory<IApiMarker>,
    IAsyncLifetime
{
    // Define a PostgreSQL container with configuration using the Testcontainers library.
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithImage("postgres:alpine") // Use a lightweight PostgreSQL Docker image.
        .WithDatabase("example") 
        .WithPortBinding(hostPort: 5433, containerPort: 5432)
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();
 
    // HttpClient used to send requests to the test server.
    public HttpClient HttpClient { get; private set; } = null!;
    
    // Connection and Respawner used to reset the database between tests.
    private DbConnection _dbConnection = null!;
    private Respawner _respawner = null!;
 
    // Override ConfigureWebHost to inject the connection string into the application under test.
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        Environment.SetEnvironmentVariable("ConnectionStrings:Database", _dbContainer.GetConnectionString());
    }
 
    // Called once before any tests run. Starts the container, runs DB migration, sets up HttpClient and Respawner.
    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
        await MigrateDatabaseAsync();
        
        HttpClient = CreateClient(); 
        
        _dbConnection = new NpgsqlConnection(_dbContainer.GetConnectionString());
        await _dbConnection.OpenAsync();
        
        _respawner = await Respawner.CreateAsync(_dbConnection, new RespawnerOptions { DbAdapter = DbAdapter.Postgres });
    }
 
    // Applies any pending EF Core migrations to the test database.
    private async Task MigrateDatabaseAsync()
    {
        await using var scope = Services.CreateAsyncScope();
        
        var services = scope.ServiceProvider;
        var context = services.GetRequiredService<ApplicationDbContext>();
        
        await context.Database.MigrateAsync();
    }
 
    // Resets the database to a clean state using Respawner. This is run before each test to ensure a fresh state.
    public async Task ResetDatabaseAsync()
    {
        await _respawner.ResetAsync(_dbConnection);
    }
 
    // Called once after all tests have run. Cleans up resources.
    public new async Task DisposeAsync()
    {
        await _dbContainer.DisposeAsync();
        await _dbConnection.DisposeAsync();
    }
}

Now we can modify our ExampleIntegrationTest class to reset the database state before each test.

// Base class for integration tests that use the ExampleWebApplicationFactory.
// Ensures consistent HttpClient access and database reset behavior.
[Collection("ExampleTests")]
public abstract class ExampleIntegrationTest : IAsyncLifetime
{
    // Factory used to create a test server and manage the PostgreSQL container.
    private readonly ExampleWebApplicationFactory _webApplicationFactory;
 
    // Expose the HttpClient from the factory to derived test classes.
    protected HttpClient ApiClient => _webApplicationFactory.HttpClient;
    
    // Constructor accepts a shared factory instance for all tests.
    protected ExampleIntegrationTest(ExampleWebApplicationFactory factory)
    {
        _webApplicationFactory = factory;  
    }
 
    // Optionally initialize test-specific setup logic. Currently no-op.
    public virtual Task InitializeAsync()
    {
        // Nothing to do here
        return Task.CompletedTask;
    }
 
    // Reset the database state after each test using the factory's Respawner.
    public async Task DisposeAsync()
    {
        await _webApplicationFactory.ResetDatabaseAsync();
    }
}

Now, our tests will run successfully against a fresh database state each time, ensuring that they are isolated and repeatable.

📝 Conclusion

Integration testing is a powerful tool in ensuring that your .NET applications behave correctly under realistic conditions. By using Testcontainers, you can spin up real dependencies like PostgreSQL in Docker containers, giving you full control over your test environment. With Respawn, you can reset the database state between tests to ensure isolation and repeatability without sacrificing speed.

Together, these tools help eliminate the flakiness and fragility that often plagues integration tests. Whether you’re running tests locally or in a CI pipeline, this setup enables fast feedback and higher confidence in your system’s behavior.

Until next time, stay curious! 🚀