Home C# / .NET Integration Testing in ASP.NET Core: The Complete Advanced Guide

Integration Testing in ASP.NET Core: The Complete Advanced Guide

In Plain English 🔥
Imagine you're building a vending machine. A unit test checks that the coin sensor works in isolation. But an integration test puts actual coins in, presses the button, and checks whether the right snack falls out — involving the sensor, the motor, the inventory counter, and the dispenser all working together. In ASP.NET Core, integration tests fire real HTTP requests at your application running in memory, touching your middleware, routing, dependency injection, and database logic all at once. It's the difference between testing parts on a workbench versus testing the whole machine.
⚡ Quick Answer
Imagine you're building a vending machine. A unit test checks that the coin sensor works in isolation. But an integration test puts actual coins in, presses the button, and checks whether the right snack falls out — involving the sensor, the motor, the inventory counter, and the dispenser all working together. In ASP.NET Core, integration tests fire real HTTP requests at your application running in memory, touching your middleware, routing, dependency injection, and database logic all at once. It's the difference between testing parts on a workbench versus testing the whole machine.

Most bugs in production don't live inside a single method — they live in the gaps between methods, between layers, between services. Your OrderService might be perfectly unit-tested, but if your controller serializes the response body differently than your client expects, or your middleware strips an auth header before it reaches your handler, no amount of unit tests will catch it before your users do. Integration tests are the safety net that catches exactly those gaps, and in ASP.NET Core, the tooling to write them has become genuinely excellent.

The problem integration testing solves is confidence at the boundary. When you spin up a WebApplicationFactory, you're running the real Startup/Program pipeline — the same middleware stack, the same DI container, the same routing engine — but in memory, with no open port, no deployment, and no flaky network. You can swap out your real database for an in-memory or SQLite test double, override specific services, inject test users, and assert on real HTTP responses with real JSON bodies. This is fundamentally different from mocking an IOrderRepository in a unit test, because you're testing whether all the wiring is correct, not just the logic.

By the end of this article you'll know how to build a reusable WebApplicationFactory fixture that replaces Entity Framework's real database with SQLite, how to authenticate requests inside tests without spinning up an identity provider, how to manage test isolation so tests don't bleed state into each other, and how to avoid the production gotchas that make integration test suites slow, flaky, and hard to maintain.

How WebApplicationFactory Works Under the Hood

WebApplicationFactory is the cornerstone of ASP.NET Core integration testing. It creates an in-process test server — a full ASP.NET Core host — using your real Program.cs or Startup as the entry point. No TCP port is opened. Requests travel through an in-memory channel directly into Kestrel's request pipeline, which means latency is near zero and your CI machine needs no special networking permissions.

Internally, WebApplicationFactory calls WebApplication.CreateBuilder (or the older CreateHostBuilder) with a special configuration that replaces the real server with TestServer from Microsoft.AspNetCore.TestHost. The TEntryPoint type parameter tells the factory which assembly to use for discovering your Program class. This is why you'll often see an empty partial class added to the web project just to expose the internal Program type to the test project.

The factory exposes a CreateClient() method that returns an HttpClient whose transport is wired directly to that TestServer — no real sockets involved. You can call WithWebHostBuilder() to override any part of the host configuration before the client is created, which is where you swap databases, override services, or reconfigure logging. Critically, the host is built lazily on first access to Server or CreateClient(), so configuration overrides must happen before that point.

Understanding this lazy build model explains one of the most common ordering bugs in integration test suites: calling CreateClient() before finishing your WithWebHostBuilder() customizations. Once the host is built, further calls to ConfigureServices on the same factory instance have no effect.

CustomWebApplicationFactory.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// CustomWebApplicationFactory.cs
// This factory is the heart of your integration test setup.
// It replaces the real SQL Server database with SQLite so tests
// run fast, in-memory, and without needing a live DB connection.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using OrderApi.Data; // Your real DbContext namespace

namespace OrderApi.IntegrationTests;

// TEntryPoint must be your Program class (or Startup).
// If Program is internal, add: public partial class Program { }
// to your web project's Program.cs.
public class OrderApiFactory : WebApplicationFactory<Program>
{
    // Override this to customise the host BEFORE it is built.
    // This runs once per factory instance, not once per test.
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Step 1: Remove the real DbContext registration that
            // Program.cs added (it points at SQL Server).
            services.RemoveAll<DbContextOptions<OrderDbContext>>();
            services.RemoveAll<OrderDbContext>();

