Learnixo
Back to blog
AI Systemsintermediate

WebApplicationFactory — Real HTTP Tests Without a Server

Test ASP.NET Core APIs end-to-end with WebApplicationFactory: in-memory HTTP client, service replacement, authentication setup, and the patterns for fast integration tests that catch real bugs.

Asma Hafeez KhanMay 16, 20265 min read
TestingWebApplicationFactoryIntegration TestsASP.NET Core.NET
Share:𝕏

What WebApplicationFactory Does

WebApplicationFactory<TProgram> starts your entire ASP.NET Core application in-process, wired up exactly as it runs in production — middleware, DI, routing, authentication — but with an in-memory HTTP client. No network port needed.

Advantages over mocking HTTP:
  ✓ Tests real middleware pipeline (auth, rate limiting, error handling)
  ✓ Tests real routing (endpoint mapping, route constraints)
  ✓ Tests real DI (missing registrations, scoping issues)
  ✓ Tests real serialization (JSON converters, property naming)
  ✓ Fast — no TCP, no external process

Basic Setup

C#
// tests/Integration.Tests/SystemForgeWebApplicationFactory.cs
public sealed class SystemForgeWebApplicationFactory
    : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");

        builder.ConfigureTestServices(services =>
        {
            // Replace real DB with in-memory for basic integration tests
            // (Use Testcontainers for full fidelity — see next lesson)
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(options =>
                options.UseInMemoryDatabase("TestDb"));

            // Replace email service with a fake
            services.RemoveAll<IEmailService>();
            services.AddSingleton<IEmailService, FakeEmailService>();
        });
    }
}

// Test class
public class PatientsApiTests : IClassFixture<SystemForgeWebApplicationFactory>
{
    private readonly HttpClient _client;

    public PatientsApiTests(SystemForgeWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task POST_patients_should_return_201_with_id()
    {
        var response = await _client.PostAsJsonAsync("/api/patients", new
        {
            Name        = "John Smith",
            DateOfBirth = "1985-03-15",
            MRN         = "MRN-001"
        });

        response.StatusCode.Should().Be(HttpStatusCode.Created);

        var body = await response.Content.ReadFromJsonAsync<JsonElement>();
        body.GetProperty("id").GetGuid().Should().NotBeEmpty();
    }
}

Authentication in Tests

C#
// Approach 1: Issue a real JWT for the test user
public class AuthenticatedWebApplicationFactory : WebApplicationFactory<Program>
{
    private readonly string _role;

    public AuthenticatedWebApplicationFactory(string role = "Doctor")
        => _role = role;

    public HttpClient CreateAuthenticatedClient()
    {
        var client = CreateClient();
        var token  = GenerateTestToken(_role);
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);
        return client;
    }

    private static string GenerateTestToken(string role)
    {
        var key    = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-key-32-chars-minimum-length!"));
        var creds  = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,   Guid.NewGuid().ToString()),
            new Claim(ClaimTypes.Role,               role),
            new Claim(JwtRegisteredClaimNames.Email, "test@hospital.com"),
        };
        var token = new JwtSecurityToken(
            issuer:             "https://systemforge.api",
            audience:           "https://systemforge.app",
            claims:             claims,
            expires:            DateTime.UtcNow.AddHours(1),
            signingCredentials: creds);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

// Approach 2: Bypass JWT validation with test auth handler
public sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims    = new[] { new Claim(ClaimTypes.Role, "Doctor") };
        var identity  = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket    = new AuthenticationTicket(principal, "Test");
        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Service Replacement Patterns

C#
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureTestServices(services =>
    {
        // Remove existing and add fake
        services.RemoveAll<IEmailService>();
        services.AddSingleton<FakeEmailService>();
        services.AddSingleton<IEmailService>(sp =>
            sp.GetRequiredService<FakeEmailService>());

        // Override a configuration value
        services.PostConfigure<JwtOptions>(options =>
        {
            options.Key = "test-signing-key-32-chars-minimum!";
        });

        // Override time to make tests deterministic
        services.RemoveAll<IDateTimeProvider>();
        services.AddSingleton<IDateTimeProvider>(
            new FixedDateTimeProvider(new DateTime(2026, 5, 16, 8, 0, 0)));
    });
}

