Integration Testing ASP.NET Core APIs with WebApplicationFactory

Integration testing for ASP.NET Core APIs with WebApplicationFactory: expose Program, replace services, test databases, and authenticated requests.

Executive Summary: Integration testing for ASP.NET Core APIs means exercising the whole pipeline in-process: WebApplicationFactory hosts your real application in memory, hands you an HttpClient with no network involved, and lets you replace services and databases for the test run. The setup is small (expose Program to the test project, add one package, subclass the factory) and the coverage is large: routing, model binding, validation, serialization, filters, and error responses all execute exactly as they will in production.

The deployment that made someone write this kind of test: hundreds of unit tests, all green, and the first production request returns 404 because a route template had a typo, or a 500 because a DI registration was missing for one environment. Every class worked. The wiring did not. Unit tests cannot see wiring, and this is exactly the gap the in-memory host closes: the test starts the real application, hits real endpoints, and reads real responses.

Integration testing ASP.NET Core APIs is what WebApplicationFactory<Program> enables: hosting the app from the test process, replacing parts of it, and asserting on the HTTP results. The official integration testing guide is the reference, and the WebApplicationFactory API documentation documents the seams this page uses.

The examples continue the TaskApi on .NET SDK 10.0 with ASP.NET Core 10, xUnit v3 from the previous guide, and the controller surface from the Web API guide.

In my experience, integration tests are the highest-value-per-line tests in a web service. Unit tests protect logic; these protect the contract clients actually call.

Setup: the package and the partial class

Two one-time steps. First, the test project references the testing package:

dotnet add TaskApi.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing

Second, top-level programs produce an internal Program class that the factory needs to see, so the bottom of Program.cs gets one line:

app.Run();

public partial class Program { }

Without it, the first test fails with a compiler error that Program is not accessible from the test project. The partial class adds nothing at runtime; it just makes the entry point visible to the test assembly.

The first integration test

WebApplicationFactory<TProgram> boots the application, and CreateClient() returns an HttpClient wired straight into the in-memory host: requests travel through the complete pipeline, routing, middleware, model binding, controllers, and serialization, with no sockets ever opened:

public class TaskApiFactory : WebApplicationFactory<Program>

public class TasksEndpointTests(TaskApiFactory factory)
    : IClassFixture<TaskApiFactory>
{
    [Fact]
    public async Task GetAll_WhenEmpty_ReturnsEmptyList()
    {
        var client = factory.CreateClient();

        var response = await client.GetAsync("/api/tasks");

        response.EnsureSuccessStatusCode();
        var body = await response.Content.ReadFromJsonAsync<List<TaskResponse>>();
        Assert.NotNull(body);
        Assert.Empty(body);
    }
}

IClassFixture shares one factory instance across the test class, because booting the app per test is needlessly slow. The same fixture resets only what the test data needs. What this one test proved, that no unit test can. The route resolves, the controller binds and returns, the serializer produces the JSON shape of TaskResponse. A client can parse it.

Replacing services with test doubles

The factory’s superpower is WithWebHostBuilder, which rebuilds the host with your overrides layered on top of Program.cs:

var factory = new TaskApiFactory().WithWebHostBuilder(builder =>
{
    builder.ConfigureTestServices(services =>
    {
        services.RemoveAll<ITaskRepository>();
        services.AddScoped<ITaskRepository>(_ => new FixedTaskRepository());
    });
});

var client = factory.CreateClient();

This is the same registration seam the production container exposes. That is why the dependency injection guide is the prerequisite here: ConfigureTestServices appends registrations after the real ones. Last-registration-wins lets a fake replace the production service for the test run without touching the application’s code.

Test databases: in-memory SQLite vs Testcontainers

Data access is the part of the app a test usually cannot leave real. Three options cover the spectrum:

Option Speed Fidelity Best for
SQLite in-memory (connection held open) Fast High for app code, misses real database SQL quirks Most integration suites
Testcontainers (real PostgreSQL in Docker) Slower Highest: the actual provider and SQL dialect Migration-heavy apps, provider-specific queries
EF in-memory provider Fast Low: LINQ behavior differs, no SQL at all Discouraged; use SQLite in-memory instead