            // Step 2: Register a SQLite in-memory database instead.
            // Using a named connection string keeps the same DB across
            // the lifetime of this factory instance — critical for
            // tests that seed data in one call and read it in another.
            services.AddDbContext<OrderDbContext>(options =>
            {
                options.UseSqlite("Data Source=:memory:");
            });

            // Step 3: Build the schema once the container is ready.
            // We resolve a scope manually so EnsureCreated() runs
            // against the in-memory database before any test touches it.
            var serviceProvider = services.BuildServiceProvider();
            using var scope = serviceProvider.CreateScope();
            var dbContext = scope.ServiceProvider
                                 .GetRequiredService<OrderDbContext>();

            // EnsureCreated() is fine for tests; never use it in production.
            // It creates tables from your EF model without running migrations.
            dbContext.Database.EnsureCreated();
        });

        // Suppress noisy logs during test runs — errors still show.
        builder.UseEnvironment("Testing");
    }
}
▶ Output
// No console output — this is infrastructure code.
// When a test calls factory.CreateClient(), the host boots,
// ConfigureWebHost runs, SQLite schema is created, and
// an HttpClient backed by TestServer is returned.
// You'll see this in the test output runner:
// info: Microsoft.Hosting.Lifetime[14]
// Now listening on: http://localhost (no real port)
⚠️
Watch Out: SQLite ':memory:' and Connection ScopeSQLite's ':memory:' database is tied to a single connection. If your DbContext opens a new connection per request (the default EF Core behaviour), each request sees an empty database. Fix this by using 'Data Source=IntegrationTestDb;Mode=Memory;Cache=Shared' and registering the context with a shared SqliteConnection that you open once and pass in as the connection — see the SqliteConnection fixture pattern in the next section.

Test Fixtures, Isolation, and Shared Database State

The biggest architectural decision in an integration test suite is: how much state do tests share? Share too much and tests interfere with each other in non-deterministic ways. Share too little and your suite spins up a full ASP.NET Core host for every single test, making it painfully slow.

xUnit's IClassFixture is the answer. It creates one instance of T per test class and disposes it after the last test in that class runs. If you put your WebApplicationFactory in a class fixture, all tests in that class share one host boot — which typically takes 500ms to 2 seconds — while each test gets a fresh HttpClient. That's a massive performance win over creating a new factory per test.

But shared factory means shared database state. A test that creates an Order in one test method will see that Order in the next test if you don't clean up. The gold standard solution is to wrap each test in a transaction that you roll back at the end, but that's hard through HTTP. The practical alternative is to use a respawn library (like Respawn by Jimmy Bogard) or to re-seed the database in a known state before each test using an IAsyncLifetime interface.

For tests that truly can't share state — load tests, tests that change global configuration — use a separate factory instance per test class and accept the boot cost. The key insight is that the cost to avoid is the host boot, not the HttpClient creation. HttpClient creation is cheap; host boot is not.

OrderEndpointTests.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
// OrderEndpointTests.cs
// Demonstrates the full pattern:
//   1. IClassFixture shares one factory across all tests in the class
//   2. IAsyncLifetime resets the DB before each test
//   3. Each test creates its own HttpClient (cheap)
//   4. Tests are fully independent of each other

using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using OrderApi.Data;
using OrderApi.Models;
using Xunit;

namespace OrderApi.IntegrationTests;

