An integration test exercises a boundary between components using enough real implementation to expose its contract. The boundary may be an ASP.NET Core pipeline, a database provider, a message broker, or an HTTP adapter. A unit test can prove a decision in isolation. An integration test proves that configuration, serialization, protocols, and persistence agree at runtime.

This layer catches wrong SQL, missing dependency registrations, incompatible payloads, and middleware-order failures. Its cost comes from startup, I/O, data lifecycle, and external process state. Flakiness is not inherent to integration testing: it is usually evidence that time, ownership, cleanup, or readiness has not been controlled.

ASP.NET Core Integration Testing with WebApplicationFactory

ASP.NET Core provides Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>, which hosts the application through TestServer and creates an HttpClient for the real request pipeline:

public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
 
    public OrdersApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureServices(services =>
                {
                    // Replace real DB with in-memory EF Core
                    services.RemoveAll<DbContextOptions<AppDbContext>>();
                    services.AddDbContext<AppDbContext>(opts =>
                        opts.UseInMemoryDatabase("TestDb"));
                });
            })
            .CreateClient();
    }
 
    [Fact]
    public async Task PostOrder_Returns201_AndPersistsOrder()
    {
        var payload = new { ProductId = "p1", Quantity = 2 };
        var response = await _client.PostAsJsonAsync("/orders", payload);
 
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
 
        // Verify the order was actually persisted
        var getResponse = await _client.GetAsync(response.Headers.Location);
        Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
    }
}

The request passes through routing, middleware, binding, application services, and the configured persistence substitute without opening a network port. It is not automatically a full production-fidelity test: the example replaces the relational provider with EF Core In-Memory, so it proves pipeline behavior but not SQL translation, relational constraints, transactions, or provider-specific concurrency.

When to Use Real Infrastructure Vs In-Memory Substitutes

DependencyTest approachReason
SQL databaseDisposable instance of the production engine. SQLite only for provider-independent relational behaviorEF Core In-Memory does not model SQL translation, transactions, or relational constraints. SQLite still differs from the production provider
HTTP external serviceWireMock.Net or HttpMessageHandler fakeAvoid real network calls. Test error scenarios
Message queueProtocol-compatible broker for delivery semantics. Fake for application-only decisionsA fake cannot prove acknowledgement, redelivery, ordering, or serialization behavior
File systemTemporary directory for filesystem semantics. Narrow fake when I/O is outside the riskReal temporary storage exposes paths, permissions, and atomic-move behavior without shared fixtures
Clock/timeFakeTimeProvider (.NET 8+)Makes time-sensitive tests deterministic

Testcontainers manages disposable containerized dependencies such as PostgreSQL, Redis, or RabbitMQ. The fixture can be scoped to a test class, collection, or run. Isolation and startup cost determine the right lifetime.

public class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres =
        new PostgreSqlBuilder().WithImage("postgres:16").Build();
 
    public string ConnectionString => _postgres.GetConnectionString();
 
    public Task InitializeAsync() => _postgres.StartAsync();
    public Task DisposeAsync()    => _postgres.DisposeAsync().AsTask();
}

WARNING

EF Core In-Memory is not a relational provider. It does not reproduce SQL translation, relational constraints, transaction behavior, or provider-specific features. SQLite in-memory provides relational behavior but still differs in SQL dialect, type handling, and concurrency. Use the production database engine when those semantics are the reason for the test. Reserve EF Core In-Memory for tests whose persistence behavior is deliberately outside scope.

Where Integration Tests Sit: the Test Pyramid

The test pyramid is a cost heuristic rather than a quota. Narrow tests usually run faster and identify one failure. Integration tests spend more setup to prove a real boundary. End-to-end tests cover the assembled path with the widest diagnostic surface. Infrastructure-heavy services can rationally have many integration tests because the important behavior lives in SQL, serialization, or protocols.

An inverted suite relies on broad UI or deployed-system tests for behavior that a lower layer could expose. The repair is to move each assertion to the cheapest faithful boundary, not to meet a universal unit/integration/end-to-end ratio.

Pitfalls

Shared Database State Between Tests

When tests share a database without resetting owned state, test A can insert a row that changes test B’s result. The tests pass alone and fail together.

Give every test a known data baseline. A transaction rollback works only when the application and assertion share that transaction and the behavior under test does not commit independently. Otherwise reset the schema, truncate owned tables, create an isolated database, or restore a template. A fresh container per class is one option, not a requirement.

Testing Too Much in One Integration Test

Expensive setup can tempt one integration test to exercise many endpoints and services. Its failure then identifies a journey but not the broken boundary.

Assert one coherent behavior or invariant per test. IClassFixture<T> can share expensive infrastructure, while each test still owns its request data and expected result.

Slow CI from Unparallelized Integration Tests

Class or collection boundaries can serialize a suite, and shared database state can prevent safe parallelism even when the runner permits it. Two hundred such tests can turn a fast boundary check into a 15-minute CI lane.

Partition data or databases so tests can run independently, and reuse expensive processes when isolation can be achieved below the process level. Use an xUnit collection when fixtures or external state truly require serialization, then measure that lane separately.

Tradeoffs

ApproachStrengthsWeaknessesWhen to use
WebApplicationFactory + EF In-MemoryFast and simpleNot relational. Can diverge in queries and transactionsPipeline tests where database semantics are explicitly outside scope
WebApplicationFactory + TestcontainersReal DB behavior, catches SQL bugsRequires Docker in CI, slower startupRepository layer, complex queries, migration tests
Full E2E (real deployed service)Tests the actual production environmentSlowest, most brittle, hard to control stateSmoke tests post-deploy, critical user journeys

Choose fidelity from the failure being tested. WebApplicationFactory with a disposable production database is appropriate when an endpoint’s contract includes SQL behavior. A lighter substitute is valid when the database is intentionally outside scope and another test proves that boundary. Deployed end-to-end tests cover a few critical journeys and operational assumptions that an in-process host cannot reproduce.

References