BrandGhost
Testing ASP.NET Core Web API: WebApplicationFactory and Integration Tests

Testing ASP.NET Core Web API: WebApplicationFactory and Integration Tests

Testing ASP.NET Core Web API effectively means going beyond unit tests and exercising the full HTTP pipeline. That includes routing, middleware, serialization, model validation, authorization, and your actual database interactions. Unit tests are fast and focused -- they are great for isolated business logic. But they mock so much of the infrastructure that they can miss a surprising number of real bugs. Integration tests bridge that gap.

This guide walks you through everything you need to know: setting up WebApplicationFactory<T>, customizing the test server, writing authenticated endpoint tests, seeding data with EF Core in-memory databases, and organizing your test suite with xUnit best practices. All examples target .NET 10 and ASP.NET Core 10.


Why Integration Tests for Web APIs?

Unit tests give you speed and precision. But when it comes to testing ASP.NET Core Web API endpoints, unit tests have a real problem: they mock the pipeline. You can mock your service, your repository, and your logger -- and still ship a bug caused by a misconfigured route, a broken middleware ordering, or a serialization mismatch between your DTO and the JSON contract your client expects.

Integration tests spin up the real pipeline. They catch routing misconfigurations that unit tests will never see. They catch missing [FromBody] attributes, wrong HTTP status codes, and broken validation filters. They catch auth policy bugs that only manifest when the full middleware stack runs. The confidence they give you is qualitatively different.

The tradeoff is speed. Integration tests are slower than unit tests -- typically measured in seconds rather than milliseconds. They also require more setup. The right approach is layered testing: use unit tests for complex business logic and pure functions, and use integration tests for every HTTP endpoint you care about. Both have their place.


WebApplicationFactory: A Cornerstone

WebApplicationFactory<TEntryPoint> is provided by the Microsoft.AspNetCore.Mvc.Testing NuGet package. It spins up your application in an in-memory test server -- no real ports, no network overhead. It uses your actual Program.cs startup, your actual middleware pipeline, your actual DI container. The only difference is that the transport is replaced with an in-memory HTTP handler.

The simplest possible integration test looks like this:

using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using Xunit;

public class ProductsEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductsEndpointTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/products");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

This is genuinely running your API. The route is real. The middleware runs. If you have a filter that returns 503 before requests hit your controller, this test will catch it. For IClassFixture<WebApplicationFactory<Program>> to work, your Program.cs must be accessible from the test project -- usually by making it public partial class Program {} at the bottom of Program.cs, or by targeting the entry point assembly directly.


Customizing the Test Server

Most real applications need customization. You want to replace your production database with an in-memory one, swap out external HTTP services with fakes, or override configuration values. The clean way to do this is to subclass WebApplicationFactory<T>.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove the production DbContext registration
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

            if (descriptor != null)
            {
                services.Remove(descriptor);
            }

            // Add SQLite in-memory DbContext for tests
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseSqlite("DataSource=:memory:");
            });

            // Replace external services with fakes
            services.AddSingleton<IEmailService, FakeEmailService>();

            // Ensure the schema is created
            var sp = services.BuildServiceProvider();
            using var scope = sp.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            db.Database.EnsureCreated();
        });
    }
}

Notice a few things here. First, you remove the existing DbContextOptions<AppDbContext> before adding the replacement -- if you just add a second registration, the original one wins and your tests hit the production database. Second, SQLite in-memory is preferred over the EF Core InMemory provider -- SQLite enforces relational constraints and behaves much closer to a real database, making tests more likely to catch real issues. Third, db.Database.EnsureCreated() creates the schema so your tests can insert and query data immediately.

This pattern also applies to replacing any infrastructure service. Got an IEmailService that fires real emails? Replace it with a FakeEmailService that just records what it received. Proper isolation is how you get reliable, fast tests. Understanding how the DI container resolves these overrides is worth a read -- see How DI Containers Use Reflection Internally for a deep dive on the mechanics.


Making HTTP Requests in Tests

With a client in hand, you can exercise every endpoint in your API. The System.Net.Http.Json extension methods make serialization painless -- no manual JSON wrangling.

using System.Net;
using System.Net.Http.Json;
using Xunit;

public class ProductsIntegrationTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;
    private readonly CustomWebApplicationFactory _factory;

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

    [Fact]
    public async Task GetProducts_WhenEmpty_ReturnsEmptyList()
    {
        var response = await _client.GetAsync("/api/products");
        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.NotNull(products);
        Assert.Empty(products);
    }

    [Fact]
    public async Task CreateProduct_WithValidData_ReturnsCreated()
    {
        var newProduct = new CreateProductRequest
        {
            Name = "Widget Pro",
            Price = 49.99m,
            StockQuantity = 100
        };

        var response = await _client.PostAsJsonAsync("/api/products", newProduct);
        var created = await response.Content.ReadFromJsonAsync<ProductDto>();

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(created);
        Assert.Equal("Widget Pro", created.Name);
        Assert.True(created.Id > 0);
    }

    [Fact]
    public async Task CreateProduct_WithMissingName_ReturnsBadRequest()
    {
        var invalidProduct = new CreateProductRequest { Price = 49.99m };

        var response = await _client.PostAsJsonAsync("/api/products", invalidProduct);

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }
}