// IClassFixture<OrderApiFactory>: xUnit creates one OrderApiFactory
// for the whole class, not one per test. Host boots once.
public class OrderEndpointTests :
    IClassFixture<OrderApiFactory>,
    IAsyncLifetime  // Gives us InitializeAsync / DisposeAsync per test
{
    private readonly OrderApiFactory _factory;
    private readonly HttpClient _httpClient;

    public OrderEndpointTests(OrderApiFactory factory)
    {
        _factory = factory;
        // CreateClient() is cheap — it just creates a new HttpClient
        // connected to the already-booted TestServer.
        _httpClient = factory.CreateClient();
    }

    // Runs BEFORE each test method — use this to seed clean data.
    public async Task InitializeAsync()
    {
        // Get a scoped service directly from the factory's DI container
        // to reset state without going through HTTP.
        using var scope = _factory.Services.CreateScope();
        var dbContext = scope.ServiceProvider
                             .GetRequiredService<OrderDbContext>();

        // Clear all orders so each test starts from a known empty state.
        dbContext.Orders.RemoveRange(dbContext.Orders);
        await dbContext.SaveChangesAsync();
    }

    // Runs AFTER each test method — clean up if needed.
    public Task DisposeAsync() => Task.CompletedTask;

    [Fact]
    public async Task GetOrders_WhenNoOrdersExist_ReturnsEmptyArray()
    {
        // Act — fire a real HTTP GET through the full middleware pipeline
        var response = await _httpClient.GetAsync("/api/orders");

        // Assert on the HTTP status code first
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);

        // Deserialize the real JSON body — this catches serialization bugs
        // that unit tests on the service layer would never catch
        var orders = await response.Content
                                   .ReadFromJsonAsync<List<OrderDto>>();
        Assert.NotNull(orders);
        Assert.Empty(orders);
    }

    [Fact]
    public async Task CreateOrder_WithValidPayload_Returns201AndLocationHeader()
    {
        // Arrange — build a realistic request payload
        var newOrder = new CreateOrderRequest
        {
            CustomerEmail = "alice@example.com",
            ProductSku    = "WIDGET-42",
            Quantity      = 3
        };

        // Act — POST through the real routing + validation pipeline
        var response = await _httpClient.PostAsJsonAsync("/api/orders", newOrder);

        // Assert — 201 Created with a Location header pointing at the new resource
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Headers.Location);

        // Verify the resource actually exists by following the Location header
        var getResponse = await _httpClient.GetAsync(response.Headers.Location);
        Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);

        var createdOrder = await getResponse.Content
                                            .ReadFromJsonAsync<OrderDto>();
        Assert.Equal("alice@example.com", createdOrder!.CustomerEmail);
    }

    [Fact]
    public async Task CreateOrder_WithInvalidQuantity_Returns400WithProblemDetails()
    {
        // Arrange — quantity of 0 should fail model validation
        var invalidOrder = new CreateOrderRequest
        {
            CustomerEmail = "bob@example.com",
            ProductSku    = "WIDGET-42",
            Quantity      = 0  // Invalid: must be >= 1
        };

        // Act
        var response = await _httpClient.PostAsJsonAsync("/api/orders", invalidOrder);

        // Assert — model validation returns 400 with RFC 7807 ProblemDetails
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

        // Check the Content-Type is application/problem+json, not application/json
        // This is a real content-negotiation detail only integration tests catch
        Assert.Equal(
            "application/problem+json",
            response.Content.Headers.ContentType?.MediaType);
    }
}
▶ Output
// xUnit test runner output (dotnet test):
//
// Test run for OrderApi.IntegrationTests.dll
// Starting test execution, please wait...
//
// A total of 3 test files matched the specified pattern.
//
// [xUnit.net 00:00:01.42] OrderEndpointTests
// [xUnit.net 00:00:01.43] PASS GetOrders_WhenNoOrdersExist_ReturnsEmptyArray [47ms]
// [xUnit.net 00:00:01.44] PASS CreateOrder_WithValidPayload_Returns201AndLocationHeader [23ms]
// [xUnit.net 00:00:01.44] PASS CreateOrder_WithInvalidQuantity_Returns400WithProblemDetails [11ms]
//
// Test Run Successful.
// Total tests: 3
// Passed: 3
// Failed: 0
// Total time: 1.8926 Seconds
⚠️
Pro Tip: Use Services.CreateScope() for Direct DB Access in TestsYou can bypass HTTP entirely for setup and teardown by calling _factory.Services.CreateScope() and resolving your DbContext directly. This is faster than HTTP for seeding data, and it lets you assert on database state after an operation without needing a GET endpoint — useful when you're testing commands that have no read-back response.

Authenticating Requests in Integration Tests Without an Identity Provider

