HomeNikola Knezevic

In this article

Banner

Respawn for Integration Tests in ASP.NET Core

17 Sept 2026
6 min

Special Thanks to Our Sponsors:

Sponsor Logo

EF Core is too slow? Discover how you can easily insert 14x faster (reducing saving time by 94%).

Boost your performance with our method within EF Core: Bulk Insert, update, delete and merge.

Thousands of satisfied customers have trusted our library since 2014.

👉 Learn more

Sponsor Newsletter

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:

shell
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:

csharp
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:

csharp
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:

csharp
[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:

csharp
[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:

csharp
[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:

csharp
[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 Code

I hope you enjoyed it, subscribe and get a notification when a new blog is up!

Related posts
Integration Tests in ASP.NET Core
Integration Tests in ASP.NET Core

Unit tests miss the full request pipeline. Integration tests with WebApplicationFactory, Testcontainers and Respawn verify your ASP.NET Core API against a real PostgreSQL database.

Unit Testing using xUnit
Unit Testing using xUnit

Unit testing is essential for maintaining code stability, preventing regressions and ensuring confidence in changes.

Parameterized Tests with xUnit in .NET
Parameterized Tests with xUnit in .NET

Parameterized tests are a powerful way to reduce repetition and improve clarity in your unit tests.

Subscribe

Stay tuned for valuable insights every Thursday morning.