Skip to content

.NET Core · Testing

Integration Testing in ASP.NET Core with WebApplicationFactory

6 min read Updated 2026-06-23 Share:

Practice Integration Testing interview questions

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 testIntegration test
ScopeOne classMultiple layers (HTTP → service → DB)
SpeedMillisecondsSeconds
IsolationFull (mocked deps)Partial (real pipeline, fake DB)
What it catchesLogic bugsConfig 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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel