Mid-level 7 min · March 06, 2026

ASP.NET Core Integration Tests — SQLite Collation Breaks CI

SQLite default binary collation vs SQL Server case-insensitive causes UNIQUE KEY violations in CI.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • WebApplicationFactory boots your real ASP.NET Core pipeline in-memory — no open port, no deployment, full middleware stack
  • Use IClassFixture or ICollectionFixture to share one factory across tests — host boot is expensive, HttpClient creation is cheap
  • Database isolation: SQLite ':memory:' with shared connection for speed, or Testcontainers with real Postgres/SQL Server for production fidelity
  • Test authentication via a custom handler reading a header like X-Integration-Test-Auth — no real identity provider needed
  • One shared factory drops suite runtime by ~10x; cheap per-test state reset using Respawn cuts flakiness
  • Biggest mistake: not guarding TestAuthHandler with environment check — a test backdoor accidentally shipped to production
Plain-English First

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<TEntryPoint> 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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 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 Scope
SQLite'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.
Production Insight
The lazy build model of WebApplicationFactory means calling CreateClient() before finishing WithWebHostBuilder() silences all later configuration changes.
Rule: never call CreateClient(), .Server, or .Services before all overrides are registered — sequence matters.
Cost of a redundant host boot: ~1-2 seconds. In a 200-test suite with individual factories, that's 200-400 seconds wasted.
Key Takeaway
WebApplicationFactory boots your real pipeline in memory, not a mock.
Host boot is expensive — share it. HttpClient creation is cheap — fresh per test.
Order of configuration matters: wire overrides before accessing the built host.
Factory Sharing Strategy
IfFewer than 20 test methods, no DB dependency
UseCreate new factory per test class — simple, no state sharing risk
If20–100 tests, single database (SQLite)
UseUse IClassFixture to share factory per class, reset DB per test via IAsyncLifetime
If100+ tests, multiple test classes, real DB (Testcontainers)
UseUse ICollectionFixture to share one factory and one DB container across all classes; reset with Respawn
IfTests require different DB configurations (e.g., Postgres vs SQL Server)
UseCreate one factory per DB configuration, each with its own collection fixture

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<T> 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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// 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 Tests
You 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.
Production Insight
Shared state between tests is the #1 cause of flaky integration tests. A test that passes in isolation fails when run after another test that left data behind.
Rule: never rely on test execution order. Reset state in InitializeAsync before every test.
Respawn gives you per-test isolation in <10ms without rebuilding the schema — faster than deleting rows manually.
Key Takeaway
Share the factory (host boot), not the database state.
Use IAsyncLifetime to reset state per test — not per class.
Respawn or manual cleanup in InitializeAsync beats transaction rollback through HTTP.

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// 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 Production
Guard 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 Insight
A TestAuthHandler that isn't environment-gated is a backdoor into your production system. Attackers who discover the header can bypass all authentication.
Rule: throw if environment is not Testing. Add a test that verifies production rejects TestAuthHandler.
Performance impact: near zero — the handler just parses a header and creates claims, no crypto overhead.
Key Takeaway
TestAuthHandler lets you test auth without a real identity provider.
Guard it with environment check — never ship to production.
Use HttpClient extension methods for clean per-request claim injection.
Authentication Strategy for Integration Tests
IfEndpoint does not require authentication
UseSkip auth setup; test with anonymous client
IfEndpoint requires authentication only (any user)
UseRegister TestAuthHandler; set minimal claims (sub only). No need for roles.
IfEndpoint requires specific roles/policies
UseAdd role claims in AsUser extension; test both authorized and unauthorized scenarios
IfEndpoint uses custom authorization policies (e.g., ClaimsAuthorizationRequirement)
UseEnsure TestAuthHandler includes all required claim types; test with missing claims to verify denial

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// 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 Isolation
Add 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.
Production Insight
SQLite passes integration tests that fail on real databases. Collation, JSON function, and transaction isolation differences are silent killers.
Rule: test against the real database engine at least in CI. Use Testcontainers to spin up containers without manual setup.
Cost: 8s container startup for first test; negligible thereafter. Worth it to avoid a production schema break.
Key Takeaway
Redundant host boots slow the suite — share factories via collection fixtures.
Flaky tests come from shared state — reset per test with Respawn or IAsyncLifetime.
Test against the real database engine using Testcontainers — SQLite hides DB-specific bugs.
Database Strategy for Integration Tests
IfLocal dev only, no need for production DB fidelity
UseSQLite ':memory:' with shared connection (fast, but collation differences)
IfCI needs to catch DB-specific behavior
UseTestcontainers with Postgres or SQL Server; pay the 8s container start cost once
IfMultiple parallel test classes in CI
UseUse Testcontainers with a single container and Respawn for isolation; avoid SQLite memory
IfTeam uses both SQL Server and Postgres in different environments
UseCreate separate factory configurations for each DB; run critical tests against both