Authenticated endpoints are where most integration test suites fall apart. Developers either skip testing protected endpoints entirely, or they stand up a real identity provider in CI — which is slow, brittle, and unnecessary. There's a much cleaner approach: a custom authentication handler that accepts a test JWT you mint yourself.

The pattern works by registering a fake authentication scheme in your test factory's ConfigureWebHost that reads a well-known header (like X-Integration-Test-Auth) and creates a ClaimsPrincipal with whatever claims you pass in. Your real controllers see an authenticated user; no JWT validation, no JWKS endpoint, no token expiry.

For authorization policies (not just authentication), this matters even more. If you have a policy like RequireRole("OrderManager"), your test handler needs to mint a principal with that role claim. You can make this ergonomic by adding an extension method on HttpClient that sets the test auth header with a predefined set of claims.

One important subtlety: only register this fake auth handler in the Testing environment. The safest way is to check builder.Environment.EnvironmentName inside ConfigureWebHost and throw an InvalidOperationException if it's ever called in Production. Defense in depth — you don't want test backdoors accidentally shipped.

TestAuthHandler.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
// TestAuthHandler.cs
// A custom AuthenticationHandler that lets tests bypass real JWT validation.
// Only active when EnvironmentName == "Testing".

using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace OrderApi.IntegrationTests;

// The options class carries no state — we just need it to satisfy
// the AuthenticationHandler<TOptions> generic constraint.
public class TestAuthHandlerOptions : AuthenticationSchemeOptions { }

