Skip to content

Integration Testing with WebApplicationFactory

Key Points

  • WebApplicationFactory<TEntryPoint> spins up your full app in-memory for tests — middleware, DI, controllers, EF — without binding a real port.
  • TEntryPoint = your Program class. With top-level Program.cs, expose via public partial class Program; so test project can reference it.
  • Override services in ConfigureWebHost / ConfigureTestServices: swap in test doubles (in-memory DB, fake auth handler, fake clock).
  • HttpClient from factory points to your in-memory app. Real HTTP semantics; no network.
  • Pair with Testcontainers for real DB/queue dependencies. Pure in-memory providers diverge from real engines.

Concepts (deep dive)

Setup

// Program.cs (top-level statements)
var builder = WebApplication.CreateBuilder(args);
// ...
var app = builder.Build();
// ...
app.Run();

public partial class Program; // ← required for test access
// MyApi.IntegrationTests.csproj
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="xunit" />

public class HelloTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    public HelloTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();

    [Fact]
    public async Task GET_root_returns_ok()
    {
        var r = await _client.GetAsync("/");
        Assert.Equal(HttpStatusCode.OK, r.StatusCode);
    }
}

That's the whole rig. Full middleware pipeline runs; routing, auth, model binding, etc.

Overriding services

public class CustomFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Swap real EF DB for SQLite in-memory:
            services.RemoveAll<DbContextOptions<AppDb>>();
            services.AddDbContext<AppDb>(o => o.UseSqlite("DataSource=:memory:"));

            // Or replace IClock with deterministic:
            services.AddSingleton<IClock>(new FakeClock(DateTimeOffset.UtcNow));
        });

        builder.UseEnvironment("Testing");
    }
}

ConfigureTestServices runs after ConfigureServices from Program.cs, so swaps win.

Testing protected endpoints

Two approaches:

1. Test auth handler:

public class TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> opts,
    ILoggerFactory log, UrlEncoder enc) : AuthenticationHandler<AuthenticationSchemeOptions>(opts, log, enc)
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[] { new Claim(ClaimTypes.Name, "TestUser"), new Claim("sub", "u-123") };
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "Test");
        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

services.AddAuthentication(d => { d.DefaultAuthenticateScheme = "Test"; d.DefaultChallengeScheme = "Test"; })
    .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });

2. Real JWT validation with test signing:

Issue tokens with a known signing key in tests; configure the API to accept them.

EF Core + real DB

public class CustomFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private PostgreSqlContainer _pg = new PostgreSqlBuilder().Build();

    public async Task InitializeAsync()
    {
        await _pg.StartAsync();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDb>>();
            services.AddDbContext<AppDb>(o => o.UseNpgsql(_pg.GetConnectionString()));
        });
    }

    public new async Task DisposeAsync()
    {
        await _pg.DisposeAsync();
        await base.DisposeAsync();
    }
}

Each fixture spins a Postgres container. Real engine = high-fidelity tests. Slower than in-memory but worth it for repository tests.

Seeding data

[Fact]
public async Task GetUser_returns_seeded()
{
    using var scope = factory.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDb>();
    db.Users.Add(new User { Id = 1, Name = "Alice" });
    await db.SaveChangesAsync();

    var r = await client.GetAsync("/users/1");
    var user = await r.Content.ReadFromJsonAsync<UserDto>();
    Assert.Equal("Alice", user!.Name);
}

For test isolation: Respawn library to reset DB state between tests:

var checkpoint = await Respawner.CreateAsync(connStr, new RespawnerOptions { /* ... */ });
await checkpoint.ResetAsync(connStr);

Customizing HttpClient

var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
    AllowAutoRedirect = false,
    BaseAddress = new Uri("https://localhost"),
    HandleCookies = true
});

Sharing factory across classes

[CollectionDefinition("API")]
public class ApiCollection : ICollectionFixture<CustomFactory> { }

[Collection("API")]
public class UsersTests { /* ... */ }

[Collection("API")]
public class OrdersTests { /* ... */ }

One factory across many test classes — saves ~1s per class on big suites.

Configuration overrides

builder.ConfigureAppConfiguration((ctx, config) =>
{
    config.AddInMemoryCollection(new Dictionary<string, string?>
    {
        ["FeatureFlags:NewBilling"] = "true",
        ["ConnectionStrings:Default"] = _pg.GetConnectionString()
    });
});

Authentication handlers vs token issuance

For OAuth flows, mocking the IdP is annoying. Easier to:

  1. Use a test auth scheme as default (above).
  2. Expose a /test/token endpoint that issues real JWTs in test environment, signed with a known key.

Testing minimal APIs

Same pattern works. WebApplicationFactory<Program> doesn't care if the endpoints are MVC or Minimal API.

