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.
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 processBasic Setup
// 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
// 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
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
[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
// 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
[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
// 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.
WebApplicationFactorystarts 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
WebApplicationFactorycreates an in-process HTTP test server with your real DI, middleware, routing, and serialization. Override services withConfigureTestServicesto 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.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.