Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 053 / 100

Integration tests with WebApplicationFactory in practice

Test the real HTTP pipeline in memory.

Expona Admin6 min read

Integration tests with WebApplicationFactory

You've written unit tests for your business logic, but how do you know your ASP.NET Core app actually works end-to-end? Integration tests with WebApplicationFactory spin up your real application in memory, letting you test the full HTTP pipeline—middleware, routing, serialization, dependency injection—without touching a live server. In this article, we'll build a strategy for integration tests that catches bugs unit tests miss, runs fast enough to stay in your CI pipeline, and scales to hundreds of tests.

Why integration tests matter

Unit tests mock everything. They're fast and focused, but they create a false sense of security. Your service might work in isolation, but fail when it hits your middleware stack, encounters a routing conflict, or gets serialized wrong over HTTP. Integration tests run your real middleware, real routing, real serialization—everything except the database (usually). That's where WebApplicationFactory comes in.

I've debugged production issues that unit tests never caught: a filter that prevented a valid request from reaching the controller, a content negotiation bug that broke API clients, a middleware ordering problem that corrupted headers. Integration tests would have caught all three in seconds.

Spins up your app with the real middleware

WebApplicationFactory creates a test server hosting your actual application. It loads your Program.cs, registers all your middleware, and stands up an in-memory server. When you call CreateClient(), you get an HttpClient pointing to this in-memory server.

csharp
public class OrdersIntegrationTests(WebApplicationFactory factory)
    : IClassFixture>
{
    [Fact]
    public async Task GetOrders_WithValidRequest_Returns200()
    {
        var client = factory.CreateClient();
        var response = await client.GetAsync("/api/orders");
        
        response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
    }
}

That's it. Your middleware runs. Authentication middleware, authorization middleware, logging middleware—all real. If a filter rejects the request, the test catches it. If routing fails, the test fails. This is the difference between "I tested my service" and "I tested my service in production".

The test runs in milliseconds because there's no network, no database roundtrip, no real I/O. Just your code path executing as fast as it can in-process.

Override services for test doubles

But you don't want integration tests hitting your real database or calling real third-party APIs. WebApplicationFactory lets you override services with test doubles. The factory rebuilds the dependency container for tests, and you inject fakes, mocks, or stubs.

csharp
public class OrdersIntegrationTests(WebApplicationFactory factory)
    : IClassFixture>
{
    private readonly WebApplicationFactory _factory = factory
        .WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Remove the real OrderRepository
                var descriptor = services
                    .SingleOrDefault(d => d.ServiceType == typeof(IOrderRepository));
                if (descriptor != null)
                    services.Remove(descriptor);

                // Inject a fake
                services.AddScoped(_ =>
                    new FakeOrderRepository());
            });
        });

    [Fact]
    public async Task GetOrders_ReturnsFakeData()
    {
        var client = _factory.CreateClient();
        var response = await client.GetAsync("/api/orders");
        var content = await response.Content.ReadAsStringAsync();

        content.Should().Contain("Fake Order 1");
    }
}

This approach keeps your tests fast and deterministic. The FakeOrderRepository returns hardcoded data every time. No database contention, no flaky tests from external APIs. Your controller still runs the real code, but talking to predictable doubles.

I typically create a TestServiceCollection or helper that orchestrates common overrides—database, email, payment providers—to keep test setup DRY.

Catches DI, routing and serialization bugs

Unit tests can't catch three categories of bugs that integration tests find immediately:

Dependency injection misconfigurations. You register IOrderService but forget to register IOrderRepository. Your unit tests mock both, so they pass. Your integration test hits a ServiceNotRegisteredException during HTTP request handling.

Routing conflicts. Two endpoints have conflicting route templates. Your unit tests call the service directly, bypassing routing. Your integration test hits the wrong endpoint and returns the wrong status code or data.

Serialization mismatches. Your service returns an object, but a property isn't serializable, or the JSON property name doesn't match your API contract. Unit tests return the in-memory object. Integration tests serialize over HTTP and catch the bug.

csharp
public class ProductsIntegrationTests(WebApplicationFactory factory)
    : IClassFixture>
{
    private readonly HttpClient _client = factory.CreateClient();

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

        var json = await response.Content.ReadAsStringAsync();
        var doc = JsonDocument.Parse(json);
        var root = doc.RootElement;

        // Verify JSON matches contract, not just C# object shape
        root.GetProperty("productId").GetInt32().Should().Be(1);
        root.GetProperty("name").GetString().Should().NotBeNullOrEmpty();
    }

    [Fact]
    public async Task CreateProduct_WithMissingRequired_Returns400()
    {
        var request = new { name = "" }; // Missing required field
        var response = await _client.PostAsJsonAsync("/api/products", request);

        response.StatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest);
    }
}

These tests are black-box from the client's perspective. They don't know or care about your service internals. They test the actual contract your API exposes.

Run hundreds in seconds

Because WebApplicationFactory uses an in-memory server and your tests are self-contained, they're fast. I've run 200+ integration tests in under 10 seconds. Compare that to spinning up a test database, waiting for schema migrations, and hitting disk I/O.

The trick is sharing a single factory instance across multiple test classes. xUnit's IClassFixture and ICollectionFixture handle this.

csharp
[CollectionDefinition("Integration Tests")]
public class IntegrationTestCollection : ICollectionFixture>
{
    // Collection marker
}

[Collection("Integration Tests")]
public class OrdersIntegrationTests
{
    private readonly WebApplicationFactory _factory;

    public OrdersIntegrationTests(WebApplicationFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetOrders_Returns200() =>
        (await _factory.CreateClient().GetAsync("/api/orders"))
            .StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
}

[Collection("Integration Tests")]
public class ProductsIntegrationTests
{
    private readonly WebApplicationFactory _factory;

    public ProductsIntegrationTests(WebApplicationFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetProducts_Returns200() =>
        (await _factory.CreateClient().GetAsync("/api/products"))
            .StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
}

The factory is built once, then reused across all test classes in the collection. This saves startup overhead without sacrificing test isolation (each test gets its own HttpClient).

Common mistakes

Not overriding external dependencies. If you don't replace the real email service, integration tests might send real emails. Use factory overrides to inject a FakeEmailService that logs calls without sending.

Sharing mutable state between tests. If your fake repository holds state in memory, test A might populate it, then test B reads that state. Reset your fakes between tests or use a new factory instance per test class.

Testing too much business logic in integration tests. Integration tests should verify the HTTP contract: status codes, headers, JSON shape, error responses. Detailed business logic belongs in unit tests, which run faster and give clearer failure messages.

Ignoring async properly. Integration tests are async, and you must await them. Missing an await means your test doesn't actually run—it just returns a Task that nobody waits for.

Creating the factory inside the test. Creating a new WebApplicationFactory per test kills performance. Use IClassFixture or ICollectionFixture to share it.

Key takeaways

  • Integration tests with WebApplicationFactory run your real middleware, routing, and serialization in memory without a network or database.
  • Use WithWebHostBuilder to override services with test doubles; keeps tests fast and deterministic.
  • Integration tests catch DI misconfigurations, routing conflicts, and serialization bugs that unit tests miss.
  • Share a single factory across test classes with ICollectionFixture to keep test suites fast (hundreds of tests in seconds).
  • Focus integration tests on the HTTP contract—status codes, JSON shape, error handling—not business logic details.
  • Always override external services (database, email, APIs) to avoid side effects and flakiness.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles