DEV Community

Cover image for Integration Tests: Verifying Components Work Together
Rhuturaj Takle
Rhuturaj Takle

Posted on

Integration Tests: Verifying Components Work Together

Integration Tests: Verifying Components Work Together

A practical guide to integration testing in .NET — testing an API together with its real database and other real dependencies — covering WebApplicationFactory, Testcontainers, test data management, testing message-based and external integrations, and where integration tests sit in the testing pyramid relative to the unit tests covered in this series' xUnit guide.


Table of Contents

  1. Introduction
  2. Where Integration Tests Sit in the Testing Pyramid
  3. WebApplicationFactory: Testing the Full ASP.NET Core Pipeline
  4. Testcontainers: Real Dependencies, Not Fakes
  5. A Complete Worked Example: API + Real Database
  6. Test Data Management and Isolation
  7. Overriding Configuration and Dependencies for Tests
  8. Testing Authentication and Authorization
  9. Testing Message-Based Integrations
  10. Testing External HTTP Dependencies
  11. Speed and CI Considerations
  12. Integration Tests vs. Contract Tests vs. End-to-End Tests
  13. Common Pitfalls
  14. Quick Reference Table
  15. Conclusion

Introduction

An integration test verifies that multiple real components genuinely work together — an API endpoint together with its actual database, its actual middleware pipeline, and (where relevant) its actual message broker or external dependencies — rather than testing a single class in isolation with every dependency mocked away, as covered in this series' xUnit guide. This guide builds directly on that foundation: the same xUnit [Fact]/[Theory] mechanics apply, but the scope of what's under test — and, critically, what's real versus faked — is fundamentally different.

public class OrdersApiIntegrationTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;
    public OrdersApiIntegrationTests(CustomWebApplicationFactory factory) => _client = factory.CreateClient();

    [Fact]
    public async Task PlaceOrder_PersistsToRealDatabase_AndReturnsCreated()
    {
        var response = await _client.PostAsJsonAsync("/orders", new { customerId = 42, items = new[] { new { productId = 1, quantity = 2 } } });

        response.EnsureSuccessStatusCode();
        var order = await response.Content.ReadFromJsonAsync<OrderDto>();
        Assert.NotNull(order);
        Assert.True(order!.Id > 0); // genuinely persisted — a real database assigned this ID
    }
}
Enter fullscreen mode Exit fullscreen mode

This test exercises the real HTTP pipeline, real model binding, the real handler, and a real (if test-scoped) database — verifying not just "does the handler's logic work in isolation" but "does the whole assembled system actually work together," which is precisely the class of bug unit tests, by design, cannot catch.


1. Where Integration Tests Sit in the Testing Pyramid

The layer above unit tests, below end-to-end tests

Unit tests (xUnit guide):        fast, isolated, mocked dependencies — the base of the pyramid
Integration tests (this guide):   real dependencies, one service's own boundary — the middle layer
End-to-end tests:                  the full, multi-service deployed system — the top, deliberately few
Enter fullscreen mode Exit fullscreen mode

As covered in this series' CI/CD Pipelines guide's testing pyramid discussion, integration tests occupy the middle layer — slower and more expensive than unit tests (spinning up a real database or, per Section 3, an actual containerized dependency takes real time), but considerably faster, cheaper, and less flaky than full end-to-end tests that require an entire deployed, multi-service environment to be running.

What integration tests catch that unit tests structurally cannot

// A unit test with a mocked repository can verify the HANDLER'S LOGIC is correct...
mockRepository.Setup(r => r.GetByIdAsync(It.IsAny<OrderId>())).ReturnsAsync(someOrder);

// ...but it can never catch a genuinely real integration bug like:
// - a missing EF Core migration
// - an incorrect column mapping or index
// - a SQL query that times out against a realistic data volume
// - a serialization mismatch between what the API actually returns and what a real HTTP client receives
Enter fullscreen mode Exit fullscreen mode

Because a unit test's dependencies are mocked (per this series' xUnit guide), it verifies the logic under test is correct given its assumptions about how those dependencies behave — it cannot verify those assumptions are actually true. A mocked IOrderRepository that "returns an order" says nothing about whether the real EF Core mapping, the real SQL Server schema, or the real connection string configuration actually work together correctly; only an integration test, exercising the real database, can catch that class of bug.

Why integration tests are not a substitute for unit tests, and vice versa

Neither layer replaces the other — unit tests give fast, precise, per-scenario feedback on business logic correctness (a wrong discount calculation, an incorrectly enforced aggregate invariant, per this series' DDD guide), while integration tests give confidence that the pieces genuinely wired together — the API, the database, the middleware pipeline — actually function as an assembled whole. A test suite relying only on one layer has a real, structural blind spot the other layer exists specifically to cover.


2. WebApplicationFactory: Testing the Full ASP.NET Core Pipeline

What it actually provides

public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    public OrdersApiTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();

    [Fact]
    public async Task GetOrder_ReturnsNotFound_ForNonexistentOrder()
    {
        var response = await _client.GetAsync("/orders/99999");
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}
Enter fullscreen mode Exit fullscreen mode

As introduced in this series' ASP.NET Core and xUnit guides, WebApplicationFactory<Program> spins up an in-memory test server running your application's real Program.cs — the actual middleware pipeline (authentication, authorization, exception handling, routing), the actual dependency injection container configuration, and the actual endpoint/controller code all run exactly as they would in production, with requests sent via an in-process HttpClient rather than over a real network socket.

Why "in-memory, no real network hop" still counts as a genuine integration test

Even without an actual TCP connection, this exercises real integration between real components — the real middleware pipeline genuinely runs, real model binding genuinely happens, and (once Section 3's Testcontainers-based database is wired in) a real database round trip genuinely occurs. The "in-memory" part refers only to skipping the network transport layer itself, which is rarely the part of the stack integration tests are actually trying to verify.

IClassFixture<WebApplicationFactory<Program>>: sharing the test server across many tests

As covered in this series' xUnit guide, wrapping WebApplicationFactory<Program> in IClassFixture<T> means the (comparatively expensive) test server startup happens once per test class, not once per individual test — directly applying the class-fixture pattern from that guide to this specific, common integration-testing scenario.

Program needs to be accessible to the test project

// In the API project's Program.cs, add this line if using top-level statements (the modern default):
public partial class Program { }
Enter fullscreen mode Exit fullscreen mode

Because WebApplicationFactory<Program> needs to reference the application's Program class, and top-level statement Program.cs files (the default in modern .NET, per this series' C# guide) generate an implicit, internal Program class by default, a one-line public partial class Program { } addition is often needed to make it accessible from a separate test project — a small, easy-to-forget but well-documented setup step.


3. Testcontainers: Real Dependencies, Not Fakes

The problem with EF Core's in-memory provider for integration testing

As flagged directly in this series' EF Core guide's testing section, EF Core's in-memory provider is not a real relational database — it doesn't enforce real foreign key constraints the same way, doesn't genuinely translate LINQ to SQL, and can behave differently around transactions and concurrency. A test suite that passes against the in-memory provider is not a reliable guarantee the same code works correctly against SQL Server or PostgreSQL in production.

Testcontainers: spinning up a genuinely real dependency in a Docker container, for the duration of a test run

public class CustomWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithImage("postgres:17")
        .WithDatabase("testdb")
        .WithUsername("test")
        .WithPassword("test")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(options => options.UseNpgsql(_dbContainer.GetConnectionString()));
        });
    }

    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync(); // genuinely starts a real PostgreSQL container, per this series' Docker guide
        using var scope = Services.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await dbContext.Database.MigrateAsync(); // runs REAL migrations, per this series' Database Migrations guide
    }

    public new async Task DisposeAsync() => await _dbContainer.StopAsync();
}
Enter fullscreen mode Exit fullscreen mode