These tests cover the happy path, a validation failure, and a get-empty case. Each one is independent. Each one tells a clear story about what the endpoint should do. The descriptive test names follow the MethodUnderTest_Condition_Expected pattern -- important for making failing builds immediately diagnosable without reading the test body.


Testing Authenticated Endpoints

Most APIs have endpoints protected by [Authorize]. Testing these endpoints presents a choice: bypass authentication for the test, or issue a real token.

Bypassing auth is the simpler option and appropriate for most tests. The goal of integration tests is usually to test behavior, not re-test the auth framework. Override the auth configuration in your custom factory:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

public class AuthenticatedWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.AddAuthentication("TestScheme")
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                    "TestScheme", options => { });
        });
    }
}

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, "TestUser"),
            new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
            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));
    }
}

This approach creates a fake authentication handler that always returns a specific set of claims. Every request made through a client created by this factory will be treated as authenticated with those claims. Your [Authorize] attributes check the claims -- if the role check passes, the request proceeds.

The alternative is issuing real JWT tokens. This makes sense when you specifically want to test the JWT validation logic, or when your auth middleware has complex claim transformation. For most behavioral tests of business logic behind authenticated endpoints, the fake handler approach is faster, simpler, and just as effective.


In-Memory Database for Tests

Seeding test data is where a lot of integration test setups fall apart. If your test depends on specific data being present, you need a reliable way to seed it and a reliable way to clean it up. The cleanest pattern uses IAsyncLifetime combined with a scoped service resolution.

public class ProductsWithDataTests
    : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
    private readonly CustomWebApplicationFactory _factory;
    private readonly HttpClient _client;

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

    public async Task InitializeAsync()
    {
        // Seed data before each test class runs
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        db.Products.AddRange(
            new Product { Name = "Alpha Widget", Price = 10.00m, StockQuantity = 50 },
            new Product { Name = "Beta Widget", Price = 20.00m, StockQuantity = 25 },
            new Product { Name = "Gamma Widget", Price = 30.00m, StockQuantity = 10 }
        );

        await db.SaveChangesAsync();
    }

    public async Task DisposeAsync()
    {
        // Clean up after all tests in this class complete
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        db.Products.RemoveRange(db.Products);
        await db.SaveChangesAsync();
    }

    [Fact]
    public async Task GetProducts_WithSeededData_ReturnsAllProducts()
    {
        var response = await _client.GetAsync("/api/products");
        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();

        Assert.Equal(3, products!.Count);
    }
}

IAsyncLifetime gives you InitializeAsync and DisposeAsync -- async versions of constructor setup and teardown. This lets you seed data asynchronously before tests run and clean it up afterward. The cleanup in DisposeAsync is critical for test isolation, especially when multiple test classes share the same factory instance via IClassFixture.

For more on configuring structured logging in your test output to make failures easier to diagnose, see Logging in .NET: The Complete Developer's Guide. Capturing log output during failing integration tests can make root-cause analysis dramatically faster.


Test Organization: Fixtures and Collections

IClassFixture<T> gives you a shared instance of T across all tests in a single test class. The factory starts once, tests run, and it disposes. This is efficient for tests that don't mutate shared state.

ICollectionFixture<T> goes further -- it shares a fixture across multiple test classes. You define a collection with [CollectionDefinition] and apply it with [Collection]. This is useful when starting the test server is expensive and you want to share a single instance across your entire integration test suite.

The key discipline is test isolation. Tests must not depend on execution order. Tests must not leave behind state that affects other tests. If one test creates a product, another test that lists all products should not suddenly see that product -- unless you explicitly designed it that way. Using per-test database names (the Guid.NewGuid() trick in your factory) or aggressive cleanup in IAsyncLifetime.DisposeAsync keeps tests independent.


Unit Testing Controllers and Business Logic

Integration tests give you confidence about the HTTP pipeline. But they are slow relative to unit tests, and they are harder to set up for complex logic scenarios with many branches. For logic-heavy code, unit tests are still the right tool.

Controllers in ASP.NET Core 10 are plain classes. You can instantiate them directly in a unit test, pass in mocked dependencies, call the action method, and assert on the IActionResult returned. This is fast and focused.

