Respawn: Fast Database Reset Between Integration Tests
Integration tests that hit a real database need a clean state for each test. The naive approach — dropping and recreating the database, or re-running migrations — works but is painfully slow. A PostgreSQL database with 50 tables and migrations can take several seconds to recreate, turning a test suite that should run in 30 seconds into a 10-minute ordeal.
Respawn takes a smarter approach. Instead of dropping the database, it intelligently deletes data from tables in the correct order, respecting foreign key constraints. It's created by Jimmy Bogard (the author of MediatR and AutoMapper), and it's the standard tool for this problem in the .NET ecosystem.
Installation
dotnet add package Respawn
How It Works
On first use, Respawn analyses your database schema — tables, foreign keys, and relationships — and determines the correct deletion order. It then generates and caches the DELETE statements needed to clear all tables. Subsequent resets reuse the cached plan, making them extremely fast.
// Create a Respawner (do this once, reuse across tests)
var respawner = await Respawner.CreateAsync(connectionString, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres
});
// Reset the database (do this before each test)
await respawner.ResetAsync(connectionString);
That's the entire API. Create once, reset many times.
Integration with xUnit and Testcontainers
The typical setup combines Respawn with Testcontainers for a fully isolated test database:
public class DatabaseFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
private Respawner _respawner = null!;
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
// Apply migrations
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(ConnectionString)
.Options;
await using var db = new AppDbContext(options);
await db.Database.MigrateAsync();
// Create the respawner after migrations
_respawner = await Respawner.CreateAsync(ConnectionString, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = new[] { "public" }
});
}
public async Task ResetAsync()
{
await _respawner.ResetAsync(ConnectionString);
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
Use it in your tests:
[Collection("Database")]
public class OrderRepositoryTests : IAsyncLifetime
{
private readonly DatabaseFixture _fixture;
private readonly AppDbContext _db;
public OrderRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(fixture.ConnectionString)
.Options;
_db = new AppDbContext(options);
}
public async Task InitializeAsync()
{
await _fixture.ResetAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateOrder_PersistsOrder()
{
var order = new Order { CustomerName = "Alice", Total = 49.99m };
_db.Orders.Add(order);
await _db.SaveChangesAsync();
var saved = await _db.Orders.FirstAsync(o => o.CustomerName == "Alice");
Assert.Equal(49.99m, saved.Total);
}
[Fact]
public async Task GetOrders_EmptyDatabase_ReturnsEmpty()
{
// This test relies on the database being empty after Respawn reset
var orders = await _db.Orders.ToListAsync();
Assert.Empty(orders);
}
}
The IAsyncLifetime.InitializeAsync method runs before each test, calling ResetAsync to clear all data. Each test starts with a clean database.
Combining with WebApplicationFactory
For full API integration tests:
public class ApiTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
private Respawner _respawner = null!;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_dbContainer.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
// Force the app to start and apply migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
_respawner = await Respawner.CreateAsync(
_dbContainer.GetConnectionString(),
new RespawnerOptions { DbAdapter = DbAdapter.Postgres });
}
public async Task ResetDatabaseAsync()
{
await _respawner.ResetAsync(_dbContainer.GetConnectionString());
}
public new async Task DisposeAsync()
{
await _dbContainer.DisposeAsync();
await base.DisposeAsync();
}
}
Configuration Options
Respawn provides several options to control which tables are reset:
var respawner = await Respawner.CreateAsync(connectionString, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = new[] { "public" },
TablesToIgnore = new Table[]
{
"__EFMigrationsHistory", // Keep migration history
"Roles", // Keep reference data
"Permissions" // Keep reference data
},
WithReseed = true // Reset identity columns (SQL Server only)
});
The TablesToIgnore option is essential for reference/lookup data that should survive resets. Migrations history, role definitions, country lists — anything that's seeded once and shouldn't be deleted.
Performance Comparison
On a database with 30 tables and moderate foreign key complexity:
| Approach | Time per reset |
|---|---|
| Drop + recreate + migrate | 3-8 seconds |
EF EnsureDeleted + EnsureCreated |
2-5 seconds |
| Respawn | 20-50 milliseconds |
The difference is dramatic. With 100 integration tests, Respawn saves several minutes of total execution time.
When to Use Respawn
Use Respawn whenever you have integration tests that need a clean database and you're not recreating the database container per test. It pairs perfectly with the Testcontainers pattern of one container per test class (or per test collection), where the container starts once and Respawn cleans between individual tests.
For tests that don't touch the database, you don't need it. For tests that only read from the database using pre-seeded data, you don't need it either. Respawn is specifically for the case where tests write data and subsequent tests need to start fresh.