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
- Introduction
- Where Integration Tests Sit in the Testing Pyramid
- WebApplicationFactory: Testing the Full ASP.NET Core Pipeline
- Testcontainers: Real Dependencies, Not Fakes
- A Complete Worked Example: API + Real Database
- Test Data Management and Isolation
- Overriding Configuration and Dependencies for Tests
- Testing Authentication and Authorization
- Testing Message-Based Integrations
- Testing External HTTP Dependencies
- Speed and CI Considerations
- Integration Tests vs. Contract Tests vs. End-to-End Tests
- Common Pitfalls
- Quick Reference Table
- 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
}
}
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
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
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);
}
}
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 { }
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();
}
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();
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);
}
}
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);
}
public class OrdersApiIntegrationTests : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
public async Task InitializeAsync() => await _factory.DatabaseFixture.ResetAsync(); // clean slate before EVERY test
public Task DisposeAsync() => Task.CompletedTask;
}
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();
}
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
}
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>();
});
}
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
});
});
}
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));
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddAuthentication("TestScheme")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("TestScheme", options => { });
});
}
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);
}
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);
}
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
}
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
}
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 */ ]
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?
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)