Testcontainers (introduced briefly in this series' EF Core and xUnit guides) programmatically starts a real, containerized instance of an actual dependency — here, genuine PostgreSQL, per this series' Docker guide — for the duration of a test run, then tears it down afterward. This is the current, widely-adopted best practice for integration testing against a database: genuinely real behavior (real constraint enforcement, real query translation, real migration execution), at the cost of requiring Docker available in the test/CI environment and real container startup time (Section 10 covers managing this cost).

Testcontainers beyond databases

private readonly RedisContainer _redisContainer = new RedisBuilder().Build();
private readonly RabbitMqContainer _rabbitContainer = new RabbitMqBuilder().Build();
Enter fullscreen mode Exit fullscreen mode

Testcontainers supports pre-built container modules for essentially every dependency covered elsewhere in this series — Redis (per the Redis guide), RabbitMQ (per the RabbitMQ guide), Kafka (per the Kafka guide) — meaning an integration test suite can exercise genuinely real versions of every infrastructure dependency an application actually has, not just its database.

Why this replaced the older "shared test database" approach

Before Testcontainers became standard practice, a common (and genuinely problematic) pattern was maintaining one shared, persistent test database that every developer's and CI run's tests ran against — a source of real, recurring pain: tests interfering with each other's data, an environment drifting out of sync with what migrations actually expect, and CI runs failing due to another concurrent run's leftover state. A fresh, isolated, disposable container per test run eliminates this entire class of problem structurally.


4. A Complete Worked Example: API + Real Database

Putting Sections 2 and 3 together

// CustomWebApplicationFactory.cs — as shown in Section 3

// OrdersApiIntegrationTests.cs
public class OrdersApiIntegrationTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;
    private readonly CustomWebApplicationFactory _factory;

    public OrdersApiIntegrationTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task PlaceOrder_PersistsOrderToRealDatabase()
    {
        var request = new { customerId = 42, items = new[] { new { productId = 1, unitPrice = 29.99m, quantity = 2 } } };

        var response = await _client.PostAsJsonAsync("/orders", request);

        response.EnsureSuccessStatusCode();
        var created = await response.Content.ReadFromJsonAsync<OrderDto>();

        // Verify directly against the database too — not just trusting the API's own response
        using var scope = _factory.Services.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var persistedOrder = await dbContext.Orders.Include(o => o.LineItems).FirstOrDefaultAsync(o => o.Id == created!.Id);

        Assert.NotNull(persistedOrder);
        Assert.Equal(42, persistedOrder!.CustomerId);
        Assert.Single(persistedOrder.LineItems);
    }

    [Fact]
    public async Task GetOrder_ReturnsNotFound_ForNonexistentOrder()
    {
        var response = await _client.GetAsync("/orders/99999");
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}
Enter fullscreen mode Exit fullscreen mode

The first test demonstrates a genuinely valuable integration-testing technique: verifying both the API's HTTP response and the database's actual resulting state directly — confirming not just "the API said it succeeded" but "the API's success claim is actually backed by real, correctly-persisted data," which is precisely the class of bug (a handler that returns 201 Created but has a subtle bug in its actual persistence logic) that a purely response-focused assertion could miss entirely.


5. Test Data Management and Isolation

The problem: tests sharing a database instance need to not interfere with each other

Even with Testcontainers providing a fresh container per test run (Section 3), individual tests within that run typically still share the same database instance and schema — without deliberate isolation, one test's data can affect another's results, recreating exactly the cross-test-interference problem this series' xUnit guide warned about for class fixtures generally.

Respawn: resetting the database to a clean state between tests

public class DatabaseFixture : IAsyncLifetime
{
    private Respawner _respawner = null!;
    private readonly string _connectionString;

    public async Task InitializeAsync()
    {
        // after initial setup/migration...
        _respawner = await Respawner.CreateAsync(_connectionString, new RespawnerOptions
        {
            TablesToIgnore = new[] { new Respawn.Graph.Table("__EFMigrationsHistory") }
        });
    }

    public async Task ResetAsync() => await _respawner.ResetAsync(_connectionString);
}
Enter fullscreen mode Exit fullscreen mode
public class OrdersApiIntegrationTests : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
    public async Task InitializeAsync() => await _factory.DatabaseFixture.ResetAsync(); // clean slate before EVERY test
    public Task DisposeAsync() => Task.CompletedTask;
}
Enter fullscreen mode Exit fullscreen mode