The SQLite setup keeps a connection alive for the database’s lifetime, since “in-memory” databases vanish when their last connection closes:

public class TaskApiFactory : WebApplicationFactory<Program>
{
    private readonly SqliteConnection _connection = new("DataSource=:memory:");

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<DbContextOptions<TaskApiContext>>();
            services.AddDbContext<TaskApiContext>(options => options.UseSqlite(_connection));
        });
    }

    public async Task ResetDatabaseAsync()
    {
        _connection.Open();                    // the in-memory DB exists while this stays open
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<TaskApiContext>();
        await db.Database.EnsureDeletedAsync();
        await db.Database.EnsureCreatedAsync();
    }
}

The SQLite provider and context setup come from the EF Core code-first guide. Testcontainers, at orientation level, is the heavier sibling: the test suite starts a disposable PostgreSQL container via Docker, points the context at it, and gets the true provider behavior, at the cost of Docker on CI machines and slower startup. Most teams run SQLite in-memory daily and let the CI pipeline own a Testcontainers stage.

Authenticated requests without a token service

Testing endpoints behind [Authorize] deserves the same in-process treatment: instead of minting real JWTs in the test suite, replace the authentication handler with a test scheme that authenticates a fixed principal. The pattern keeps the real authorization policies in force while removing the dependency on a token service:

builder.ConfigureTestServices(services =>
{
    services.AddAuthentication(defaultScheme: "TestScheme")
        .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
            "TestScheme", options => { });
});

// TestAuthHandler: authenticates a principal with the claims the test needs,
// then the app's real policies and [Authorize] attributes evaluate them

The handler itself is small: on request, it returns Success with a principal carrying the test’s claims (an id, a role, a project claim). Every policy from the authentication guide runs unmodified behind it. The result is authorization tests that fail when a rule changes, which is what you want them to do.

Deterministic data: same world, every run

Integration tests share one host, so they share one database unless each test resets it. The habits that keep the suite honest:

  • Reset in the fixture. The ResetDatabaseAsync method above runs in the test’s setup, so every test starts from an empty, freshly created schema.
  • Seed explicitly, per test. Each test creates exactly the rows it needs with fixed ids and values. No test depends on another test’s leftovers.
  • Clock and randomness injected. The production code already takes them as services in tests. The container registers fixed implementations and every timestamp in the suite is predictable.

One suite, one convention: a test that would pass only when run after another test is a bug. The fix is resetting state, not adding an execution order.

How real systems do this

  • Every endpoint has one happy-path integration test. The contract each endpoint promises, status code, JSON shape, is pinned by at least one test. As a result, renames and route changes fail loudly.
  • Error shapes are asserted as contracts. A 400 or 404 test asserts the Problem Details fields, the standard error body the API serves. So clients can rely on one error shape everywhere.
  • The suite runs in CI, partitioned. Unit tests run first and fast. The integration project runs next, and its duration is visible. This is because a slow suite that gets skipped nightly is a suite in name only.

A release I remember was blocked by exactly the kind of test this guide builds: a renaming change kept every unit test green and would have shipped a broken route, and the integration test for that endpoint failed in CI within a minute of the push. The unit suite had hundreds of tests about the code around that route and not one that could see the route. The takeaway: tests protect what they observe, and only an integration test observes what a client calls.

Decision framework: what gets an integration test

  1. Does the behavior involve the pipeline? Routing, validation, serialization, filters, auth: integration test it. A unit test cannot see any of it.
  2. Does the test need the real service or a fake? Start real, replace with fakes only where the dependency is another system (email, payments, external APIs). The DI registration seam makes the swap one line.
  3. Is the database part of the contract? Use the SQLite in-memory setup. Move that test to Testcontainers when the provider’s own behavior is what you are proving.
  4. Is the suite getting slow? Share fixtures, reset only what changed, and move slow outlier tests to a nightly run rather than shrinking coverage of the happy paths.
  5. Would a client notice this bug? That is the criterion. If a client can see it, an integration test should pin it.

