Unit tests tell you a class works in isolation. They cannot tell you that your routing, model binding, middleware pipeline, authentication and database queries actually work together, because in production those pieces run inside a real ASP.NET Core host talking to a real database. Integration testing closes that gap. This guide covers WebApplicationFactory, the standard way to boot a full ASP.NET Core app in-process for tests; Testcontainers, for running that app against a real, disposable database instead of a fake; Respawn, for resetting that database between tests; how to mock authentication; how to integration-test a .NET Aspire distributed app; and how to keep all of it fast and reliable in CI.

What Is Integration Testing in ASP.NET Core?#

An integration test in ASP.NET Core exercises the app through its real HTTP pipeline β€” routing, model binding, filters, middleware, authentication and authorization β€” rather than calling a controller method directly. Microsoft.AspNetCore.Mvc.Testing provides WebApplicationFactory<TEntryPoint>, which hosts your app in an in-memory TestServer, so requests never touch a real socket but still flow through every piece of middleware you registered in Program.cs. The trade-off compared to unit tests is speed: an integration test that talks to a real Postgres container takes tens or hundreds of milliseconds instead of microseconds, so you write fewer of them and aim them at the paths that matter β€” the ones a mocked repository or an in-memory fake would let through unnoticed.

How WebApplicationFactory Works#

WebApplicationFactory<TEntryPoint> reflects over your app's entry point (Program, for a minimal-hosting app) and boots the exact same WebApplicationBuilder pipeline your app uses in production, inside the test process. CreateClient() returns an HttpClient wired to the in-memory TestServer; it automatically follows redirects and stores cookies between requests, both of which you can turn off through WebApplicationFactoryClientOptions when a test needs to inspect an intermediate response.

Because the factory reuses your real Program.cs, every test automatically stays in sync with genuine startup behavior β€” if someone adds required configuration or a new piece of middleware, the integration tests pick it up without any change on the test side.

Getting Started: A Minimal WebApplicationFactory Test#

The simplest integration test needs no customization at all. WebApplicationFactory<TEntryPoint> implements IDisposable, so an xUnit class fixture is the natural fit:

C#
public class BasicEndpointTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Theory]
    [InlineData("/health")]
    [InlineData("/products")]
    public async Task Get_endpoints_return_success_and_json(string url)
    {
        var client = factory.CreateClient();

        var response = await client.GetAsync(url);

        response.EnsureSuccessStatusCode();
        Assert.Equal("application/json; charset=utf-8",
            response.Content.Headers.ContentType?.ToString());
    }
}

IClassFixture<T> shares one WebApplicationFactory β€” and therefore one warmed-up host β€” across every test in the class, which matters once you add a real database behind it. For a minimal API or controller project, Program must be public partial class Program (the default template already marks it that way) so the test project can reference it as TEntryPoint.

Replacing Services for Tests#

Production Program.cs registers a real DbContext, a real message bus client and similar external dependencies. Override ConfigureWebHost in a custom factory to remove and replace them after the app's own Program.cs has already run:

C#
public class ApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<CatalogDbContext>));
            if (descriptor is not null)
            {
                services.Remove(descriptor);
            }

            services.AddDbContext<CatalogDbContext>(options =>
                options.UseNpgsql(TestDatabase.ConnectionString));
        });
    }
}

For a one-off override scoped to a single test method instead of every test in the class, call WithWebHostBuilder to get a fresh factory and use ConfigureTestServices (from Microsoft.AspNetCore.TestHost), which runs after the host's own ConfigureServices:

C#
var client = factory.WithWebHostBuilder(builder =>
{
    builder.ConfigureTestServices(services =>
    {
        services.AddSingleton<IPricingEngine, FixedPricingEngine>();
    });
}).CreateClient();

Starting in .NET 11, you can also override ConfigureWebApplicationBuilder(IHostApplicationBuilder), which runs immediately after WebApplication.CreateBuilder returns β€” earlier than ConfigureWebHost β€” which is useful when a test needs to inject configuration before any hosted service reads it.

Testing Against Real Dependencies with Testcontainers#

An in-memory fake or the EF Core InMemory provider can hide real bugs: case-sensitive string comparisons, cascade-delete behavior, unique constraints and raw SQL all behave differently β€” or not at all β€” against a fake. Testcontainers for .NET starts real Docker images for the test run and tears them down afterward, giving you production-accurate behavior without a shared, stateful test database:

C#
public class PostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer container = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("catalog")
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();

    public string ConnectionString => container.GetConnectionString();

    public Task InitializeAsync() => container.StartAsync();

    public Task DisposeAsync() => container.DisposeAsync().AsTask();
}

[CollectionDefinition("Postgres")]
public class PostgresCollection : ICollectionFixture<PostgresFixture>;

The Testcontainers.PostgreSql package (part of the Testcontainers for .NET family, also available for SQL Server, MySQL, Redis, Kafka, RabbitMQ and many more images) wraps a Docker container behind a small builder API. Pair the fixture with a CollectionDefinition so every test class in the "Postgres" collection shares one running container instead of paying its startup cost per class:

C#
[Collection("Postgres")]
public class CatalogEndpointTests : IClassFixture<ApiFactory>
{
    public CatalogEndpointTests(ApiFactory factory, PostgresFixture db)
    {
        Client = factory.WithWebHostBuilder(builder =>
            builder.ConfigureTestServices(services =>
                services.AddDbContext<CatalogDbContext>(o => o.UseNpgsql(db.ConnectionString))))
            .CreateClient();
    }

    private HttpClient Client { get; }
}

Testcontainers relies on a background "reaper" container to remove test containers even if the test process crashes or the CI job is killed, so runs don't leave orphaned containers behind on shared build agents.

Resetting Data Between Tests with Respawn#

Recreating a container per test is safe but slow. The common pattern is to reuse one running database for a whole collection and reset its data β€” not its schema β€” before or after each test with Respawn. Respawner.CreateAsync inspects the database's foreign-key graph once and computes a deletion order; ResetAsync then deletes rows from every table (excluding anything you opt out of) far faster than dropping and recreating the schema:

C#
public class DatabaseResetFixture : IAsyncLifetime
{
    private Respawner respawner = null!;
    private NpgsqlConnection connection = null!;

    public DatabaseResetFixture(PostgresFixture db) => ConnectionString = db.ConnectionString;

    private string ConnectionString { get; }

    public async Task InitializeAsync()
    {
        connection = new NpgsqlConnection(ConnectionString);
        await connection.OpenAsync();
        respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres,
            SchemasToInclude = ["public"],
            TablesToIgnore = ["__ef_migrations_history"]
        });
    }

    public async Task ResetAsync() => await respawner.ResetAsync(connection);

    public Task DisposeAsync() => connection.DisposeAsync().AsTask();
}

Call ResetAsync at the start of every test (an xUnit IAsyncLifetime.InitializeAsync on the test class itself is a convenient hook) so each test starts from a known-empty state regardless of what the previous test left behind. Because Respawn deletes in dependency order instead of truncating, it works even when foreign keys prevent a plain TRUNCATE.

Testing Authentication and Authorization#

Real sign-in flows β€” cookies, OAuth redirects, token issuance β€” are slow and mostly irrelevant to whether your endpoint's authorization policy is correct. Register a fake AuthenticationHandler through ConfigureTestServices instead, and let it issue whatever claims a given test needs:

C#
public class TestAuthHandler(
    IOptionsMonitor<AuthenticationSchemeOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[] { new Claim(ClaimTypes.Name, "test-user"), new Claim(ClaimTypes.Role, "Admin") };
        var identity = new ClaimsIdentity(claims, "TestScheme");
        var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), "TestScheme");
        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}
C#
var client = factory.WithWebHostBuilder(builder =>
{
    builder.ConfigureTestServices(services =>
    {
        services.AddAuthentication("TestScheme")
            .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("TestScheme", _ => { });
    });
}).CreateClient();

The scheme name passed to AddScheme must match what your test registers as the default, or ASP.NET Core falls back to the app's real authentication handler and the test silently exercises the wrong code path. To verify the unauthenticated case, set WebApplicationFactoryClientOptions.AllowAutoRedirect = false and assert on the 302 status code and the Location header instead of following the redirect all the way to a login page that doesn't make sense inside a test.

Testing .NET Aspire Apps with DistributedApplicationTestingBuilder#

