Back to blog
Backend Systemsbeginner

WebApplicationFactory — Real HTTP Tests Without a Browser

Write integration tests in ASP.NET Core using WebApplicationFactory. Test real HTTP endpoints, middleware, authentication, and database interactions.

Asma HafeezApril 17, 20263 min read
dotnettestingintegration-testswebappfactoryaspnet-core
Share:𝕏

Integration Tests with WebApplicationFactory

Integration tests spin up your actual ASP.NET Core app in-memory and make real HTTP requests. They catch bugs that unit tests miss: routing, middleware, serialization, authentication.


Setup

Bash
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package FluentAssertions
XML
<!-- MyApp.Tests.csproj -->
<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.*" />
  <ProjectReference Include="..\MyApp\MyApp.csproj" />
</ItemGroup>

Basic Integration Test

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

    [Fact]
    public async Task GetProducts_ReturnsOk()
    {
        var response = await _client.GetAsync("/products");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }

    [Fact]
    public async Task GetProduct_NotFound_Returns404()
    {
        var response = await _client.GetAsync("/products/99999");

        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task CreateProduct_ValidRequest_Returns201()
    {
        var body = JsonContent.Create(new { name = "Widget", price = 9.99 });
        var response = await _client.PostAsync("/products", body);

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

        var product = await response.Content.ReadFromJsonAsync<Product>();
        product!.Name.Should().Be("Widget");
    }
}

Custom WebApplicationFactory with In-Memory Database

C#
public class TestWebFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Remove real DB
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor is not null) services.Remove(descriptor);

            // Add in-memory DB
            services.AddDbContext<AppDbContext>(options =>
                options.UseInMemoryDatabase("TestDb_" + Guid.NewGuid()));

            // Seed test data
            var sp = services.BuildServiceProvider();
            using var scope = sp.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            db.Database.EnsureCreated();
            SeedTestData(db);
        });
    }

    private static void SeedTestData(AppDbContext db)
    {
        db.Products.AddRange(
            new Product { Id = 1, Name = "Widget",  Price = 9.99m },
            new Product { Id = 2, Name = "Gadget",  Price = 49.99m },
            new Product { Id = 3, Name = "Doohickey", Price = 19.99m }
        );
        db.SaveChanges();
    }
}

// Use the custom factory
public class ProductTests(TestWebFactory factory) : IClassFixture<TestWebFactory>
{
    private readonly HttpClient _client = factory.CreateClient();

    [Fact]
    public async Task GetAll_ReturnsSeedData()
    {
        var products = await _client.GetFromJsonAsync<List<Product>>("/products");
        products!.Should().HaveCount(3);
    }
}

Testing with Authentication

C#
public class TestWebFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Replace JWT auth with a test scheme
            services.AddAuthentication("Test")
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
        });
    }
}

// Test auth handler — always authenticates as a specific user
public class TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
    ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
            new Claim(ClaimTypes.Email, "test@example.com"),
            new Claim(ClaimTypes.Role, "Admin"),
        };
        var identity  = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket    = new AuthenticationTicket(principal, "Test");
        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

// Test authenticated endpoints
[Fact]
public async Task AdminEndpoint_WithTestAuth_Succeeds()
{
    // The TestAuthHandler automatically provides Admin credentials
    var response = await _client.GetAsync("/admin/users");
    response.StatusCode.Should().Be(HttpStatusCode.OK);
}

Testing Request/Response Serialization

C#
[Fact]
public async Task CreateProduct_SerializationRoundTrip()
{
    // Arrange
    var request = new CreateProductRequest("Test Widget", 15.99m, "Electronics");

    // Act
    var postResponse = await _client.PostAsJsonAsync("/products", request);
    postResponse.EnsureSuccessStatusCode();

    var created = await postResponse.Content.ReadFromJsonAsync<ProductResponse>();
    created.Should().NotBeNull();
    created!.Id.Should().BeGreaterThan(0);
    created.Name.Should().Be("Test Widget");
    created.Price.Should().Be(15.99m);

    // Verify it's retrievable
    var getResponse = await _client.GetFromJsonAsync<ProductResponse>($"/products/{created.Id}");
    getResponse!.Name.Should().Be("Test Widget");
}

Key Takeaways

  1. WebApplicationFactory<Program> spins up your real app in-memory — tests run without a server
  2. Override ConfigureTestServices to replace real dependencies (DB, email, payment providers)
  3. Use in-memory database for fast tests without requiring a real database
  4. Custom authentication handlers let you test secured endpoints without real tokens
  5. Integration tests are slower than unit tests — run them in CI, not on every keystroke

Enjoyed this article?

Explore the Backend 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.