HomeNikola Knezevic

In this article

Banner

Integration Tests in ASP.NET Core

13 Aug 2026
6 min

Sponsor Newsletter

Unit tests give you fast feedback on isolated pieces of logic. They are great for validators, domain methods and small services.

But real ASP.NET Core apps are more than isolated methods. Requests flow through middleware, DI, EF Core, validation and HTTP wiring.

A unit test can pass while the endpoint still fails at runtime. That gap shows up late, often in staging or production.

That's where integration tests come in. They exercise the app as a whole against real dependencies so you catch those failures earlier.

If you're new to unit testing with xUnit, check out my previous blog post: Unit Testing using xUnit.

Integration Tests

Integration tests verify that multiple components work correctly together. In an API, that usually means the full request pipeline plus persistence.

Unlike unit tests, you do not mock everything away. You want real HTTP calls, real DI and ideally a real database.

In ASP.NET Core, the foundation for this is WebApplicationFactory. Combined with Testcontainers and Respawn, you get a clean setup that is close to production without sharing fragile test state.

  • WebApplicationFactory - Boots your ASP.NET Core app in-memory and gives you an HttpClient
  • Testcontainers - Spins up a real PostgreSQL container for each test run
  • Respawn - Resets database tables between tests so data does not leak

Getting Started

Create an xUnit test project next to your Web API. I name mine after the project under test, for example WebApi.IntegrationTests.

Add a project reference to the Web API, then install the packages:

shell
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Testcontainers.PostgreSql
dotnet add package Respawn
dotnet add package Shouldly

Microsoft.AspNetCore.Mvc.Testing provides WebApplicationFactory. Shouldly is optional, but I prefer its readable assertions.

NOTE: Testcontainers needs Docker running on the machine that executes the tests.

Custom WebApplicationFactory

The custom factory is the heart of the setup. It starts PostgreSQL, points the app at that container and prepares Respawn for cleanup.

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);
}

ConfigureWebHost overrides the connection string before the host starts. That way EF Core talks to the container instead of your local or cloud database.

InitializeAsync starts the container, creates the client and builds a Respawner. I include only the schemas my tests touch.

ResetDatabaseAsync clears those tables between tests. Respawn does this efficiently without dropping and recreating the database.

In the sample app, migrations run when the host starts in Development. After CreateClient, the schema is ready and Respawn can reset data safely.

Sharing the Factory

Starting a container for every test class is slow. xUnit collection fixtures let you share one factory instance across related tests.

csharp
[CollectionDefinition("ProductTests")]
public class BaseTest : ICollectionFixture<WebAppFactory>;

Tests that use this collection get the same WebAppFactory. The container starts once and stays alive for the collection lifetime.

Each test class still resets the database after it finishes so leftover rows do not affect the next class.

Writing the Tests

Mark the test class with the collection name and inject WebAppFactory. Implement IAsyncLifetime so you can reset the database 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_ShouldReturnBadRequest_WhenPriceIsNegative()
    {
        var request = new CreateRequest("Test Product", "Test Description", -1);

        var response = await factory.HttpClient.PostAsJsonAsync(BaseUrl, request);

        response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
    }

    [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);
    }
}

These tests hit the real endpoint. Validation, MediatR handlers and EF Core all run the same way they would outside the test harness.

You can also chain requests when a flow needs existing data. Create a product first, then fetch it by id and assert the response body:

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 productResponse = await response.Content.ReadFromJsonAsync<ProductResponse>();

    productResponse.ShouldNotBeNull();
    productResponse.Id.ShouldBe(id);
    productResponse.Name.ShouldBe(request.Name);
    productResponse.Description.ShouldBe(request.Description);
    productResponse.Price.ShouldBe(request.Price);
}

The same pattern works for updates and deletes. Assert status codes for happy paths and failure cases like not found or invalid input.

Because Respawn clears the tables after each test class, uniqueness rules and leftover products stay out of your way.

Conclusion

Integration tests close the gap unit tests leave open. They validate that your API, DI and database really work together.

WebApplicationFactory boots the app. Testcontainers gives you a real PostgreSQL instance. Respawn keeps tests isolated without slow teardown.

Start with the critical endpoints. Cover success and failure paths. Then expand as the surface of your API grows.

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!

Subscribe

Stay tuned for valuable insights every Thursday morning.