WebApplicationFactory — In-Memory API Testing

📋 Table of Contents
  1. BlogAppFactory Setup
  2. Common Mistakes

WebApplicationFactory<TEntryPoint> spins up the complete ASP.NET Core application in memory — the same routing, middleware, DI container, and controllers as production, but with a test HTTP client instead of a real socket. Integration tests using WebApplicationFactory test the entire vertical slice: from HTTP request parsing through controller action to database (or test substitute) and back. They are slower than unit tests but catch bugs that unit tests miss — middleware ordering, model binding, validation pipeline, response serialisation.

BlogAppFactory Setup

// ── Custom WebApplicationFactory ──────────────────────────────────────────
public class BlogAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    // Unique DB name per factory instance to enable parallel test classes
    private readonly string _dbName = $"BlogApp_Test_{Guid.NewGuid():N}";

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // ── Replace production DbContext with test database ────────────
            // Remove production SQL Server registration
            var descriptor = services.SingleOrDefault(d =>
                d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor != null) services.Remove(descriptor);

            // Add SQLite in-memory (or LocalDB — see Lesson 3)
            services.AddDbContext<AppDbContext>(opts =>
                opts.UseSqlite($"Data Source={_dbName};Mode=Memory;Cache=Shared"));

            // ── Replace external services with test fakes ──────────────────
            // Remove production blob storage
            services.RemoveAll<IBlobStorageService>();
            services.AddSingleton<IBlobStorageService, FakeBlobStorageService>();

            // Remove production email service
            services.RemoveAll<IEmailService>();
            services.AddSingleton<IEmailService, FakeEmailService>();

            // Override JWT config for tests (use test-only key)
            services.Configure<JwtOptions>(opts =>
            {
                opts.Key      = "test-signing-key-long-enough-for-hmac-256";
                opts.Issuer   = "test-issuer";
                opts.Audience = "test-audience";
            });
        });

        builder.UseEnvironment("Test");
    }

    // ── IAsyncLifetime — create and seed database once per factory ────────
    public async Task InitializeAsync()
    {
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.EnsureCreatedAsync();  // creates schema from migrations
        await SeedTestDataAsync(db);
    }

    private static async Task SeedTestDataAsync(AppDbContext db)
    {
        // Seed a known admin user for auth tests
        var adminUser = new ApplicationUser
        {
            Id            = "admin-test-user-id",
            UserName      = "admin@test.com",
            NormalizedUserName = "ADMIN@TEST.COM",
            Email         = "admin@test.com",
            NormalizedEmail = "ADMIN@TEST.COM",
            DisplayName   = "Test Admin",
            EmailConfirmed = true,
        };
        var hasher   = new PasswordHasher<ApplicationUser>();
        adminUser.PasswordHash = hasher.HashPassword(adminUser, "Admin!123Test");
        db.Users.Add(adminUser);
        await db.SaveChangesAsync();
    }

    public new Task DisposeAsync()
    {
        // Delete the test database file
        var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared");
        conn.Close();
        return Task.CompletedTask;
    }

    // ── Factory helpers for creating test clients ─────────────────────────
    public HttpClient CreateAuthenticatedClient(string userId = "admin-test-user-id",
                                                 string[] roles = null!)
    {
        roles ??= ["Admin"];
        var token  = JwtTestHelper.GenerateToken(userId, roles,
            key: "test-signing-key-long-enough-for-hmac-256",
            issuer: "test-issuer", audience: "test-audience");
        var client = CreateClient();
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);
        return client;
    }
}

// ── Usage in test class ────────────────────────────────────────────────────
public class PostsIntegrationTests : IClassFixture<BlogAppFactory>
{
    private readonly BlogAppFactory _factory;
    private readonly HttpClient     _client;      // anonymous
    private readonly HttpClient     _adminClient; // authenticated as admin

    public PostsIntegrationTests(BlogAppFactory factory)
    {
        _factory     = factory;
        _client      = factory.CreateClient();
        _adminClient = factory.CreateAuthenticatedClient();
    }
}
Note: IAsyncLifetime (implemented on the factory) runs InitializeAsync() before any tests in the class and DisposeAsync() after all tests complete. Because the factory is shared via IClassFixture, database setup runs exactly once for all tests in a class — not once per test. This is correct for a shared seed dataset (the admin user, initial categories) but means tests must not corrupt shared data that other tests depend on. Write tests that either add unique data or restore state after modification.
Tip: Always use ConfigureTestServices (not ConfigureServices) when overriding services in WebApplicationFactory. ConfigureTestServices runs after the application’s Program.cs service registration, so it can override what was registered. ConfigureServices runs alongside the app’s registration — the last registration wins, which means the test override may be overwritten by the app’s later registration. ConfigureTestServices guarantees the test version wins.
Warning: WebApplicationFactory is expensive to create — it bootstraps the entire application including DI container compilation, middleware pipeline setup, and database schema creation. Never put WebApplicationFactory in a test method or a class constructor that runs per-test. Always share it via IClassFixture<BlogAppFactory> (one factory per test class) or ICollectionFixture<BlogAppFactory> (one factory shared across multiple test classes). Not sharing it makes integration tests 10-50x slower than necessary.

Common Mistakes

Mistake 1 — Creating WebApplicationFactory per test method (extremely slow)

❌ Wrong — new BlogAppFactory() in each [Fact]; factory created 50 times for 50 tests; 5+ seconds per test.

✅ Correct — IClassFixture<BlogAppFactory>; factory created once; all tests share it.

Mistake 2 — Using ConfigureServices instead of ConfigureTestServices (override ignored)

❌ Wrong — builder.ConfigureServices(services => ...); app’s later service registration overwrites the test override.

✅ Correct — builder.ConfigureTestServices(services => ...); runs after app registration; test override always wins.

🧠 Test Yourself

Two test classes share a BlogAppFactory via ICollectionFixture. Test class A seeds 5 posts. Test class B runs afterwards. Does class B see those 5 posts?