Integration Testing

Integration Testing

An integration test verifies that multiple components work correctly together — including real infrastructure like databases, HTTP clients, message queues, and configuration. Where unit tests replace dependencies with fakes to isolate logic, integration tests use real (or realistic test-instance) dependencies to validate wiring, configuration, and cross-boundary behavior.

Integration tests catch a class of bugs that unit tests cannot: wrong SQL queries, misconfigured DI registrations, broken serialization contracts, and middleware ordering issues. The tradeoff is speed and flakiness — integration tests are slower and more sensitive to environment state.

The standard approach in .NET is Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<T>, which boots the real application in-process with a test HTTP client:

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);
    }
}

This tests the full request pipeline: routing, middleware, controller, service, repository, and database — all in one test without spinning up a real server.

When to Use Real Infrastructure vs In-Memory Substitutes

Dependency Test approach Reason
SQL database Testcontainers (real Docker DB) or EF In-Memory In-memory misses SQL-specific behavior (constraints, transactions, indexes)
HTTP external service WireMock.Net or HttpMessageHandler fake Avoid real network calls; test error scenarios
Message queue In-memory fake or Testcontainers Real queues add latency and ordering complexity
File system System.IO.Abstractions fake Avoids path/permission issues in CI
Clock/time FakeTimeProvider (.NET 8+) Makes time-sensitive tests deterministic

Testcontainers spins up a real Docker container (Postgres, Redis, RabbitMQ) per test run, giving you real behavior without a shared environment:

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();
}

Pitfalls

Shared Database State Between Tests

What goes wrong: test A inserts a row; test B reads it and gets unexpected results. Tests pass in isolation but fail when run together.

Why it happens: tests share a database without cleaning up between runs.

Mitigation: wrap each test in a transaction and roll back at the end, or recreate the database schema before each test class. With Testcontainers, use a fresh container per test class.

Testing Too Much in One Integration Test

What goes wrong: a single test exercises 10 endpoints and 5 services. When it fails, the failure message doesn't tell you which component broke.

Why it happens: integration tests feel expensive to set up, so developers pack multiple assertions into one test.

Mitigation: one behavior per test. The setup cost is paid by IClassFixture<T> — share the expensive infrastructure, not the test logic.

Slow CI from Unparallelized Integration Tests

What goes wrong: 200 integration tests run sequentially and take 15 minutes in CI.

Why it happens: xUnit runs test classes in parallel by default, but tests in the same class run sequentially. Tests that share a database via [Collection] are serialized.

Mitigation: use separate databases per test class (Testcontainers makes this cheap). Avoid [Collection] unless tests genuinely share state that cannot be isolated.

Tradeoffs

Approach Strengths Weaknesses When to use
WebApplicationFactory + EF In-Memory Fast, no Docker dependency Misses SQL-specific behavior (constraints, transactions) API contract tests, middleware tests, DI wiring
WebApplicationFactory + Testcontainers Real DB behavior, catches SQL bugs Requires Docker in CI, slower startup Repository layer, complex queries, migration tests
Full E2E (real deployed service) Tests the actual production environment Slowest, most brittle, hard to control state Smoke tests post-deploy, critical user journeys

Decision rule: start with WebApplicationFactory + EF In-Memory for API-level tests. Add Testcontainers for the repository layer when you need real SQL behavior (constraints, RETURNING, CTEs). Reserve full E2E tests for post-deploy smoke checks only.

Questions

References


Whats next