Respawn is a widely used library specifically for resetting a database to a known-clean state between tests — faster than tearing down and recreating the entire database/container for every single test, while still guaranteeing each test starts from a predictable, empty (or seeded, below) baseline rather than accumulating data from every previous test in the run.

Seeding known test data

public async Task InitializeAsync()
{
    await _respawner.ResetAsync(_connectionString);
    using var scope = _factory.Services.CreateScope();
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    dbContext.Customers.Add(new Customer(id: 42, name: "Ada Lovelace"));
    await dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

For tests that need specific, known baseline data to exist (a customer with a specific ID that a test's order-placement request references), seeding it explicitly as part of test setup — after a reset, before the test itself runs — keeps that data both intentional and predictable, rather than a test depending on data that happens to already exist from some other source.

Using unique, generated identifiers instead of resetting, as a lighter-weight alternative

[Fact]
public async Task PlaceOrder_Succeeds()
{
    var uniqueCustomerId = Guid.NewGuid(); // or an auto-incrementing test-specific counter
    // ... a test that creates its own unique data, rather than relying on a clean-slate reset
}
Enter fullscreen mode Exit fullscreen mode

For simpler scenarios, having each test create its own, uniquely-identified data (rather than resetting the whole database between every test) can be a lighter-weight alternative that avoids Respawn's reset overhead entirely — the right choice depends on how much a given test suite's tests genuinely need a completely clean baseline versus how well they tolerate coexisting, non-interfering data from other tests run in the same shared database instance.


6. Overriding Configuration and Dependencies for Tests

Swapping specific services for test doubles, while keeping everything else real

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureServices(services =>
    {
        // Replace the REAL database with a Testcontainers-backed one (per Section 3)
        services.RemoveAll<DbContextOptions<AppDbContext>>();
        services.AddDbContext<AppDbContext>(options => options.UseNpgsql(_dbContainer.GetConnectionString()));

        // But replace a genuinely external, costly-to-call third-party service with a test double
        services.RemoveAll<IPaymentGateway>();
        services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
    });
}
Enter fullscreen mode Exit fullscreen mode

Integration tests don't require every dependency to be genuinely real — the deliberate, common pattern is keeping the dependencies you're actually trying to verify integration with (typically your own database, per Section 3) real, while substituting a lightweight fake or a mock for dependencies that are genuinely external, slow, costly, or outside your control (a real third-party payment gateway, an external email provider) — a distinction this guide's Section 9 covers in more depth for HTTP-based external dependencies specifically.

Overriding configuration values for the test environment

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureAppConfiguration((context, config) =>
    {
        config.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["FeatureManagement:NewCheckoutFlow"] = "true" // per this series' Feature Flags guide
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

As covered in this series' ASP.NET Core guide's layered configuration system, ConfigureAppConfiguration lets an integration test override specific configuration values — useful for deterministically testing both sides of a feature flag (per this series' Feature Flags guide's testing recommendation), or for pointing at test-specific external service URLs.


7. Testing Authentication and Authorization

Bypassing real authentication for tests that don't need to verify it

public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user-42"), new Claim(ClaimTypes.Role, "Admin") };
        var identity = new ClaimsIdentity(claims, "TestScheme");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "TestScheme");
        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}
Enter fullscreen mode Exit fullscreen mode
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureServices(services =>
    {
        services.AddAuthentication("TestScheme")
            .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("TestScheme", options => { });
    });
}
Enter fullscreen mode Exit fullscreen mode