When you structure your API using the mediator pattern -- which cleanly separates HTTP concerns from business logic -- testing becomes even more targeted. Your controller unit test just verifies it dispatches the right command or query. Your handler unit test exercises the business rules. See the Mediator Design Pattern in C# guide for how to structure this separation in your ASP.NET Core projects.

For logging in test output, Serilog's sink abstractions make it easy to capture log entries during tests for assertions. The Serilog in .NET Complete Guide covers the sink extension points you need.


Testing Middleware and Filters

Custom middleware and action filters deserve their own tests. Middleware is testable in isolation using TestServer from Microsoft.AspNetCore.TestHost. You build a minimal pipeline with just your middleware and a terminal handler, then send test requests through it.

For action filters -- classes implementing IActionFilter or IAsyncActionFilter -- the most pragmatic approach is to test them through an integration test against an endpoint that uses the filter. This exercises the real attribute activation and the real filter pipeline. It also tests that the filter is registered correctly, which a direct instantiation test would miss.

The key principle is to test middleware and filters at the boundary where they actually work. Mocking the HttpContext deeply enough to test middleware in isolation is possible but tedious. The TestServer approach is almost always cleaner and more reliable.


xUnit Best Practices

xUnit is the standard test framework for .NET. A few practices make a significant difference in maintainability.

Name every test clearly. Use MethodUnderTest_Condition_Expected for non-parameterized tests. For parameterized tests ([Theory]), the name should describe what scenario is being exercised, since the data values will appear in the test output. A failing test named GetProducts_WhenEmpty_ReturnsEmptyList tells you everything. A failing test named Test1 tells you nothing.

Use [Theory] with [InlineData] for tests that vary only by input values. This avoids duplicating test logic for similar cases. Keep each test focused on a single behavior. An integration test that hits five endpoints and asserts fifteen things is hard to diagnose when it fails.

Avoid shared mutable state at the class level. The xUnit runner may run test methods in any order and sometimes in parallel. State left by one test method must not affect another. The IAsyncLifetime pattern described above is the correct mechanism for initialization and cleanup that needs to be async.

For configuring structured logging in your test projects to capture diagnostics during integration test runs, see How to Set Up Serilog in ASP.NET Core for the sink and configuration patterns that work well with test output helpers.


Frequently Asked Questions

What is WebApplicationFactory and why do I need it for testing asp.net core web api?

WebApplicationFactory<TEntryPoint> is a class from the Microsoft.AspNetCore.Mvc.Testing package that bootstraps your entire application in-memory for testing. It reads your Program.cs, builds your DI container, wires up your middleware pipeline, and exposes an HttpClient that sends requests through it without opening a real network socket.

You need it for testing ASP.NET Core Web API endpoints because it exercises the real pipeline. Routing, model binding, validation, filters, middleware, serialization -- all of it runs. Unit tests that call controller methods directly skip all of that infrastructure. WebApplicationFactory is how you catch the bugs that only appear when the full stack runs together.

The setup is minimal. Add the Microsoft.AspNetCore.Mvc.Testing package, make Program accessible as a public partial class, and implement IClassFixture<WebApplicationFactory<Program>> in your test class. The factory is instantiated once per test class and disposed when all tests in the class finish.

How do I replace the production database with a test database in WebApplicationFactory?

The standard approach is to subclass WebApplicationFactory<T> and override ConfigureWebHost. Inside ConfigureWebHost, you call services.Remove to remove the production DbContextOptions<AppDbContext> descriptor, then add a replacement registration pointing at an in-memory database.

Using UseSqlite("DataSource=:memory:") is the recommended starting point -- SQLite enforces relational constraints and behaves much closer to a real database. If you use UseInMemoryDatabase instead, append a unique name via Guid.NewGuid() to prevent cross-test pollution when tests run in parallel, though note that it skips foreign key and transaction semantics.

Call db.Database.EnsureCreated() in your factory setup after building the service provider. This applies your EF Core model to the in-memory database so your tests can insert and query data without hitting migration errors.

How do I test endpoints that require authentication?

The cleanest approach for most integration tests is a custom AuthenticationHandler that always succeeds and returns a fixed set of claims. You register it in your custom factory by calling services.AddAuthentication("TestScheme").AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("TestScheme", ...). Every request from clients created by that factory will be treated as authenticated.

This approach is appropriate when you are testing business logic behind authentication -- you care that the endpoint works correctly for an authenticated user, not that the JWT validation library is functioning. For most application teams, integration tests at the API level don't need to retest the internals of JWT validation -- the focus should be on verifying your application's authentication and authorization policies work as expected.

If you specifically need to test JWT validation -- for example, testing that expired tokens are rejected -- then you should configure the test server with real JWT validation and issue test tokens signed with a known test key. This is more involved but gives you end-to-end confidence in the auth configuration.

