Why integration testing matters
Unit tests verify logic in isolation. But they cannot catch a misconfigured middleware pipeline, a mismatched JSON serializer, an EF Core mapping error, or an authorization policy that doesn't enforce what you think it does. Integration tests fill this gap by exercising the full stack — HTTP request in, HTTP response out — in a fast, in-process server with no Docker or network required.
Interviewers ask about integration testing to assess whether a candidate distinguishes it from unit testing, knows how to bootstrap a test server, and understands the tradeoffs between speed and fidelity.
Unit testing vs integration testing
| Unit test | Integration test | |
|---|---|---|
| Scope | One class | Multiple layers (HTTP → service → DB) |
| Speed | Milliseconds | Seconds |
| Isolation | Full (mocked deps) | Partial (real pipeline, fake DB) |
| What it catches | Logic bugs | Config bugs, serialization, middleware, policies |
You need both. A well-tested codebase has many unit tests and a smaller number of high-value integration tests that cover the critical paths end-to-end.
WebApplicationFactory: the .NET integration test host
WebApplicationFactory<TProgram> from Microsoft.AspNetCore.Mvc.Testing spins
up the real ASP.NET Core pipeline in-process. You get a real HttpClient, real
middleware, real DI — but no network overhead.
dotnet add package Microsoft.AspNetCore.Mvc.Testing
// Minimal setup — use the app with its real configuration:
public class HealthTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public HealthTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Fact]
public async Task GetHealth_Returns200()
{
var response = await _client.GetAsync("/health");
response.EnsureSuccessStatusCode();
}
}
IClassFixture<WebApplicationFactory<Program>> creates one factory per test
class. The factory is shared across all tests in the class, making it fast.
Custom factories: swapping services for tests
For integration tests you usually want to replace the real database with an in-memory one and swap real external services with fakes:
public class TestWebFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove the real DbContext registration:
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
// Add an in-memory database with a unique name per test run:
services.AddDbContext<AppDbContext>(opts =>
opts.UseInMemoryDatabase("TestDb-" + Guid.NewGuid()));
// Replace real external services with fakes:
services.AddSingleton<IEmailSender, FakeEmailSender>();
services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
});
builder.UseEnvironment("Testing");
}
}
Tests then inherit from this factory and get a pre-configured test server with no real database or email calls.
In-memory database vs SQLite vs real database
Three options for the test database, each at a different speed/fidelity tradeoff:
EF Core InMemory provider
Fast, zero setup, but does not enforce foreign keys, unique indexes, or relational constraints. Use only when you are testing service logic that happens to call EF, not when you care about database behavior.
SQLite in-memory
Runs real SQL, enforces constraints, supports migrations. Almost as fast as the InMemory provider. The gotcha: in-memory SQLite databases are per-connection, so you must keep the connection alive for the test's duration.
public class SqliteFixture : IDisposable
{
public SqliteConnection Connection { get; } = new("Data Source=:memory:");
public AppDbContext Context { get; }
public SqliteFixture()
{
Connection.Open();
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(Connection).Options;
Context = new AppDbContext(opts);
Context.Database.EnsureCreated();
}
public void Dispose() { Context.Dispose(); Connection.Close(); }
}
SQLite in-memory is the right default for most integration test suites.
Testcontainers (real SQL Server or Postgres)
For tests that exercise database-specific behavior — JSON columns, full-text search, stored procedures, EF migrations — use Testcontainers to start a real Docker container:
// dotnet add package Testcontainers.MsSql
public class SqlServerTests : IAsyncLifetime
{
private readonly MsSqlContainer _sql = new MsSqlBuilder().Build();
public async Task InitializeAsync()
{
await _sql.StartAsync();
// run real migrations against the container
}
public async Task DisposeAsync() => await _sql.DisposeAsync();
}
Testcontainers requires Docker and adds ~5–15 seconds startup. Reserve them for tests that truly need production-equivalent database behavior.
Seeding test data
Never rely on data from a previous test. Seed exactly the data each test needs:
public class ProductTests : IClassFixture<TestWebFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly AppDbContext _db;
public ProductTests(TestWebFactory factory)
{
_client = factory.CreateClient();
var scope = factory.Services.CreateScope();
_db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
}
public async Task InitializeAsync()
{
await _db.Database.EnsureCreatedAsync();
_db.Products.AddRange(
new Product { Id = 1, Name = "Widget", Price = 9.99m },
new Product { Id = 2, Name = "Gadget", Price = 24.99m }
);
await _db.SaveChangesAsync();
}
public async Task DisposeAsync()
{
_db.Products.RemoveRange(_db.Products);
await _db.SaveChangesAsync();
}
[Fact]
public async Task GetProducts_ReturnsAllSeeded()
{
var response = await _client.GetAsync("/api/products");
var products = await response.Content.ReadFromJsonAsync<List<Product>>();
Assert.Equal(2, products!.Count);
}
}
Use deterministic IDs or GUIDs to avoid collisions when tests run in parallel.
Testing authenticated endpoints
The cleanest approach is a custom AuthenticationHandler that grants a fixed
test identity to every request through the test client:
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.Name, "test-user"),
new Claim(ClaimTypes.Role, "Admin"),
};
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")), "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
// Register in TestWebFactory.ConfigureWebHost:
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
All requests through _client are now authenticated as test-user / Admin.
To test unauthorized access, create a second client with no auth header.
Testing middleware in isolation
When you want to test a single middleware component without the full application
pipeline, use TestServer directly:
[Fact]
public async Task ExceptionMiddleware_WhenThrows_Returns500WithProblemDetails()
{
using var host = await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder.UseTestServer();
webBuilder.Configure(app =>
{
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.Run(_ => throw new InvalidOperationException("Boom!"));
});
})
.StartAsync();
var response = await host.GetTestClient().GetAsync("/");
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.Equal("application/problem+json",
response.Content.Headers.ContentType!.MediaType);
}
This lets you test the middleware's error handling behavior without configuring routing, authorization, or any other application concern.
Asserting on ProblemDetails error responses
ASP.NET Core returns ProblemDetails (RFC 7807) for validation and error
responses. Deserve two assertions: the HTTP status code and the Content-Type:
[Fact]
public async Task PostOrder_WithMissingSku_Returns400WithProblemDetails()
{
var response = await _client.PostAsync("/api/orders",
new StringContent("""{"quantity":2}""", Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("application/problem+json",
response.Content.Headers.ContentType!.MediaType);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.True(problem!.Errors.ContainsKey("Sku"));
}
A 400 returning text/html means the error middleware is misconfigured even
though the status is "correct."
Parallel test execution and isolation
xUnit can run test collections in parallel. Shared mutable state (a single in-memory database) is the most common cause of intermittent failures:
// Group tests that share state into a collection (sequential within collection):
[CollectionDefinition("Integration")]
public class IntegrationCollection : ICollectionFixture<TestWebFactory> { }
[Collection("Integration")]
public class OrderTests { /* shares TestWebFactory */ }
[Collection("Integration")]
public class ProductTests { /* same factory, same collection thread */ }
Within a collection, use unique data (GUIDs, random IDs) so tests can run in parallel without stepping on each other.
Rule of thumb: Start integration tests with WebApplicationFactory and
SQLite in-memory. Add Testcontainers only when you need real database constraints
or migrations. Seed test data in InitializeAsync, clean it in DisposeAsync,
and never depend on test execution order.