public class TestAuthHandler
    : AuthenticationHandler<TestAuthHandlerOptions>
{
    // The header name that tests use to pass claim data.
    // Format: "sub=alice@example.com,role=OrderManager,role=Admin"
    public const string AuthHeaderName = "X-Integration-Test-Auth";
    public const string SchemeName    = "TestAuth";

    public TestAuthHandler(
        IOptionsMonitor<TestAuthHandlerOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder) { }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // If the test didn't send the auth header, fail authentication
        // gracefully — this lets tests for unauthenticated scenarios work.
        if (!Request.Headers.TryGetValue(AuthHeaderName, out var headerValue))
        {
            return Task.FromResult(AuthenticateResult.NoResult());
        }

        // Parse comma-separated "key=value" pairs into claims.
        // Example header: "sub=alice@example.com,role=OrderManager"
        var claims = headerValue.ToString()
            .Split(',', StringSplitOptions.RemoveEmptyEntries)
            .Select(pair =>
            {
                var parts = pair.Split('=', 2);
                return new Claim(parts[0].Trim(), parts[1].Trim());
            })
            .ToList();

        // Always include a name identifier so standard
        // User.Identity.IsAuthenticated returns true.
        if (!claims.Any(c => c.Type == ClaimTypes.NameIdentifier))
        {
            var subClaim = claims.FirstOrDefault(c => c.Type == "sub");
            if (subClaim is not null)
            {
                claims.Add(new Claim(
                    ClaimTypes.NameIdentifier,
                    subClaim.Value));
            }
        }

        var identity  = new ClaimsIdentity(claims, SchemeName);
        var principal = new ClaimsPrincipal(identity);
        var ticket    = new AuthenticationTicket(principal, SchemeName);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

// -----------------------------------------------------------------
// Extension to register this handler inside OrderApiFactory
// -----------------------------------------------------------------
// Add this inside your OrderApiFactory.ConfigureWebHost:
//
//   services.AddAuthentication(TestAuthHandler.SchemeName)
//           .AddScheme<TestAuthHandlerOptions, TestAuthHandler>(
//               TestAuthHandler.SchemeName, _ => { });
//
// -----------------------------------------------------------------
// HttpClientExtensions.cs — ergonomic helpers for test methods
// -----------------------------------------------------------------

namespace OrderApi.IntegrationTests;

public static class HttpClientExtensions
{
    // Call this to make any request look like it came from
    // the specified user with the specified roles.
    public static HttpClient AsUser(
        this HttpClient client,
        string email,
        params string[] roles)
    {
        // Build the header value: "sub=alice@example.com,role=OrderManager"
        var rolePairs   = roles.Select(r => $"role={r}");
        var claimString = string.Join(',', new[] { $"sub={email}" }.Concat(rolePairs));

        // Remove any previously set auth header so we can switch users
        // between calls on the same client instance.
        client.DefaultRequestHeaders.Remove(
            TestAuthHandler.AuthHeaderName);

        client.DefaultRequestHeaders.Add(
            TestAuthHandler.AuthHeaderName,
            claimString);

        return client; // Fluent — lets you chain: client.AsUser(...).GetAsync(...)
    }
}

// -----------------------------------------------------------------
// Usage in a test:
// -----------------------------------------------------------------
//
//   [Fact]
//   public async Task DeleteOrder_AsOrderManager_Returns204()
//   {
//       _httpClient.AsUser("alice@example.com", "OrderManager");
//       var response = await _httpClient.DeleteAsync("/api/orders/1");
//       Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
//   }
//
//   [Fact]
//   public async Task DeleteOrder_AsRegularUser_Returns403()
//   {
//       _httpClient.AsUser("bob@example.com"); // No roles
//       var response = await _httpClient.DeleteAsync("/api/orders/1");
//       Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
//   }
▶ Output
// When AsUser is NOT called (no header sent):
// AuthenticateResult.NoResult() → request is anonymous
// [Authorize] endpoint returns: HTTP 401 Unauthorized
//
// When AsUser("alice@example.com", "OrderManager") is called:
// Claims: sub=alice@example.com, role=OrderManager, NameIdentifier=alice@example.com
// [Authorize(Roles="OrderManager")] passes → HTTP 200 OK
//
// When AsUser("bob@example.com") is called (no matching role):
// [Authorize(Roles="OrderManager")] fails → HTTP 403 Forbidden
//
// Test output:
// PASS DeleteOrder_AsOrderManager_Returns204 [18ms]
// PASS DeleteOrder_AsRegularUser_Returns403 [9ms]
⚠️
Watch Out: Never Ship TestAuthHandler to ProductionGuard your ConfigureWebHost override with: if (builder.Environment.EnvironmentName != "Testing") throw new InvalidOperationException("TestAuthHandler must only be registered in the Testing environment."); — and add an integration test that verifies the Production environment rejects this scheme. A test backdoor that accidentally reaches production is a critical security vulnerability.

Production Gotchas — Performance, Flakiness, and Parallel Execution

Once your integration test suite grows past 50 tests, three problems tend to emerge simultaneously: the suite gets slow, tests start failing non-deterministically, and parallel execution breaks everything. Each has a root cause and a fix.

Slowness almost always traces back to redundant host boots. xUnit runs test classes in parallel by default, and if each class creates its own WebApplicationFactory, you boot one host per class. The fix is a shared CollectionFixture — a single factory shared across all test classes — but this means all tests share the same database, which brings us to the second problem.

Flakiness in integration tests is almost always a test ordering dependency: Test A creates data that Test B accidentally reads. If tests run in the same order every time locally but in a different order in CI (where parallelism is different), you'll see intermittent failures that are almost impossible to reproduce. The fix is strict isolation: each test either resets the database in IAsyncLifetime.InitializeAsync, or uses a Respawn checkpoint to fast-truncate only the tables that changed.

Parallel execution breaks SQLite ':memory:' databases because each connection sees its own empty database. The fix is either to use a file-based SQLite database with a unique path per test run (using Path.GetTempFileName()), or to use a real containerised database in CI via Testcontainers-dotnet, which spins up a real Postgres or SQL Server container that multiple parallel test processes can share.

Testcontainers is worth the extra setup for production-grade suites because it catches SQL Server-specific behaviour — things like case-sensitive collation, specific JSON function syntax, or NOLOCK hints — that SQLite silently handles differently.

TestcontainersOrderApiFactory.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
// TestcontainersOrderApiFactory.cs
// Uses Testcontainers to spin up a real PostgreSQL container in CI.
// Requires NuGet: Testcontainers.PostgreSql
//
// This is the production-grade approach for teams who need
// their integration tests to run against the real database engine.

using DotNet.Testcontainers.Builders;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;
using OrderApi.Data;

namespace OrderApi.IntegrationTests;

// IAsyncLifetime makes xUnit call InitializeAsync before the first test
// and DisposeAsync after the last — perfect for starting and stopping
// the Docker container around the test run.
public class PostgresOrderApiFactory
    : WebApplicationFactory<Program>, IAsyncLifetime
{
    // Testcontainers builds a real Docker container for PostgreSQL.
    // The builder pattern configures the image, port mapping,
    // and database credentials.
    private readonly PostgreSqlContainer _postgresContainer =
        new PostgreSqlBuilder()
            .WithImage("postgres:16-alpine")   // Pin the version for reproducibility
            .WithDatabase("integration_tests")
            .WithUsername("test_user")
            .WithPassword("test_password_never_used_in_prod")
            .WithCleanUp(true)  // Remove container after test run
            .Build();

    // Start the container before any test touches the factory.
    // This is where Docker actually pulls the image and boots Postgres.
    public async Task InitializeAsync()
    {
        await _postgresContainer.StartAsync();
    }

    // Stop and remove the container when the test run finishes.
    public new async Task DisposeAsync()
    {
        await _postgresContainer.StopAsync();
        await base.DisposeAsync();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove the production DbContext pointing at SQL Server
            services.RemoveAll<DbContextOptions<OrderDbContext>>();
            services.RemoveAll<OrderDbContext>();

            // Wire up EF Core to the live Postgres container.
            // GetConnectionString() returns the dynamically assigned
            // host/port from the running container.
            services.AddDbContext<OrderDbContext>(options =>
            {
                options.UseNpgsql(
                    _postgresContainer.GetConnectionString());
            });

            // Run migrations against the real Postgres schema.
            // MigrateAsync is better than EnsureCreated here because
            // it validates that your migration history is correct.
            var serviceProvider = services.BuildServiceProvider();
            using var scope     = serviceProvider.CreateScope();
            var dbContext       = scope.ServiceProvider
                                       .GetRequiredService<OrderDbContext>();

            dbContext.Database.Migrate();
        });

        builder.UseEnvironment("Testing");
    }
}