Advanced Patterns: Testing Middleware, Filters, and Error Handling

Integration tests aren't just for endpoints — they're the most reliable way to test middleware behaviour, custom filters, and global error handling. A unit test can verify that your exception filter returns a ProblemDetails response, but only an integration test can verify the full pipeline: middleware adds the correlation header, the exception filter catches the error, the logging middleware records it, and the response reaches the client with the correct status code and content type.

Here's a pattern: create a test that sends a malformed request to a controller that triggers validation errors. The integration test will exercise your model binding, FluentValidation (if used), the ProblemDetails middleware, and the response serialization — all in one round trip. That's confidence no unit test can give you.

Another pattern: test custom middleware that adds a request ID header. Spin up the factory, make a request, and assert on the response headers. If your middleware depends on scoped services (like a correlation ID provider), the integration test verifies the DI wiring is correct and the middleware is registered in the correct order.

For global exception handling, create a test that hits an endpoint guaranteed to throw an unhandled exception (e.g., by passing invalid parameters to a service that throws ArgumentException). Assert that the response is 500 with the expected ProblemDetails structure, and that the error is logged (by asserting on a mocked ILogger or inspecting logs in the output). This catches issues where your exception handler misconfigures the response or fails to serialize the error.

MiddlewareTest.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// MiddlewareTest.cs
// Tests custom middleware for correlation ID and global error handling.

using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace OrderApi.IntegrationTests;

public class MiddlewareTest : IClassFixture<OrderApiFactory>
{
    private readonly OrderApiFactory _factory;