WebApplicationFactory vs TestServer

WebApplicationFactory wraps TestServer. Direct TestServer is more bare-bones; rarely needed.

CI/CD considerations

  • Container images cached for Testcontainers (docker pull first).
  • Parallel test classes can starve memory (each spins its own container). Use shared collection fixture.
  • Logs from app: factory exposes factory.Server and you can attach a LoggerProvider.

Performance tips

  • Avoid creating a new factory per test (slow boot).
  • Reuse via IClassFixture / ICollectionFixture.
  • Use Respawn or transactions for cleanup, not full DB reset.
  • Tests should target ~hundreds of ms each for integration tests; if seconds, optimize.

Code: correct vs wrong

❌ Wrong: missing public partial class Program;

error CS0122: 'Program' is inaccessible due to its protection level

✅ Correct: declare partial

// at the bottom of Program.cs
public partial class Program;

❌ Wrong: real prod DB in tests

services.AddDbContext<AppDb>(o => o.UseSqlServer("Server=prod;..."));   // accidental data corruption

✅ Correct: separate test DB

services.RemoveAll<DbContextOptions<AppDb>>();
services.AddDbContext<AppDb>(o => o.UseNpgsql(_testContainer.GetConnectionString()));

❌ Wrong: shared HttpClient with mutable state

client.DefaultRequestHeaders.Authorization = new(...);   // pollutes other tests using the same client

✅ Correct: per-request headers or per-test client

var req = new HttpRequestMessage(HttpMethod.Get, "/x");
req.Headers.Authorization = new(...);
await client.SendAsync(req);

Design patterns for this topic

Pattern 1 — "Test auth handler for protected endpoints"

  • Intent: bypass real OIDC; inject test claims.

Pattern 2 — "Testcontainer per fixture"

  • Intent: real DB; isolated per test class/collection.

Pattern 3 — "Respawn between tests"

  • Intent: fast DB reset without container restart.

Pattern 4 — "Shared collection fixture"

  • Intent: one factory across many classes.

Pattern 5 — "Config overrides via in-memory provider"

  • Intent: per-test feature flags.

Pros & cons / trade-offs

Approach Pros Cons
In-memory factory Fast; full pipeline Slower than unit tests
In-memory DB Fast Diverges from real engine
Testcontainers High fidelity Docker dep; slower
Test auth handler Skip real OIDC Doesn't test actual auth
Real JWT signing Tests auth too More setup

When to use / when to avoid

  • Always integration tests for auth, routing, validation.
  • Use Testcontainers for repository/DB tests.
  • Use in-memory DB only for fast smoke tests, not query correctness.
  • Avoid hitting the prod environment from tests.

Interview Q&A

Q1. What does WebApplicationFactory<Program> do? Boots your app in-memory using Program as entry point. Returns an HttpClient that calls the app without network.

Q2. Why public partial class Program;? With top-level statements, Program is internal. Declaring partial makes it accessible to the test assembly.

Q3. ConfigureServices vs ConfigureTestServices? ConfigureTestServices runs after the app's normal services; replacements/overrides win.

Q4. How test endpoints requiring auth? Add a test authentication scheme that always succeeds with desired claims, OR issue real JWTs signed with a known key.

Q5. In-memory EF vs Sqlite vs Testcontainers? In-memory: fastest, but diverges from real SQL semantics. Sqlite: closer but still differs (e.g., concurrency). Testcontainers: real engine, highest fidelity, slower.

Q6. How clean DB between tests? Respawn library, or transaction rollback per test, or full reset between fixtures.

Q7. Can I share a factory across test classes? Yes — ICollectionFixture<TFactory>. One factory, many classes.

Q8. How configure feature flags per-test? ConfigureAppConfiguration with AddInMemoryCollection.

Q9. How log output go in tests? Add XunitLogger provider to capture; attach via ConfigureLogging.

Q10. What's the difference between HttpClient from factory vs real? Factory's HttpClient uses an in-process handler — no socket, no port. Same HttpClient API.

Q11. Should integration tests cover the full happy path or also edge cases? Happy path + auth/validation/error handling. Pure business-logic edges are unit-tested cheaper.

Q12. Memory issues running many factory instances? Yes — each boots the app. Reuse via collection fixture; consider partitioning test runs.


Gotchas / common mistakes

  • ⚠️ Forgetting public partial class Program;.
  • ⚠️ Real prod connection string leaking into tests.
  • ⚠️ Shared mutable HttpClient state.
  • ⚠️ In-memory DB for query correctness — diverges from real SQL.
  • ⚠️ No DB cleanup — tests pollute each other.
  • ⚠️ One factory per test method — slow.

Further reading