For integration tests focused on business logic rather than the authentication mechanism itself (connecting to this series' JWT Validation guide's observation that authorization logic can and should be tested independently of token validation), substituting a test authentication handler that always succeeds with a known, fixed identity avoids the overhead and complexity of genuinely minting and validating real JWTs for every single integration test.

Testing authorization rules genuinely, with different simulated identities

[Theory]
[InlineData("Admin", HttpStatusCode.NoContent)]
[InlineData("Viewer", HttpStatusCode.Forbidden)]
public async Task DeleteOrder_EnforcesRoleBasedAccess(string role, HttpStatusCode expectedStatus)
{
    var client = _factory.CreateClientWithRole(role); // a custom helper varying the TestAuthHandler's claims

    var response = await client.DeleteAsync("/orders/1");

    Assert.Equal(expectedStatus, response.StatusCode);
}
Enter fullscreen mode Exit fullscreen mode

This directly extends the RBAC/Policy-Based Authorization guide's testing recommendations to the integration-test layer — rather than only unit-testing an authorization handler in isolation (per that guide's Section 11), an integration test can verify the full pipeline genuinely enforces the intended access control, end to end, through real middleware, for a specific real endpoint.


8. Testing Message-Based Integrations

The challenge: verifying a message was actually published, or actually consumed correctly

As covered in this series' Event-Driven Architecture, RabbitMQ, and Kafka guides, verifying that an operation correctly publishes an event — and that a corresponding consumer correctly processes it — is a genuine integration-testing concern distinct from testing either side in isolation.

Testing message publishing with a real broker via Testcontainers

private readonly RabbitMqContainer _rabbitContainer = new RabbitMqBuilder().Build();

[Fact]
public async Task PlaceOrder_PublishesOrderPlacedEvent()
{
    await using var connection = new ConnectionFactory { Uri = new Uri(_rabbitContainer.GetConnectionString()) }.CreateConnection();
    await using var channel = await connection.CreateChannelAsync();
    await channel.QueueDeclareAsync("test-queue", durable: true, exclusive: false, autoDelete: false);
    await channel.QueueBindAsync("test-queue", "orders", "order.created");

    await _client.PostAsJsonAsync("/orders", validOrderRequest);

    var consumer = new AsyncEventingBasicConsumer(channel);
    var receivedEvent = await WaitForMessageAsync(channel, "test-queue", timeout: TimeSpan.FromSeconds(5));

    Assert.NotNull(receivedEvent);
}
Enter fullscreen mode Exit fullscreen mode

Using a real, Testcontainers-provided RabbitMQ instance (per this series' RabbitMQ guide) lets an integration test verify the full, genuine round trip — the API handler actually publishes to the actual exchange with the actual routing key, and a test-side consumer can verify a message genuinely arrives, rather than mocking IEventPublisher and only verifying it was called (which, per this series' xUnit guide's mocking section, only proves the handler's own logic thinks it published something, not that publishing genuinely works end-to-end).

Testing the transactional outbox pattern specifically

[Fact]
public async Task PlaceOrder_WritesOutboxMessage_InSameTransactionAsOrder()
{
    await _client.PostAsJsonAsync("/orders", validOrderRequest);

    using var scope = _factory.Services.CreateScope();
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    var outboxMessage = await dbContext.OutboxMessages.FirstOrDefaultAsync(m => m.Type == nameof(OrderPlacedEvent));

    Assert.NotNull(outboxMessage); // verifies the OUTBOX WRITE specifically, per this series' Event-Driven Architecture guide
}
Enter fullscreen mode Exit fullscreen mode