A .NET Aspire app is a graph of projects, containers and other resources orchestrated by an AppHost, so testing "the app" means testing that graph, not just one service. The Aspire.Hosting.Testing package's DistributedApplicationTestingBuilder starts the real AppHost β€” with real container resources β€” inside the test process and gives you an HttpClient wired to a specific resource's endpoint:

C#
public class CatalogApiTests : IAsyncLifetime
{
    private DistributedApplication app = null!;

    public async Task InitializeAsync()
    {
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.Shop_AppHost>();

        app = await appHost.BuildAsync();
        await app.StartAsync();
    }

    public Task DisposeAsync() => app.DisposeAsync().AsTask();

    [Fact]
    public async Task Catalog_api_returns_seeded_products()
    {
        var client = app.CreateHttpClient("catalog-api");

        var response = await client.GetAsync("/products");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

CreateAsync<TEntryPoint> points at your AppHost project's generated Projects type, BuildAsync/StartAsync bring up every resource the AppHost declares β€” including Testcontainers-backed resources such as a Postgres or Redis container added with AddPostgres or AddRedis β€” and CreateHttpClient resolves the right base address for a named resource without you having to hardcode a port. app.GetConnectionStringAsync("resourceName") gives you a resource's connection string directly when a test needs to talk to a database outside of HTTP. Because this spins up real containers for every container resource in the graph, reserve it for the handful of tests that actually need to verify cross-service wiring, and keep most tests at the single-service WebApplicationFactory level.

Contract Testing: A Quick Overview#

Integration tests validate a service in isolation against its own dependencies; they cannot tell you that your client and someone else's API still agree on a request or response shape after either side changes independently. Consumer-driven contract testing closes that gap: the consumer records the exact requests and responses it depends on as a "contract" file, and the provider's own test suite replays that contract against its real implementation, failing the provider's build if it would have broken the consumer. PactNet, the .NET client for the Pact framework, is the most common way to do this in a .NET codebase, usually alongside a Pact Broker that stores and versions contracts between teams. Reach for contract testing once you have more than a couple of independently deployed services calling each other; below that scale, a shared integration test suite is usually simpler.

Making Integration Tests Fast and Reliable in CI#

  • Share containers across a collection, not a test. An ICollectionFixture that starts one Testcontainers instance per xUnit collection, combined with a Respawn reset before each test, is far cheaper than starting a fresh container per test class.
  • Disable parallelization within a collection that shares state. xUnit runs test collections in parallel by default; two tests resetting the same database concurrently will race. Put the whole "Postgres" collection in one sequential group and let unrelated collections run in parallel against each other.
  • Prefer the real container image over the EF Core InMemory provider for anything that depends on real SQL semantics. Reserve fakes for pure unit tests of logic that doesn't touch the database at all.
  • Run on runners with Docker already available. GitHub Actions' ubuntu-latest runners ship with Docker preinstalled, so Testcontainers-based tests need no extra services: block; see CI/CD for .NET with GitHub Actions and Azure DevOps for the rest of the pipeline.
  • Pin container image tags (postgres:16-alpine, not postgres:latest) so a test failure never turns out to be an unannounced image upgrade instead of a real regression.
  • Reserve DistributedApplicationTestingBuilder tests for cross-service scenarios. They start every resource in the AppHost graph, which is slower than a focused WebApplicationFactory test against one service.

Best Practices#

  • Keep one WebApplicationFactory-derived class per logical test surface and reuse it through IClassFixture, rather than constructing a new host per test method.
  • Assert on status codes, headers and a typed response body β€” deserialize with System.Text.Json, don't string-match raw JSON.
  • Seed only the data a test needs, inside that test, rather than relying on a shared fixture's seed data that other tests might mutate.
  • Keep authentication mocking centralized in one TestAuthHandler and vary claims per test through a header or a test-scoped service, instead of writing a new handler per scenario.
  • Log the TestServer's output on failure (factory.Services.GetRequiredService<ILoggerFactory>() wired to the test output) so a failing integration test is debuggable from CI logs alone.

Common Pitfalls#

  • Sharing one mutable database across parallel test classes with no reset strategy. Tests become order-dependent and flaky; add Respawn or scope each collection to its own schema.
  • Forgetting public partial class Program. Minimal API templates already mark it, but a stripped-down Program.cs can lose it, and WebApplicationFactory<Program> then fails to resolve the entry point.
  • Testing exclusively against the EF Core InMemory provider and discovering constraint violations, cascade behavior or raw SQL bugs only in production.
  • Letting CreateClient() follow redirects during an auth test and asserting on the final page instead of the redirect response that actually proves the authorization check ran.
  • Treating DistributedApplicationTestingBuilder tests as your default integration test style. They're valuable for verifying the AppHost graph itself, but every container resource adds real startup time.

Integration Tests vs Unit Tests vs Contract Tests#

AspectUnit testIntegration testContract test
What it verifiesOne class or method in isolationThe app's real pipeline plus its direct dependenciesAgreement between a consumer and a provider's API shape
Typical dependenciesNone (mocks/fakes only)Real database, cache or queue via TestcontainersA recorded contract file, replayed independently by each side
SpeedMicroseconds to low millisecondsTens to hundreds of milliseconds per testRuns as part of each side's own build
Primary .NET toolsxUnit/NUnit/MSTest with a mocking library, see Unit Testing in .NETWebApplicationFactory, Testcontainers, RespawnPactNet plus a Pact Broker
CatchesLogic errors within a unitWiring, middleware, query and serialization bugsBreaking API changes between independently deployed teams

Frequently Asked Questions#

What is the difference between WebApplicationFactory and TestServer?#

TestServer, from Microsoft.AspNetCore.TestHost, is the in-memory server that actually hosts your app for a test; WebApplicationFactory<TEntryPoint> is the higher-level wrapper that builds your app's real Program.cs pipeline, creates that TestServer for you, and hands you a ready-to-use HttpClient. Most integration tests only need WebApplicationFactory; reach for TestServer directly only for advanced hosting scenarios it doesn't cover.

Do I need Docker installed to use Testcontainers for .NET?#

Yes. Testcontainers starts and stops real Docker containers, so it needs a running Docker (or Podman, with the right compatibility settings) daemon on the machine or CI runner. GitHub Actions' ubuntu-latest runners include Docker by default, which is why most .NET teams run their Testcontainers-based suite there without extra setup.

Should I use the EF Core InMemory provider or a real database for integration tests?#

Use a real database through Testcontainers whenever the code under test relies on real SQL behavior: constraints, cascades, case sensitivity or raw queries. The InMemory provider is faster but silently allows things a real database would reject, which can hide bugs that only surface in production.

How do I mock authentication in an ASP.NET Core integration test?#

Register a custom AuthenticationHandler<AuthenticationSchemeOptions> through ConfigureTestServices and set it as the default scheme for the test client. The handler can return AuthenticateResult.Success with whatever claims the test needs, letting you exercise authorization policies without a real sign-in flow.

Can I integration test a .NET Aspire application?#

Yes, with DistributedApplicationTestingBuilder from the Aspire.Hosting.Testing package. It boots your real AppHost, including any Testcontainers-backed resources it declares, and gives you an HttpClient scoped to a named resource through CreateHttpClient, so you can test cross-service behavior close to how the app actually runs in production.

What is contract testing and do I need it?#

Contract testing verifies that a service consumer and provider still agree on request and response shapes, independent of either side's own integration tests. It's most valuable once several teams deploy services independently and a shared end-to-end suite becomes too slow or too coupled to own; below that scale, a solid WebApplicationFactory and Testcontainers suite usually covers the risk on its own.

Summary#

  • WebApplicationFactory<TEntryPoint> boots your real ASP.NET Core pipeline in-process; use ConfigureWebHost and WithWebHostBuilder/ConfigureTestServices to replace dependencies for tests.
  • Testcontainers gives integration tests real, disposable databases and other infrastructure instead of fakes that hide real bugs.
  • Respawn resets a shared database's data between tests far faster than recreating the schema, and it respects foreign-key order automatically.
  • Mock authentication with a custom AuthenticationHandler registered through ConfigureTestServices, matching the scheme name your app expects.
  • DistributedApplicationTestingBuilder extends the same ideas to a full .NET Aspire AppHost graph; reserve it for cross-service scenarios and keep most tests scoped to one service.
  • Share containers per test collection, disable parallelism within a collection that shares state, and pin image tags to keep the suite fast and deterministic in CI.

Further Reading#