What is the difference between IClassFixture and ICollectionFixture in xUnit?

IClassFixture<T> creates a single shared instance of T for all tests in one test class. The fixture is created before the first test runs and disposed after the last test completes. Use this when you want to share an expensive setup -- like a WebApplicationFactory -- across all tests in a class without restarting it for each test method.

ICollectionFixture<T> shares the same instance of T across multiple test classes. You define a collection using [CollectionDefinition("MyCollection")] and apply it to test classes with [Collection("MyCollection")]. Use this when you want one WebApplicationFactory instance for your entire integration test suite.

The tradeoff with ICollectionFixture is that all collections sharing the same fixture run sequentially, not in parallel. XUnit isolates collections from each other. If you have many test classes that all share a factory, this can slow down your test run. Measure the impact before committing to collection-wide fixtures.

How do I seed test data reliably without test pollution?

The most reliable pattern combines IAsyncLifetime with scoped service resolution. Implement IAsyncLifetime in your test class, seed data in InitializeAsync, and clean up in DisposeAsync. This ensures each test class starts with a known state regardless of what other tests ran before it.

If different tests within the same class need different data sets, the most reliable approach is per-test databases. Give each test its own database name and create the factory with those options. This eliminates any possibility of state leaking between tests at the cost of slightly more setup overhead.

Avoid seeding data in the WebApplicationFactory itself for the same data that your tests will mutate. Seed data in the factory for lookup tables and reference data that never changes. Seed test-specific data in IAsyncLifetime.InitializeAsync so it stays close to the tests that depend on it.

Should I use EF Core InMemory or SQLite in-memory for tests?

Both work. The EF Core InMemory provider is simpler to set up and has zero configuration. It works well for most testing ASP.NET Core Web API scenarios where you are testing HTTP behavior rather than SQL semantics. It does not enforce relational constraints, so foreign key violations will not be caught in tests using it.

The SQLite in-memory provider (UseSqlite("DataSource=:memory:")) runs a real SQLite engine in memory. It enforces constraints, supports transactions, and behaves much more like a real relational database. If your production code relies on specific SQL behavior -- cascading deletes, unique constraints, transactions -- SQLite in-memory is the safer choice.

The downside of SQLite in-memory is that you need EF Core migrations or EnsureCreated to set up the schema, and connection lifetime management matters (keep the connection open for the lifetime of the test). For a production-grade test suite, SQLite in-memory is worth the extra setup.

How many integration tests should I write per endpoint?

As a rough baseline, aim for a handful of tests per endpoint -- covering the happy path and key failure modes -- adjusting based on complexity. Cover the happy path, at least one validation failure case, and at least one authorization case if the endpoint is protected. Add tests for any business rule that could return a non-200 response -- not-found scenarios, conflict responses, business rule violations.

The goal is not 100% code coverage through integration tests. That would be slow and expensive. The goal is high confidence that your API's HTTP contract is correct -- the right status codes, the right response shapes, the right error messages. Unit tests handle the deeper branching logic within handlers and services.

Think of your integration test suite as documentation for your API's behavior. When a future developer reads the tests, they should understand exactly what each endpoint does, what it accepts, and what it returns under various conditions.


Summary

Testing ASP.NET Core Web API with WebApplicationFactory is the most reliable way to gain confidence that your endpoints work correctly end-to-end. The framework makes it straightforward: add the testing package, create a factory, customize it to replace production infrastructure with test doubles, and write focused, descriptive xUnit tests. Layer integration tests on top of unit tests rather than replacing them. Use IClassFixture or ICollectionFixture to share factory instances efficiently. Use IAsyncLifetime for async setup and teardown. Keep tests isolated, keep them descriptive, and let the failing test names guide you straight to the problem.

For further reading, the LINQ in C# Complete Guide covers patterns useful for asserting on collections returned from your API endpoints, and C# Reflection: The Complete .NET 10 Guide provides context on how the test frameworks and DI containers use reflection internally to wire everything together.

ASP.NET Core Web API in .NET: The Complete Guide

Master ASP.NET Core Web API in .NET 10 -- learn request pipelines, routing, controllers, JWT authentication, error handling, and deployment strategies.

WebApplicationFactory in ASP.NET Core: Practical Tips for C# Developers

Learn about WebApplicationFactory in ASP.NET Core and leveraging it for testing. Use an HttpClient and implement the tips in this article for better tests!

Testing Feature Slices in C#: Unit Tests, Integration Tests, and What to Test

Learn how to test feature slices in C# effectively. Covers unit tests for handlers, integration tests with WebApplicationFactory, and when to use each testing strategy.

An error has occurred. This application may no longer respond until reloaded. Reload