Accessing Services from Tests

C#
[Fact]
public async Task POST_patients_should_send_welcome_email()
{
    // Create client (triggers factory setup)
    var factory = new SystemForgeWebApplicationFactory();
    var client  = factory.CreateClient();

    // Act
    await client.PostAsJsonAsync("/api/patients", new
    {
        Name = "John Smith", DateOfBirth = "1985-03-15", MRN = "MRN-001"
    });

    // Access the fake service to verify it was called
    var fakeEmail = factory.Services.GetRequiredService<FakeEmailService>();
    fakeEmail.SentEmails.Should().HaveCount(1);
    fakeEmail.SentEmails[0].To.Should().Be("john.smith@hospital.com");
}

Database Setup with In-Memory DB

C#
// Reset DB state between tests
public class PatientsApiTests : IClassFixture<SystemForgeWebApplicationFactory>
{
    private readonly HttpClient    _client;
    private readonly AppDbContext  _db;

    public PatientsApiTests(SystemForgeWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
        _db     = factory.Services.CreateScope()
                         .ServiceProvider.GetRequiredService<AppDbContext>();
    }

    public async Task InitializeAsync()
    {
        await _db.Database.EnsureCreatedAsync();
        // Seed test data
        _db.Patients.Add(Patient.Create("Existing Patient", new DateOnly(1980, 1, 1), "MRN-EXIST").Value);
        await _db.SaveChangesAsync();
    }
}

Testing Error Scenarios

C#
[Fact]
public async Task POST_patients_with_duplicate_mrn_should_return_409()
{
    // Arrange — seed existing patient
    // ... (DB setup)

    // Act — duplicate MRN
    var response = await _client.PostAsJsonAsync("/api/patients", new
    {
        Name = "Another Patient", DateOfBirth = "1990-01-01", MRN = "MRN-DUP"
    });

    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.Conflict);
    var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
    problem!.Title.Should().Be("Patient.MRNAlreadyExists");
}

[Fact]
public async Task GET_patients_without_auth_should_return_401()
{
    var response = await _client.GetAsync("/api/patients");
    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task GET_patients_as_pharmacist_should_return_403()
{
    var pharmacistClient = _factory.CreateClientAs("Pharmacist");
    var response = await pharmacistClient.GetAsync("/api/clinical/prescriptions/schedule-ii");
    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

Custom WebApplicationFactory Extension Methods

C#
// Extension methods for readable test setup
public static class WebApplicationFactoryExtensions
{
    public static HttpClient CreateClientAs(
        this WebApplicationFactory<Program> factory,
        string role)
    {
        var client = factory.CreateClient();
        var token  = factory.Services
            .GetRequiredService<TestTokenService>()
            .Generate(role);
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);
        return client;
    }
}

// Usage
var doctorClient     = factory.CreateClientAs("Doctor");
var pharmacistClient = factory.CreateClientAs("Pharmacist");
var adminClient      = factory.CreateClientAs("Admin");

Red Flag / Green Answer

Red Flag: "Our integration tests start a real IIS server on port 5000 for each test run."

External process, network dependency, port conflicts in CI, slow startup. WebApplicationFactory starts the app in-process — no port, no IIS, starts in milliseconds.

Green Answer:

WebApplicationFactory<Program> for all HTTP-level integration tests. In-memory or Testcontainers DB. Tests run in CI without any external process running.


Key Takeaway

WebApplicationFactory creates an in-process HTTP test server with your real DI, middleware, routing, and serialization. Override services with ConfigureTestServices to replace infrastructure (DB, email) with fakes. Issue real JWTs or use a test authentication handler for auth-required endpoints. Test the full HTTP contract: status codes, response body shape, error responses, and auth failures — not just happy paths.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.