When NOT to write an integration test

  • Business rules already unit tested. A calculation with theories covers the logic. Wrapping it in HTTP adds runtime and no new coverage.
  • Another system’s behavior. Third-party APIs get a fake at the boundary, not a test that calls the vendor. The seam you test is your code’s handling of their responses.
  • Exhaustive combinatorics. Every validation case against the real server is slow; unit-test the rules, and integration-test one representative invalid request per endpoint.

Common mistakes

  • Forgetting the partial Program class. The factory needs the entry point visible. Without public partial class Program { }, the test project fails to compile against a top-level program’s internal class.
  • Tests sharing stale database state. Without a per-test reset, tests pass in one order and fail in another. Each test builds the rows it needs against a clean schema.
  • Calling production configuration from tests. The in-memory host loads the app’s real appsettings. Environment-specific values (and any secrets) must be overridden in the factory, not assumed absent.
  • Asserting implementation details. Testing which service was called inside the app re-implements the unit layer with slower tests. Assert the HTTP contract, statuses, headers, and bodies.
  • Minting real tokens to test auth. A test suite that needs a live identity provider is slow and brittle. The test authentication scheme keeps real policies and removes the dependency.
  • A slow suite that gets skipped. Ten-minute integration suites start being run “nightly”, which means broken contracts surface a day late. Keep the daily suite fast and push outliers to a separate job.

Key takeaways

  • WebApplicationFactory<Program> hosts the real application in memory; CreateClient() returns an HttpClient that exercises the full pipeline with no network.
  • Expose the entry point with public partial class Program { }; add the Microsoft.AspNetCore.Mvc.Testing package to the test project.
  • WithWebHostBuilder plus ConfigureTestServices replaces registrations after the app’s own, the same DI seam production uses.
  • SQLite in-memory with a held-open connection covers most suites. Testcontainers adds the real provider when its behavior is the contract; the EF in-memory provider stays out of integration work.
  • Authenticated requests use a test authentication scheme with fixed claims, keeping real authorization policies under test without a token service.
  • Deterministic data is the discipline: fixture resets, per-test seeding, injected clocks. A suite that depends on execution order is broken, and it should fail until fixed.

FAQ

What is WebApplicationFactory in ASP.NET Core?

A class that boots your application inside the test process and hands out HttpClient instances that call it in memory. Requests run through the complete real pipeline, routing, middleware, model binding, and serialization, without opening a network socket, and the factory supports overriding services and configuration for the test run.

Why do I need a partial Program class for integration tests?

Top-level statements generate an internal Program class, and WebApplicationFactory needs to reference that type from the test project. Adding public partial class Program { } at the bottom of Program.cs makes the entry point visible to the test assembly without changing anything at runtime.

How do I replace a service in WebApplicationFactory tests?

Call WithWebHostBuilder and use ConfigureTestServices inside it to register replacements. Those registrations are added after the application’s own, and last registration wins. Remove the original with RemoveAll if it was registered by interface, then add your fake or fixed implementation.

Which database should integration tests use?

SQLite in-memory with the connection held open is the default choice: fast, SQL-capable, and close to production behavior for application code. Testcontainers running the real PostgreSQL is the high-fidelity option for provider-specific behavior and migration testing. Avoid the EF in-memory provider for integration tests, since it translates nothing to SQL and behaves differently from a database.

How do I test authenticated endpoints with WebApplicationFactory?

Register a test authentication scheme in ConfigureTestServices with a handler that authenticates a principal carrying the claims your test needs. The app’s real authorization policies and [Authorize] attributes then evaluate that principal, so you test the actual rules without minting tokens or depending on an identity provider.

Test the seams the client calls

Unit tests protect your rules. Integration tests protect the contract, the routes, the validation, the error shapes, and the auth wiring, that clients depend on and that production breakage proves. The factory keeps the cost low enough to run both on every build. The seams it exposes are the same ones the production container already owns. The architecture track comes next: clean architecture in .NET, and every guide in the series lives on the .NET series index. The rule of thumb: if a client can observe it, an integration test should pin it. If only a developer can observe it, a unit test should.

Last updated on 15 November 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *