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= yourProgramclass. With top-levelProgram.cs, expose viapublic 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). HttpClientfrom 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:
- Use a test auth scheme as default (above).
- Expose a
/test/tokenendpoint 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 pullfirst). - Parallel test classes can starve memory (each spins its own container). Use shared collection fixture.
- Logs from app: factory exposes
factory.Serverand you can attach aLoggerProvider.
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;
✅ Correct: declare partial
❌ Wrong: real prod DB in tests
✅ 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
HttpClientstate. - ⚠️ In-memory DB for query correctness — diverges from real SQL.
- ⚠️ No DB cleanup — tests pollute each other.
- ⚠️ One factory per test method — slow.