    public MiddlewareTest(OrderApiFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task Request_ShouldReceiveCorrelationIdHeader()
    {
        // Arrange
        // The middleware adds X-Correlation-Id to every response.
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync("/api/orders");

        // Assert
        Assert.True(response.Headers.Contains("X-Correlation-Id"),
            "Middleware should add a correlation ID header to every response.");
        var correlationId = response.Headers.GetValues("X-Correlation-Id").First();
        Assert.False(string.IsNullOrWhiteSpace(correlationId),
            "Correlation ID should not be empty.");
    }

    [Fact]
    public async Task GlobalExceptionHandler_ReturnsProblemDetails_OnUnhandledException()
    {
        // Arrange
        // Assume we have an endpoint /api/test/throw that throws an exception.
        // This could be a dedicated test endpoint (conditionally registered in Testing env).
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync("/api/test/throw");

        // Assert
        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
        Assert.Equal("application/problem+json",
            response.Content.Headers.ContentType?.MediaType);

        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        Assert.NotNull(problem);
        Assert.Equal(500, problem.Status);
        Assert.Equal("An error occurred while processing your request.", problem.Title);
        // Note: In development, the detail may include the stack trace; in production it should be generic.
        // Our test environment matches production settings.
    }
}

// To support this test, register a test-only endpoint in Program.cs (conditional on env):
//
// if (app.Environment.IsEnvironment("Testing"))
// {
//     app.MapGet("/api/test/throw", () =>
//     {
//         throw new InvalidOperationException("Test exception");
//     });
// }
Output
// dotnet test output:
//
// PASS Request_ShouldReceiveCorrelationIdHeader [15ms]
// PASS GlobalExceptionHandler_ReturnsProblemDetails_OnUnhandledException [21ms]
Pro Tip: Register Test-Only Endpoints for Exception Testing
In Program.cs, check for app.Environment.IsEnvironment("Testing") and register minimal API endpoints that throw specific exceptions. This lets you test the global exception handler's response without relying on chance exceptions from real endpoints. Remove these in production by not registering them — the check ensures they only exist in tests.
Production Insight
Global exception handlers often miss edge cases like JSON serialization errors inside the handler itself. An integration test that throws an exception and asserts on the response catches this.
Rule: always test the full error handling pipeline, not just the filter unit in isolation.
Performance impact: negligible; test takes ~20ms.
Key Takeaway
Integration tests are the best tool to verify middleware order, correlation headers, and global error responses.
Register test-only endpoints for exception testing to avoid flakiness.
Assert on response headers and content types that unit tests cannot cover.
● Production incidentPOST-MORTEMseverity: high

SQLite vs SQL Server Case Sensitivity Brings Down CI Pipeline

Symptom
All tests pass on developer machines using SQLite in-memory. CI tests using Testcontainers with SQL Server fail intermittently with 'Violation of UNIQUE KEY constraint' on a column that should be unique per case-insensitive collation.
Assumption
The team assumed SQLite's collation behaviour matches SQL Server's default case-insensitive collation for string columns.
Root cause
SQLite uses binary collation by default (except for NOCASE), while SQL Server uses a case-insensitive collation (SQL_Latin1_General_CP1_CI_AS). This meant that SQLite allowed duplicate keys differing only by case, while SQL Server did not. The test data generation was creating entries like 'abc' and 'ABC' — acceptable in SQLite but violating the constraint on SQL Server.
Fix
Changed the SQLite collation for the relevant columns to COLLATE NOCASE when using UseSqlite, and added a CI integration test that explicitly uses Testcontainers SQL Server to catch such differences early. Also added a collation-specific assertion in the SQLite-based tests to ensure case insensitivity.
Key lesson
  • Never assume SQLite behaves identically to your production database engine — collation, transaction isolation, and JSON functions differ.
  • Use Testcontainers with the real database engine as part of CI to catch engine-specific issues before deployment.
  • Add a dedicated integration test that runs against the production database engine (even if slower) to validate engine-specific behaviour.
Production debug guideCommon symptoms and actions for failing or flaky integration tests5 entries
Symptom · 01
Test suite runs for 10+ minutes in CI
Fix
Check for duplicate host boots. Ensure tests share one WebApplicationFactory via ICollectionFixture. Each host boot costs 1–2 seconds — 100 test classes with individual factories add minutes.
Symptom · 02
Tests pass individually but fail when run together
Fix
This is a shared state problem. Add IAsyncLifetime with DB cleanup in InitializeAsync. Use Respawn to reset tables in correct FK order. Log test order to detect ordering dependency.
Symptom · 03
Auth-protected endpoints always return 401
Fix
Verify the integration test sets the auth header (e.g., X-Integration-Test-Auth) before each request. Check the custom auth handler is registered only for 'Testing' environment and not overridden by production middleware.
Symptom · 04
In-memory SQLite schema is empty on first request
Fix
SQLite ':memory:' is connection-scoped. Use a shared SqliteConnection opened once, or use 'Cache=Shared' mode. Typically need to create the connection and keep it open for the factory's lifetime.
Symptom · 05
Testcontainers container fails to start in CI
Fix
Check Docker availability in CI runner. For GitHub Actions, use 'ubuntu-latest' which includes Docker. Ensure Testcontainers has permission to pull images (may need authentication for private registries).
★ Quick Debug Cheat Sheet for Integration TestsFive-second responses to the most common integration test failures in ASP.NET Core
Host build takes too long
Immediate action
Verify only one WebApplicationFactory instance per test collection
Commands
dotnet test --filter "ClassName=OrderApi.IntegrationTests.OrderEndpointTests"
Inspect test log for 'Hosting startup' entries; one boot per factory instance is normal
Fix now
Refactor to use ICollectionFixture<OrderApiFactory> and decorate test classes with [Collection("...")]
Test fails due to stale data+
Immediate action
Add Respawn checkpoint after factory creation, reset before each test
Commands
Add NuGet: dotnet add package Respawn
In InitializeAsync: var respawner = await Respawner.CreateAsync(connectionString); await respawner.ResetAsync(connectionString);
Fix now
Replace manual cleanup code with Respawn — it handles FK order and takes <10ms
Auth header not sent on request+
Immediate action
Check HttpClient.DefaultRequestHeaders before request
Commands
Add a test helper: client.DefaultRequestHeaders.Add("X-Integration-Test-Auth", "sub=test@test.com,role=Admin");
Verify auth handler is being hit: add breakpoint in HandleAuthenticateAsync
Fix now
Ensure all tests call .AsUser() before making requests; consider a test middleware that logs all headers
SQLite in-memory DB drops tables between tests+
Immediate action
Change connection string to use 'Data Source=:memory:;Cache=Shared' and manage connection manually
Commands
In factory: var connection = new SqliteConnection("Data Source=IntegrationTestDb;Mode=Memory;Cache=Shared"); connection.Open(); services.AddDbContext<OrderDbContext>(o => o.UseSqlite(connection));
Ensure connection is not disposed until factory is disposed
Fix now
Create a singleton SqliteConnection in the factory and pass it via ConfigureWebHost
Parallel test execution causes random failures+
Immediate action
Disable parallelization temporarily to confirm it's the cause
Commands
Edit test project .csproj: <PropertyGroup><XunitParallelizeAssemblies>false</XunitParallelizeAssemblies></PropertyGroup>
If parallel is required, use collection fixture with a single database (SQLite shared connection or real DB via Testcontainers) and Respawn for isolation
Fix now
Apply xUnit collection attribute to enforce sequential execution within collection; use Testcontainers to allow parallel test classes against same database
Integration Testing vs Unit Testing vs E2E Testing
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

1
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.
2
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.
3
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.
4
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.
5
Test middleware and error handling with integration tests
unit tests cannot verify response headers, content types, or the full pipeline flow.
6
Reset database state between tests using Respawn or IAsyncLifetime
never rely on test execution order. Flaky tests are state dependencies in disguise.

Common mistakes to avoid

5 patterns
×

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). Developer productivity plummets.
Fix
Use IClassFixture<TFactory> to share one factory per test class, or ICollectionFixture<TFactory> to share one factory across all test classes in the solution. Host boot is the expensive operation — do it once.
×

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. Each request sees a fresh empty database.
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.
×

