Integration tests give us confidence that the full stack works together. API endpoints, validation, persistence and real database behavior all get exercised in one place.
The hard part is usually not writing the tests. It is keeping the database clean between them.
Shared state makes tests flaky. One test inserts a product, another expects the table to be empty and suddenly your suite fails for the wrong reason.
That's where Respawn comes in.
What is Respawn?
Respawn is a small library by Jimmy Bogard that resets a test database to a clean state.
Instead of dropping the database, recreating schemas or wrapping every test in a transaction, Respawn deletes data from tables in the right order.
It inspects foreign keys once, builds a delete strategy and reuses it. That makes resets fast and predictable across SQL Server, PostgreSQL, MySQL and more.
You can find the project here: Respawn on GitHub.
If you're new to unit testing with xUnit, check out my previous blog post: Unit Testing with xUnit.
Also I've written a dedicated blog post on integration tests that you can check out here: Integration Tests in ASP.NET Core.
Project Setup
We'll use a clean architecture sample with a Product API, PostgreSQL and xUnit.
The integration test project needs a few packages:
dotnet add package Respawn
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Testcontainers.PostgreSql
dotnet add package Shouldly
WebApplicationFactory hosts the ASP.NET Core app in memory. Testcontainers spins up a real PostgreSQL instance. Respawn clears the data between tests.
In the sample, the DbContext uses a dedicated schema for products:
public class ApplicationDbContext(DbContextOptions options)
: DbContext(options), IApplicationDbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("products");
modelBuilder.ApplyConfigurationsFromAssembly(
Assembly.GetExecutingAssembly());
}
public DbSet<Product> Products { get; set; }
}
That schema name matters later when we configure Respawn.
WebApplicationFactory with Respawn
The fixture owns the container, the HTTP client and the Respawner. We create the Respawner once during initialization:
public class WebAppFactory : WebApplicationFactory<AssemblyReference>, IAsyncLifetime
{
public HttpClient HttpClient { get; private set; }
private readonly PostgreSqlContainer _dbContainer =
new PostgreSqlBuilder()
.WithImage("postgres:latest")
.WithDatabase("testDb")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
private DbConnection _dbConnection = null!;
private Respawner _respawner = null!;
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
Environment.SetEnvironmentVariable(
"ConnectionStrings:Postgres",
_dbContainer.GetConnectionString());
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
HttpClient = CreateClient();
_dbConnection = new NpgsqlConnection(_dbContainer.GetConnectionString());
await _dbConnection.OpenAsync();
_respawner = await Respawner.CreateAsync(
_dbConnection,
new RespawnerOptions
{
SchemasToInclude = ["products"],
DbAdapter = DbAdapter.Postgres
});
}
public new async Task DisposeAsync()
{
await _dbContainer.StopAsync();
await _dbConnection.DisposeAsync();
}
public async Task ResetDatabaseAsync() =>
await _respawner.ResetAsync(_dbConnection);
}
A few details are worth calling out.
- ConfigureWebHost - Points the app at the Testcontainers connection string before the host starts.
- CreateClient - Boots the app so migrations can run in Development.
- Respawner.CreateAsync - Builds the delete plan once. Call this during fixture setup, not in every test.
- SchemasToInclude - Limits cleanup to the products schema from our DbContext.
- ResetDatabaseAsync - Thin wrapper around ResetAsync that tests call after each run.
NOTE: For PostgreSQL you pass an open DbConnection into both CreateAsync and ResetAsync. Keeping one connection open on the fixture avoids opening a new one on every reset.
Sharing the Fixture
Starting a container for every test class is slow. xUnit collection fixtures let us share one WebAppFactory across related tests:
[CollectionDefinition("ProductTests")]
public class BaseTest : ICollectionFixture<WebAppFactory>;
Each test class then opts into that collection and receives the shared factory through the constructor.
Resetting Between Tests
With one shared database, every test must leave a clean slate for the next one.
I reset after each test class by implementing IAsyncLifetime and calling ResetDatabaseAsync in DisposeAsync:
[Collection("ProductTests")]
public class CreateProductTests(WebAppFactory factory) : IAsyncLifetime
{
private const string BaseUrl = "/products";
public Task InitializeAsync() =>
Task.CompletedTask;
public async Task DisposeAsync() =>
await factory.ResetDatabaseAsync();
[Fact]
public async Task CreateProduct_ShouldReturnOk_WhenRequestIsValid()
{
var request = new CreateRequest(
"Test Product",
"Test Description",
2);
var response = await factory.HttpClient
.PostAsJsonAsync(BaseUrl, request);
response.StatusCode
.ShouldBe(HttpStatusCode.OK);
}
}
You can also reset before each test if you need a stronger isolation guarantee inside a class. The important part is that Respawn runs against the same open connection and the same Respawner instance.
Writing the Tests
Once the fixture is in place, the tests themselves stay focused on HTTP behavior.
Here is a get-by-id flow that creates a product first, then verifies the response:
[Fact]
public async Task GetProduct_ShouldReturnOk_WhenProductIsFound()
{
var request = new CreateRequest(
"Test Product",
"Test Description",
2);
var postResponse = await factory.HttpClient.PostAsJsonAsync(BaseUrl, request);
var id = await postResponse.Content.ReadFromJsonAsync<Guid>();
var response = await factory.HttpClient.GetAsync($"{BaseUrl}/{id}");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var product = await response.Content.ReadFromJsonAsync<ProductResponse>();
product.ShouldNotBeNull();
product.Id.ShouldBe(id);
product.Name.ShouldBe(request.Name);
product.Description.ShouldBe(request.Description);
product.Price.ShouldBe(request.Price);
}
And uniqueness validation only works reliably when leftover rows from other tests are gone:
[Fact]
public async Task CreateProduct_ShouldReturnBadRequest_WhenNameIsNotUnique()
{
var request = new CreateRequest(
"Test Product",
"Test Description",
2);
var firstResponse = await factory.HttpClient.PostAsJsonAsync(BaseUrl, request);
firstResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var secondResponse = await factory.HttpClient.PostAsJsonAsync(BaseUrl, request);
secondResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
Without Respawn, that second insert might already fail because an earlier test left the same name behind. With Respawn, the failure means your uniqueness rule actually works.
Useful RespawnerOptions
Depending on your database layout, you may want finer control:
- TablesToIgnore - Skip lookup tables or seed data you want to keep.
- SchemasToExclude - Leave migration or tooling schemas alone.
- WithReseed - Reset identity columns where that matters.
- DbAdapter - Explicitly select Postgres, SqlServer, MySql and others when needed.
In my sample, including only the products schema is enough. The delete plan stays small and resets stay cheap.
Conclusion
Reliable integration tests need a clean database. Recreating containers or databases between every test is too slow. Manual deletes are easy to get wrong.
Respawn gives you a fast reset that respects foreign keys and keeps your schema intact.
Combine it with WebApplicationFactory, Testcontainers and an xUnit collection fixture and you get a setup that is realistic, isolated and practical for day-to-day development.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!



