ASP.NET Core Integration Tests — SQLite Collation Breaks CI
SQLite default binary collation vs SQL Server case-insensitive causes UNIQUE KEY violations in CI.
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
Integration testing in ASP.NET Core validates your application's full HTTP pipeline — middleware, filters, controllers, database access, and authentication — against a real or simulated infrastructure. The WebApplicationFactory<T> class, part of Microsoft.AspNetCore.Mvc.Testing, bootstraps your application's Program class in-memory, giving you an HttpClient that sends requests through the same middleware stack as production.
This catches issues unit tests miss: serialization mismatches, binding errors, authentication failures, and database interaction bugs. The tradeoff is speed — integration tests are slower than unit tests — and flakiness from shared state, especially when using SQLite as a stand-in for SQL Server.
SQLite is the default in-memory database for many ASP.NET Core integration test suites because it requires no external process and resets quickly. But SQLite's default collation differs from SQL Server's: SQLite uses binary comparison by default, while SQL Server uses case-insensitive, culture-aware collation.
This mismatch causes string comparisons, ORDER BY clauses, and unique constraint violations to behave differently in tests than in production. A query that works locally with SQLite may fail in CI when the test environment enforces stricter collation rules, or worse, pass tests but break in production.
The fix involves either configuring SQLite's collation via PRAGMA statements or switching to a Testcontainers-based SQL Server instance.
Beyond database concerns, integration tests require careful fixture management. WebApplicationFactory caches the application instance by default, meaning shared state like in-memory databases or authentication tokens persists across tests unless you explicitly isolate them.
For authenticated requests, you can inject a test authentication handler via AddAuthentication().AddTestAuth() rather than spinning up a full Identity Provider — this keeps tests fast and deterministic. Production gotchas include parallel test execution corrupting shared database state, flaky tests from leftover data, and performance degradation when tests don't dispose of HttpClient instances.
Advanced patterns let you test middleware behavior, exception filters, and custom error pages by injecting faults into the pipeline, but these require understanding the request lifecycle and how to override services in the factory's ConfigureWebHost callback.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
Why SQLite Collation Breaks Your CI Integration Tests
ASP.NET Core integration tests validate your application's full HTTP pipeline — middleware, controllers, filters, and data access — against a real or simulated database. The core mechanic is the WebApplicationFactory<T> class, which bootstraps your app in-memory, allowing you to override services (like swapping PostgreSQL for SQLite) and send real HTTP requests via HttpClient. This gives you confidence that your components wire together correctly, catching configuration errors and contract mismatches that unit tests miss.
In practice, the test host runs your Program.cs startup logic, so any ConfigureServices or Configure calls execute as they would in production. The key property: you control the service provider. Replace IServiceCollection entries before the host builds, and the test uses your substitutes. This is where SQLite enters — teams often replace EF Core's PostgreSQL provider with SQLite for speed and isolation. But SQLite's default collation is binary, while PostgreSQL uses case-insensitive or locale-aware collation. A query that works in production (WHERE Name = 'john' matching 'John') fails in CI because SQLite treats 'j' and 'J' as different characters.
Use this pattern when you need end-to-end validation of your HTTP API without deploying to a real environment. It's essential for CI pipelines where you cannot run a full database server. The trade-off: you gain fast, deterministic feedback but must account for database-specific behaviors. Ignoring collation differences leads to false positives — tests pass locally but fail in production, or worse, pass in CI but fail in staging because the collation mismatch hides a real bug.
CollationAttribute or raw SQL to set NOCASE.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.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"); } }
CreateClient() before finishing WithWebHostBuilder() silences all later configuration changes.CreateClient(), .Server, or .Services before all overrides are registered — sequence matters.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.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); } }
_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 // 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); // }
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 // 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.
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.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"); // }); // }
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.Why Your Integration Tests Are Slow (And How to Fix It)
Slow integration tests kill developer productivity. You run them, grab coffee, come back, still running. The bottleneck: each test rebuilds the host or resets the database. That's waste.
The fix: share the WebApplicationFactory across tests in a collection fixture. Create the host once, keep it warm. For database resets, use a transaction rollback — wrap each test in a TransactionScope, dispose it without committing. No truncate, no schema rebuild.
Another hidden cost: logging. Production logging is verbose. In tests, you're parsing strings you'll never read. Configure logging to Warning or Error in your test WebApplicationFactory. Or suppress it entirely with AddConsole().SetMinimumLevel(LogLevel.Critical).
Finally, parallel execution. xUnit runs tests in parallel by default. That's good — unless your tests share state. Use collection fixtures to isolate shared resources. Mark tests that mutate global state as [Collection("NonParallel")] to serialize them. Don't fight the framework; use its mechanics.
// io.thecodeforge using System.Transactions; public class IntegrationTestBase : IClassFixture<WebApplicationFactory<Program>> { private readonly WebApplicationFactory<Program> _factory; private TransactionScope? _scope; protected HttpClient Client { get; } public IntegrationTestBase(WebApplicationFactory<Program> factory) { _factory = factory.WithWebHostBuilder(builder => builder.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))); Client = _factory.CreateClient(); } public void BeginTransaction() { _scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); } public void RollbackTransaction() { _scope?.Dispose(); _scope = null; } }
Mocking External HTTP Calls Without Touching Real Endpoints
Your integration test calls an external API. That API goes down during a deployment. Now your test fails — not because your code is wrong, but because you're dependent on someone else's uptime. That's flakiness by design.
The antidote: replace the HttpClient handler in your test WebApplicationFactory. Use DelegatingHandler to intercept requests and return canned responses. No real network calls. No token refresh. No rate limiting.
Here's the pattern: create a TestHttpMessageHandler that maps request URLs to responses. Register it as a singleton in the test's service collection. Wire it into HttpClient via AddHttpClient().AddHttpMessageHandler<TestHandler>().
This gives you deterministic tests. You control every response — success, 404, timeout, malformed payload. You also test your retry logic without hitting a real endpoint. The WHY: integration tests should validate your app's behavior, not your dependencies' behavior. That's what contract tests are for.
// io.thecodeforge public class TestHttpMessageHandler : DelegatingHandler { private readonly Dictionary<string, HttpResponseMessage> _responses = new(); public void Map(string url, HttpResponseMessage response) => _responses[url] = response; protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { if (_responses.TryGetValue(request.RequestUri!.ToString(), out var response)) return Task.FromResult(response); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); } } // Registration in test var handler = new TestHttpMessageHandler(); handler.Map("https://api.example.com/orders/1", new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent("{\"id\":1,\"status\":\"shipped\"}", Encoding.UTF8, "application/json") }); var factory = new WebApplicationFactory<Program>() .WithWebHostBuilder(builder => builder.ConfigureTestServices(services => services.AddSingleton<TestHttpMessageHandler>(handler)));
SQLite vs SQL Server Case Sensitivity Brings Down CI Pipeline
- 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.
dotnet test --filter "ClassName=OrderApi.IntegrationTests.OrderEndpointTests"Inspect test log for 'Hosting startup' entries; one boot per factory instance is normalAdd NuGet: dotnet add package RespawnIn InitializeAsync: var respawner = await Respawner.CreateAsync(connectionString); await respawner.ResetAsync(connectionString);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 HandleAuthenticateAsyncAsUser() before making requests; consider a test middleware that logs all headersIn 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 disposedEdit 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| Aspect | Unit Tests | Integration Tests (WebApplicationFactory) | E2E Tests (Playwright / Selenium) |
|---|---|---|---|
| What's tested | A single class or method in isolation | Full HTTP pipeline: routing, middleware, DI, DB | Real browser against deployed app |
| Speed | < 1ms per test | 10–100ms per test (after host boot) | 500ms–5s per test |
| Database needed | No — mocked or in-memory | Test double (SQLite / Testcontainers) | Real deployed database |
| Catches serialization bugs | No | Yes — tests real JSON responses | Yes |
| Catches middleware bugs | No | Yes | Yes |
| Catches routing bugs | No | Yes | Yes |
| Catches browser/JS bugs | No | No | Yes |
| CI infrastructure needed | None | Docker for Testcontainers (optional) | Browser binaries, deployed app |
| Recommended test ratio | ~70% of suite | ~20% of suite | ~10% of suite |
| Best for | Business logic, algorithms | API contracts, auth, validation flow | Critical user journeys |
| File | Command / Code | Purpose |
|---|---|---|
| CustomWebApplicationFactory.cs | using Microsoft.AspNetCore.Hosting; | How WebApplicationFactory Works Under the Hood |
| OrderEndpointTests.cs | using System.Net; | Test Fixtures, Isolation, and Shared Database State |
| TestAuthHandler.cs | using System.Security.Claims; | Authenticating Requests in Integration Tests Without an Iden |
| TestcontainersOrderApiFactory.cs | using DotNet.Testcontainers.Builders; | Production Gotchas |
| MiddlewareTest.cs | using System.Net; | Advanced Patterns |
| IntegrationTestBase.cs | using System.Transactions; | Why Your Integration Tests Are Slow (And How to Fix It) |
| TestHttpMessageHandler.cs | public class TestHttpMessageHandler : DelegatingHandler | Mocking External HTTP Calls Without Touching Real Endpoints |
Key takeaways
Common mistakes to avoid
5 patternsCreating a new WebApplicationFactory per test method
Using SQLite ':memory:' without a shared connection
Not overriding authentication when testing protected endpoints
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
Shipping TestAuthHandler to production
if (builder.Environment.EnvironmentName != "Testing") throw new InvalidOperationException(...). Add an integration test that verifies Production environment rejects this scheme.Interview Questions on This Topic
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?
If 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?
dotnet test --filter … -- RunConfiguration.TestSessionTimeout=... and add logging of test order. Temporarily set [Collection("...")] to run serially; if failures disappear, it's a parallel state problem.How 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?
TestAuthHandler that reads a header like X-Integration-Test-Auth and creates a ClaimsPrincipal with the desired claims. Register it as the default authentication scheme only in the 'Testing' environment. Use an HttpClient extension method to set the header per test. The security risk: this handler could be accidentally registered in production, allowing anyone to bypass authentication by simply adding the header. Mitigate by checking builder.Environment.EnvironmentName and throwing InvalidOperationException if not 'Testing'. Additionally, write an integration test that verifies production environment (if you can simulate it) rejects the header.Explain the difference between SQLite ':memory:' and Testcontainers for integration test databases. When would you use each?
How do you manage test isolation in a suite where multiple test classes share a single WebApplicationFactory and a single Testcontainers database?
ICollectionFixture to share one factory instance across all test classes. Then implement IAsyncLifetime on each test class and reset the database state in InitializeAsync. The best tool for this is Respawn: create a Respawner checkpoint once (in the factory's InitializeAsync), and in each test class's InitializeAsync call await _respawner.ResetAsync(connectionString). This truncates all tables in correct foreign-key order in under 10ms. This gives you per-test isolation without rebuilding the schema or restarting the Docker container.Frequently Asked Questions
WebApplicationFactory<TEntryPoint> 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.
Override ConfigureWebHost in your WebApplicationFactory subclass, call services.RemoveAll<DbContextOptions<YourDbContext>>() to remove the production registration, then re-register with services.AddDbContext<YourDbContext>(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.
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.
Register a custom TestAuthHandler in ConfigureWebHost that reads claims from a custom header (e.g., X-Integration-Test-Auth). The handler creates a ClaimsPrincipal with the specified claims. Use an HttpClient extension method like .AsUser(email, roles) to set the header before each request. This lets you test [Authorize] policies, [Authorize(Roles=...)], and custom policies without setting up a real JWT issuer.
Use Testcontainers when your application uses database-specific features (collation settings, stored procedures, advanced JSON functions, specific transaction isolation levels) that SQLite handles differently. Testcontainers spins up a real Docker container with your production database engine (Postgres, SQL Server, etc.). The startup cost is paid once per test run; after that, tests run at normal speed. For teams that need maximum confidence before deploying, Testcontainers in CI is worth the extra complexity.
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
That's Testing. Mark it forged?
8 min read · try the examples if you haven't