Not overriding authentication when testing protected endpoints

Symptom
All tests against [Authorize] endpoints return 401 Unauthorized. 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.
×

Not resetting database state between tests in a shared factory

Symptom
Tests pass when run individually but fail when run together. Intermittent failures in CI that cannot be reproduced locally due to different test ordering.
Fix
Implement IAsyncLifetime on the test class and reset the database in InitializeAsync. Use Respawn library to truncate tables in correct foreign-key order (under 10ms). Never rely on test execution order.
×

Shipping TestAuthHandler to production

Symptom
A critical security vulnerability: any request with the X-Integration-Test-Auth header can bypass authentication. This can be exploited by external attackers if the header reaches production.
Fix
Guard the TestAuthHandler registration with a check: if (builder.Environment.EnvironmentName != "Testing") throw new InvalidOperationException(...). Add an integration test that verifies Production environment rejects this scheme.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between WebApplicationFactory and a unit test with...
Q02SENIOR
If two integration tests pass when run individually but one fails when r...
Q03SENIOR
How would you test an endpoint protected by a JWT bearer scheme in an in...
Q04SENIOR
Explain the difference between SQLite ':memory:' and Testcontainers for ...
Q05SENIOR
How do you manage test isolation in a suite where multiple test classes ...
Q01 of 05SENIOR

What's the difference between WebApplicationFactory and a unit test with mocked dependencies — and how do you decide which to write for a given scenario?

ANSWER
WebApplicationFactory boots the full ASP.NET Core pipeline (middleware, routing, DI, serialization) in-memory. A unit test with mocks isolates a single class. Use integration tests for scenarios involving the HTTP boundary — auth, middleware, validation, serialization, database interaction. Use unit tests for pure business logic, complex algorithms, and deterministic service layer methods. A good heuristic: if you need to assert on HTTP status codes, response headers, or content types, use integration tests. If you need 100s of cheap tests for algorithmic correctness, use unit tests.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is WebApplicationFactory in ASP.NET Core testing?
02
How do I replace Entity Framework's real database in integration tests?
03
Why do my integration tests pass individually but fail when run together?
04
How do I test authorization policies in integration tests without a real identity provider?
05
When should I use Testcontainers instead of SQLite for integration test databases?
🔥

That's Testing. Mark it forged?

7 min read · try the examples if you haven't

Previous
Mocking with Moq in C#
3 / 5 · Testing
Next
BDD with SpecFlow in C#