This directly verifies the transactional outbox pattern covered in this series' Event-Driven Architecture guide — confirming the outbox row was genuinely written as part of the same database transaction as the order itself, distinct from (and testable independently of) whether the separate background publisher process has picked it up and actually sent it to the broker yet.


9. Testing External HTTP Dependencies

Why calling a real third party in a test suite is usually the wrong choice

Genuinely calling a real external payment gateway, email provider, or third-party API in every CI run is slow, potentially costly, subject to that third party's own uptime and rate limits (an unreliable dependency for your own test suite's reliability), and — for anything involving real side effects like an actual charge — often simply unacceptable.

WireMock.Net: a real HTTP server, standing in for the external dependency

private readonly WireMockServer _mockPaymentGateway = WireMockServer.Start();

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureServices(services =>
    {
        services.Configure<PaymentGatewayOptions>(options => options.BaseUrl = _mockPaymentGateway.Url!);
    });
}

[Fact]
public async Task PlaceOrder_HandlesPaymentGatewayTimeout_Gracefully()
{
    _mockPaymentGateway
        .Given(Request.Create().WithPath("/charge").UsingPost())
        .RespondWith(Response.Create().WithStatusCode(504).WithDelay(TimeSpan.FromSeconds(10)));

    var response = await _client.PostAsJsonAsync("/orders", validOrderRequest);

    Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); // verifies OUR error handling, not the gateway's
}
Enter fullscreen mode Exit fullscreen mode

WireMock.Net runs a genuine, local HTTP server that your application's HttpClient calls exactly as it would the real third party — but with fully configurable, deterministic responses (including simulating specific failure modes, like the timeout above) that would be difficult or impossible to reliably reproduce against the real, live service. This lets an integration test verify your application's actual HTTP client code, retry policies (per this series' Microservices guide's resilience patterns), and error handling — genuinely exercising the real HttpClient configuration and code path — without depending on the real third party's availability or behavior.


10. Speed and CI Considerations

Integration tests are inherently slower — managing that cost deliberately

As covered in this series' CI/CD Pipelines guide's fail-fast, fastest-tests-first ordering principle, integration tests should run after the faster unit test suite in a CI pipeline, so a genuine logic bug (caught cheaply by a unit test) fails the pipeline in seconds rather than only being discovered after several minutes of container startup and database interaction.

jobs:
  unit-tests:
    steps: [ /* fast — seconds */ ]
  integration-tests:
    needs: unit-tests   # only run if unit tests already passed, per this series' CI/CD Pipelines guide
    steps: [ /* slower — Testcontainers startup + real database interaction */ ]
Enter fullscreen mode Exit fullscreen mode

Reusing containers across a test run, rather than one per test

As covered in this series' xUnit guide, using a collection fixture to share one Testcontainers-provided database instance across many test classes (rather than starting a fresh container per test class, or worse, per test) is the standard mitigation for container startup overhead — the reset-between-tests strategy from Section 5 (Respawn) keeps that shared instance's data isolated, without paying container startup cost repeatedly.

Requiring Docker in CI