// -----------------------------------------------------------------
// CollectionFixture.cs — share ONE factory across ALL test classes
// -----------------------------------------------------------------
// This cuts host boot time from O(n classes) to O(1).

[CollectionDefinition("OrderApi Integration Tests")]
public class OrderApiTestCollection
    : ICollectionFixture<PostgresOrderApiFactory> { }

// Then in each test class, replace IClassFixture with [Collection]:
//
//   [Collection("OrderApi Integration Tests")]
//   public class OrderEndpointTests
//   {
//       public OrderEndpointTests(PostgresOrderApiFactory factory) { ... }
//   }
//
// All classes in the same collection share the same factory instance —
// one Docker container, one host boot, for the entire test run.
▶ Output
// dotnet test output when Testcontainers is in use:
//
// [00:00:00.000] Starting PostgreSQL container...
// [00:00:08.312] Container postgres:16-alpine started on port 54321
// [00:00:08.891] Applying EF Core migrations...
// [00:00:09.201] Migrations applied successfully.
//
// Test Run Starting...
// PASS CreateOrder_WithValidPayload_Returns201AndLocationHeader [31ms]
// PASS GetOrders_WhenNoOrdersExist_ReturnsEmptyArray [22ms]
// PASS DeleteOrder_AsOrderManager_Returns204 [18ms]
//
// [00:00:11.003] Stopping and removing PostgreSQL container...
//
// Total time: 11.3 Seconds (8s = container start, 3s = tests)
// — compare to 1.9s for SQLite. Postgres gives you real SQL behaviour.
// The startup cost is paid once across all tests in the collection.
⚠️
Pro Tip: Combine Testcontainers with Respawn for Zero-Flakiness IsolationAdd the Respawn NuGet package and create a Respawner checkpoint after migrations run. In each test's InitializeAsync, call await _respawner.ResetAsync(connectionString) — it issues DELETE statements in the correct foreign-key order in under 10ms. This gives you per-test isolation without rebuilding the schema, and it works perfectly with a shared Testcontainers Postgres instance.
AspectUnit TestsIntegration Tests (WebApplicationFactory)E2E Tests (Playwright / Selenium)
What's testedA single class or method in isolationFull HTTP pipeline: routing, middleware, DI, DBReal browser against deployed app
Speed< 1ms per test10–100ms per test (after host boot)500ms–5s per test
Database neededNo — mocked or in-memoryTest double (SQLite / Testcontainers)Real deployed database
Catches serialization bugsNoYes — tests real JSON responsesYes
Catches middleware bugsNoYesYes
Catches routing bugsNoYesYes
Catches browser/JS bugsNoNoYes
CI infrastructure neededNoneDocker for Testcontainers (optional)Browser binaries, deployed app
Recommended test ratio~70% of suite~20% of suite~10% of suite
Best forBusiness logic, algorithmsAPI contracts, auth, validation flowCritical user journeys