Testcontainers requires a Docker daemon available wherever tests run — GitHub-hosted runners and most managed CI environments (per this series' GitHub Actions and Azure DevOps guides) provide this by default, but self-hosted runners or more restrictive environments may need explicit Docker availability confirmed and configured.

Parallelizing integration test classes, while keeping tests within a shared-container collection sequential

As covered in this series' xUnit guide, xUnit's default class-level parallelization, combined with the collection-fixture pattern forcing sequential execution within a shared resource, gives a reasonable default balance — different integration test classes touching genuinely independent containers/resources can run in parallel, while tests sharing one container's state run safely, sequentially, relative to each other.


11. Integration Tests vs. Contract Tests vs. End-to-End Tests

Where each layer's actual concern differs

Integration test (this guide): does MY service's own components work together correctly
                                 (my API + my real database + my real message broker)?

Contract test (per this series' Microservices guide): does MY service's API/event schema genuinely
                                                          match what CONSUMING services actually expect?

End-to-end test: does the FULL, multi-service, deployed system work correctly for a critical user journey?
Enter fullscreen mode Exit fullscreen mode

As covered in this series' Microservices guide's testing strategies section, these three layers answer genuinely different questions, and conflating them leads to using the wrong tool — an integration test (this guide) verifies your own service's internal wiring; a contract test verifies your service's external interface matches what other, independently-deployed services actually rely on; an end-to-end test verifies the whole assembled system, deliberately kept to a small number of critical paths given its cost and inherent flakiness (per that guide's discussion).

Why integration tests remain valuable even in a microservices architecture with contract tests

Even with solid contract tests in place (verifying your service's API shape matches what consumers expect), integration tests remain necessary for verifying your service's own internal correctness — a contract test confirms "my API returns the right shape," while an integration test confirms "my API, given a real request, actually does the right thing against a real database," which is a genuinely distinct, equally necessary concern.


12. Common Pitfalls

Pitfall Why it hurts Better approach
Using EF Core's in-memory provider as a stand-in for a real database Doesn't genuinely enforce constraints or translate LINQ to SQL the same way; false confidence Use Testcontainers with the real target database engine
No test data isolation between tests sharing one database instance Tests interfere with each other, producing order-dependent, flaky failures Use Respawn (or unique generated data) to reset/isolate state between tests
Calling real third-party APIs directly in the test suite Slow, unreliable, potentially costly, subject to a dependency outside your control Use WireMock.Net (or a similar local HTTP double) for external HTTP dependencies
Mocking IEventPublisher and only verifying it was called, when testing message-based flows Doesn't verify the message genuinely reaches and is correctly formatted for a real broker Use a real, Testcontainers-provided broker for genuinely verifying publish/consume behavior
Running integration tests before unit tests in a CI pipeline Slower feedback on the more common category of bug (business logic) Order unit tests first, per this series' CI/CD Pipelines guide's fail-fast principle
No shared container reuse across a test run Unnecessary container startup overhead multiplied across every test class Use collection fixtures to share expensive Testcontainers instances, per this series' xUnit guide
Conflating integration tests with contract tests or end-to-end tests Using the wrong tool for a genuinely different question Keep the three layers distinct, per Section 11

Quick Reference Table

Concept Purpose
WebApplicationFactory<Program> In-memory test server exercising the real ASP.NET Core pipeline
Testcontainers Genuinely real, disposable, containerized dependencies for tests
Respawn Resets a shared test database to a known-clean state between tests
ConfigureWebHost / ConfigureServices Overrides specific services/configuration for the test environment
TestAuthHandler Bypasses real token validation for tests focused on business logic
WireMock.Net A real, locally-run HTTP double for external third-party dependencies
Collection fixture (per xUnit guide) Shares one expensive container instance across many integration test classes
Integration vs. contract vs. E2E Three distinct testing layers answering genuinely different questions

Conclusion

Integration tests exist specifically to catch the class of bug unit tests structurally cannot — a missing migration, an incorrect EF Core mapping, a message that's never actually published correctly to a real broker, an authorization rule that isn't genuinely enforced through the real middleware pipeline. WebApplicationFactory provides the real ASP.NET Core pipeline; Testcontainers provides genuinely real, disposable infrastructure dependencies rather than approximations that risk false confidence; and disciplined test data isolation (via Respawn or unique per-test data) keeps a shared test database from becoming a source of flaky, order-dependent failures.

Positioned correctly — after the fast, numerous unit tests from this series' xUnit guide, and before the deliberately few, expensive end-to-end tests covered in the Microservices guide's testing strategy — integration tests are the layer that actually verifies your service's individually-tested pieces genuinely work together as an assembled whole, which is a distinct, necessary confidence that no amount of well-mocked unit testing alone can ever fully provide.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the integration test that caught a migration bug your entire unit test suite had no way of ever seeing.

Top comments (0)