🎯 Key Takeaways

  • WebApplicationFactory boots your real ASP.NET Core pipeline in-memory — it tests routing, middleware, DI wiring, serialization, and validation together, catching bugs that unit tests structurally cannot.
  • Use IClassFixture or ICollectionFixture to share one factory instance across tests — host boot is the expensive operation; HttpClient creation is cheap. One shared factory drops suite time from minutes to seconds.
  • SQLite ':memory:' is fast but requires a shared SqliteConnection to avoid empty-schema bugs; Testcontainers with a real Postgres or SQL Server container is the production-grade alternative that catches DB-engine-specific behaviour.
  • Your TestAuthHandler is a backdoor — guard it with an environment check, never register it outside the Testing environment, and write a test that verifies it cannot be activated in Production.

⚠ Common Mistakes to Avoid

  • Mistake 1: Creating a new WebApplicationFactory per test method — Symptom: test suite takes 10+ minutes in CI because each test boots a full ASP.NET Core host (1–2 seconds each) — Fix: use IClassFixture to share one factory per test class, or ICollectionFixture to share one factory across all test classes in the solution.
  • Mistake 2: Using SQLite ':memory:' without a shared connection — Symptom: EF Core migrations succeed but the schema is empty when the first test runs; inserts appear to succeed but reads return nothing — Fix: create a single SqliteConnection, open it once, pass it to UseSqlite(existingConnection), and manage its lifetime in the factory. The connection must stay open for the in-memory database to persist.
  • Mistake 3: Not overriding authentication when testing protected endpoints — Symptom: all tests against [Authorize] endpoints return 401 Unauthorized, so developers comment out [Authorize] during testing or skip those tests entirely, leaving auth logic untested — Fix: register a TestAuthHandler in ConfigureWebHost that reads a custom header, and use the AsUser() extension method to set claims per-test. This lets you test both authenticated and authorization-failure scenarios without a real identity provider.

Interview Questions on This Topic

  • QWhat's the difference between WebApplicationFactory and a unit test with mocked dependencies — and how do you decide which to write for a given scenario?
  • QIf two integration tests pass when run individually but one fails when run together, what are the three most likely root causes and how would you diagnose each?
  • QHow would you test an endpoint protected by a JWT bearer scheme in an integration test, and what security risk do you need to guard against in your implementation?

Frequently Asked Questions

What is WebApplicationFactory in ASP.NET Core testing?

WebApplicationFactory is a class in Microsoft.AspNetCore.Mvc.Testing that creates an in-process test server running your real ASP.NET Core application. It boots the full middleware pipeline, DI container, and routing engine without opening a real TCP port, then provides an HttpClient whose transport is wired directly to that in-memory server. This lets you fire real HTTP requests and get real HTTP responses in tests, without deploying your app.

How do I replace Entity Framework's real database in integration tests?

Override ConfigureWebHost in your WebApplicationFactory subclass, call services.RemoveAll>() to remove the production registration, then re-register with services.AddDbContext(o => o.UseSqlite("...")) for fast local tests or o.UseNpgsql(container.GetConnectionString()) for Testcontainers. Call EnsureCreated() or Migrate() inside a manually created service scope before any test runs.

Why do my integration tests pass individually but fail when run together?

This is almost always a shared database state problem. Test A inserts data that Test B reads — or Test A deletes data Test B expected to exist. Fix it by resetting database state in IAsyncLifetime.InitializeAsync before each test, either by removing and re-seeding rows directly via DbContext, or by using the Respawn library to truncate tables in the correct foreign-key order in under 10ms.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousBackground Services in ASP.NET CoreNext →Records in C# 9
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged