Overview
WebApplicationFactory, TestServer, and HttpClient are commonly used to write integration tests for ASP.NET Core applications. These tests start the application in a test host, send real HTTP requests to the application, and verify real HTTP responses.
This type of testing sits between unit testing and end-to-end testing.
A unit test usually tests one class or function in isolation. An end-to-end test usually drives the application through a real browser, real network port, real frontend, and real deployed-like environment. An ASP.NET Core integration test using WebApplicationFactory tests the server-side application pipeline in memory. It can exercise routing, middleware, dependency injection, authentication, authorization, model binding, validation, filters, endpoint execution, JSON serialization, exception handling, and database integration if configured.
This topic matters because many bugs in ASP.NET Core applications do not appear in isolated unit tests. For example:
- A route is incorrectly mapped.
- Middleware order is wrong.
[Authorize]blocks a request unexpectedly.- Model validation returns a different response than expected.
- Dependency injection is misconfigured.
- A controller action works in isolation but fails through HTTP.
- A Minimal API endpoint does not bind parameters correctly.
- Error middleware returns the wrong
ProblemDetailsresponse. - The database configuration used by tests does not match relational behavior.
- Authentication behaves differently from the expected production flow.
WebApplicationFactory<TEntryPoint> helps test these problems by bootstrapping the application similarly to how it runs normally, but with test-specific configuration. It creates a TestServer and exposes an HttpClient that can call the application without opening a real network socket.
This topic is important for interviews because it tests practical ASP.NET Core testing knowledge. Interviewers often ask:
- What is
WebApplicationFactoryused for? - What does
TestServertest? - How is this different from unit testing or E2E testing?
- How do you expose the
Programclass for integration tests? - How do you replace production services with test services?
- How do you test authenticated endpoints?
- How do you test middleware behavior?
- How do you test validation and error responses?
- How do you configure a test database?
- How do you avoid tests sharing state?
- What are the limitations of in-memory test servers?
- When should you use a real database container instead of an in-memory provider?
A strong answer should explain that WebApplicationFactory is not just for testing controllers. It tests the server pipeline through HTTP. It is most valuable when you want confidence that the application is wired correctly, endpoints behave correctly, and important cross-cutting behavior works as expected.
Core Concepts
What Full ASP.NET Core Pipeline Testing Means
Full ASP.NET Core pipeline testing means sending a request through the application almost the same way a client would.
A request can pass through:
- Host configuration.
- Dependency injection.
- Middleware.
- Routing.
- Authentication.
- Authorization.
- CORS behavior if configured in the test path.
- Antiforgery behavior when relevant.
- Endpoint filters.
- MVC filters.
- Model binding.
- Model validation.
- Controllers or Minimal API handlers.
- Application services.
- EF Core database access.
- Exception handling middleware.
- Response formatting.
- JSON serialization.
- Status code pages.
ProblemDetailsgeneration.- Response headers and cookies.
Example test target:
app.UseExceptionHandler();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/api/orders/{id:int}", async (
int id,
IOrderService orderService,
CancellationToken cancellationToken) =>
{
var order = await orderService.GetByIdAsync(id, cancellationToken);
return order is null
? Results.NotFound()
: Results.Ok(order);
})
.RequireAuthorization();
A full pipeline integration test can verify that:
- The route matches.
- The route constraint works.
- Authentication and authorization run.
- The endpoint receives the route parameter.
- The service is resolved from DI.
- The response status is correct.
- The response body is serialized correctly.
- Errors are handled consistently.
This is more realistic than directly calling the handler method in a unit test.
WebApplicationFactory<TEntryPoint>
WebApplicationFactory<TEntryPoint> is a test utility from Microsoft.AspNetCore.Mvc.Testing. It bootstraps an ASP.NET Core application for integration tests.
Typical package reference:
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
</ItemGroup>
A basic xUnit test:
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
public sealed class HealthEndpointTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public HealthEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task GetHealth_ReturnsOk()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
TEntryPoint is usually the application Program class.
Exposing Program for Tests
Modern ASP.NET Core apps often use top-level statements in Program.cs. The generated Program class may be internal. The test project needs access to it.
A common solution is to add this at the bottom of the app's Program.cs:
public partial class Program
{
}
Example:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
public partial class Program
{
}
Then the test can reference:
WebApplicationFactory<Program>
Another option is to use InternalsVisibleTo, but the public partial Program approach is simple and common.
TestServer
TestServer is an in-memory ASP.NET Core server implementation used for testing. WebApplicationFactory creates a TestServer internally.
Important characteristics:
- It runs the ASP.NET Core application in memory.
- It does not require opening a real TCP port.
- It does not require Kestrel.
- It can create an
HttpClient. - It dispatches
HttpRequestMessageobjects into the ASP.NET Core pipeline. - It is fast compared with real network E2E tests.
- It is suitable for server-side integration tests.
However, TestServer is not the same as a real deployed server.
It does not fully test:
- Real network behavior.
- Kestrel socket behavior.
- TLS termination.
- Reverse proxy configuration.
- Browser behavior.
- JavaScript execution.
- Real DNS.
- Real load balancer behavior.
- CDN or gateway behavior.
- Production hosting differences.
- End-to-end frontend interaction.
For those concerns, use E2E tests, browser tests, deployed environment tests, or real server tests.
HttpClient in Integration Tests
WebApplicationFactory.CreateClient() returns an HttpClient configured to send requests to the in-memory TestServer.
Example:
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/products");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
You can also configure the client:
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
BaseAddress = new Uri("https://localhost")
});
Important options:
AllowAutoRedirect = false is especially useful when testing authentication redirects or verifying Location headers.
Basic API Test Example
Example controller:
[ApiController]
[Route("api/customers")]
public sealed class CustomersController : ControllerBase
{
[HttpGet("{id:int}")]
public ActionResult<CustomerDto> GetById(int id)
{
if (id == 1)
{
return Ok(new CustomerDto
{
Id = 1,
Name = "Alice"
});
}
return NotFound();
}
}
public sealed class CustomerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
Integration test:
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
public sealed class CustomersApiTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public CustomersApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task GetById_WhenCustomerExists_ReturnsCustomer()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/customers/1");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var customer = await response.Content
.ReadFromJsonAsync<CustomerDto>();
Assert.NotNull(customer);
Assert.Equal(1, customer.Id);
Assert.Equal("Alice", customer.Name);
}
[Fact]
public async Task GetById_WhenCustomerDoesNotExist_ReturnsNotFound()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/customers/999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
This tests the application through HTTP rather than directly calling the controller method.
Testing Minimal APIs
WebApplicationFactory works well with Minimal APIs.
Example endpoint:
app.MapGet("/api/products/{id:int}", (int id) =>
{
return id == 1
? Results.Ok(new ProductDto(1, "Keyboard"))
: Results.NotFound();
});
public sealed record ProductDto(int Id, string Name);
Test:
public sealed class ProductsApiTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public ProductsApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task GetProduct_WhenRouteMatches_ReturnsProduct()
{
using var client = _factory.CreateClient();
var product = await client.GetFromJsonAsync<ProductDto>(
"/api/products/1");
Assert.NotNull(product);
Assert.Equal("Keyboard", product.Name);
}
}
This verifies route matching, parameter binding, endpoint execution, and response serialization.
Custom WebApplicationFactory
Most real integration tests need custom configuration. A common approach is to derive from WebApplicationFactory<Program>.
Example:
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
public sealed class CustomWebApplicationFactory
: WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Replace production services here.
});
}
}
Test:
public sealed class OrdersApiTests
: IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
public OrdersApiTests(CustomWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetOrders_ReturnsOk()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
}
}
A custom factory keeps test setup reusable and avoids duplicating configuration in every test class.
ConfigureWebHost
ConfigureWebHost lets tests customize the app host.
Common customizations:
- Set environment to
Testing. - Replace the database.
- Replace external services with fakes.
- Override configuration values.
- Add test authentication.
- Seed test data.
- Configure test logging.
- Remove hosted services that should not run in tests.
Example:
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
}
This modifies the service collection before the test server is built.
ConfigureTestServices
ConfigureTestServices is commonly used to override services specifically for tests.
Example:
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
});
})
.CreateClient();
This is useful when one test or one test class needs a specific fake or stub.
Use ConfigureWebHost in a custom factory for common test setup.
Use WithWebHostBuilder plus ConfigureTestServices for per-test customizations.
Replacing Services
To replace an existing service, remove the old registration and add a new one.
Example:
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
service => service.ServiceType == typeof(IEmailSender));
if (descriptor is not null)
{
services.Remove(descriptor);
}
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
A reusable extension can help:
public static class ServiceCollectionTestExtensions
{
public static IServiceCollection ReplaceService<TService, TImplementation>(
this IServiceCollection services)
where TService : class
where TImplementation : class, TService
{
var descriptor = services.SingleOrDefault(
service => service.ServiceType == typeof(TService));
if (descriptor is not null)
{
services.Remove(descriptor);
}
services.AddScoped<TService, TImplementation>();
return services;
}
}
Usage:
services.ReplaceService<IEmailSender, FakeEmailSender>();
Replacing the Database
Integration tests should use a database strategy that matches the test goal.
Options:
For ASP.NET Core + EF Core integration tests, SQLite in-memory is often better than EF Core InMemory when relational behavior matters.
SQLite In-Memory Test Database Example
For SQLite in-memory, keep the connection open for the lifetime of the factory. If the connection closes, the database disappears.
Example custom factory:
using System.Data.Common;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
public sealed class CustomWebApplicationFactory
: WebApplicationFactory<Program>
{
private DbConnection? _connection;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
var dbContextDescriptor = services.SingleOrDefault(
service => service.ServiceType ==
typeof(DbContextOptions<AppDbContext>));
if (dbContextDescriptor is not null)
{
services.Remove(dbContextDescriptor);
}
var dbConnectionDescriptor = services.SingleOrDefault(
service => service.ServiceType == typeof(DbConnection));
if (dbConnectionDescriptor is not null)
{
services.Remove(dbConnectionDescriptor);
}
services.AddSingleton<DbConnection>(_ =>
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
return _connection;
});
services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
var connection = serviceProvider
.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
using var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var context = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
context.Database.EnsureCreated();
SeedTestData(context);
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_connection?.Dispose();
}
private static void SeedTestData(AppDbContext context)
{
context.Customers.Add(new Customer
{
Name = "Test Customer"
});
context.SaveChanges();
}
}
This pattern provides relational database behavior without requiring a full external database server.
Testcontainers and Real Databases
For higher confidence, tests can run against a real database in a container, such as SQL Server, PostgreSQL, or MySQL.
This is useful when tests depend on:
- Provider-specific SQL behavior.
- Real migrations.
- Real constraints.
- Real indexes.
- Transactions.
- Stored procedures.
- JSON columns.
- Case sensitivity or collation.
- Query performance behavior.
- Database-specific functions.
Trade-offs:
- Slower than in-memory tests.
- Requires Docker or container support.
- More setup complexity.
- Must manage test data cleanup.
- More moving parts in CI.
A practical testing strategy often combines:
- Many unit tests.
- A focused set of integration tests with
WebApplicationFactory. - Database integration tests using SQLite or real containers.
- A small number of E2E tests in a deployed-like environment.
Seeding Test Data
Test data should be deterministic and isolated.
Example:
public static class TestDataSeeder
{
public static void Seed(AppDbContext context)
{
context.Customers.AddRange(
new Customer
{
Id = 1,
Name = "Alice"
},
new Customer
{
Id = 2,
Name = "Bob"
});
context.SaveChanges();
}
}
Common seeding approaches:
- Seed once per test class.
- Reseed before each test.
- Use unique database names per test.
- Use transaction rollback.
- Use Respawn-style database reset.
- Use test data builders.
- Use factory methods for request DTOs.
Avoid tests depending on execution order.
Bad:
[Fact]
public async Task TestA_CreatesCustomer()
{
// Creates customer needed by TestB.
}
[Fact]
public async Task TestB_UsesCustomerFromTestA()
{
// Bad: depends on TestA running first.
}
Each test should arrange the data it needs or use a known seeded baseline.
Testing Authentication
For integration tests, you usually do not want to call a real identity provider. Instead, add a test authentication scheme.
Test authentication handler:
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
public sealed class TestAuthHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Test";
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
new Claim(ClaimTypes.Name, "Test User"),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
Configure it in tests:
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName,
options => { });
});
})
.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
Now protected endpoints can be tested without real JWT validation or external sign-in.
Testing Authorization
Example endpoint:
app.MapGet("/api/admin/users", () => Results.Ok())
.RequireAuthorization(policy => policy.RequireRole("Admin"));
Test success:
[Fact]
public async Task AdminEndpoint_WithAdminUser_ReturnsOk()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
var response = await client.GetAsync("/api/admin/users");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
Test unauthenticated behavior:
[Fact]
public async Task AdminEndpoint_WithoutUser_ReturnsUnauthorizedOrRedirect()
{
using var client = _factory.CreateClient(
new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var response = await client.GetAsync("/api/admin/users");
Assert.True(
response.StatusCode == HttpStatusCode.Unauthorized ||
response.StatusCode == HttpStatusCode.Redirect);
}
For API projects, unauthenticated requests usually return 401 Unauthorized. For cookie-based apps, unauthenticated requests may redirect to login.
Testing Redirects
By default, HttpClient from CreateClient() may follow redirects. That can hide the original response.
Example: if an unauthenticated request redirects to /login, automatic redirect can make the final response 200 OK, even though the first response was 302 Redirect.
To test redirects:
using var client = _factory.CreateClient(
new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var response = await client.GetAsync("/admin");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Contains("/login", response.Headers.Location?.OriginalString);
This is important for authentication tests and MVC/Razor Pages tests.
Testing Model Binding and Validation
Because WebApplicationFactory sends real HTTP requests, it can test model binding and validation.
Example request:
public sealed class CreateProductRequest
{
[Required]
public string Name { get; set; } = string.Empty;
[Range(0.01, 10000)]
public decimal Price { get; set; }
}
Controller:
[ApiController]
[Route("api/products")]
public sealed class ProductsController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
return Created($"/api/products/1", new { Id = 1 });
}
}
Test invalid request:
[Fact]
public async Task CreateProduct_WhenRequestIsInvalid_ReturnsBadRequest()
{
using var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/products", new
{
Name = "",
Price = -1
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("errors", body);
}
This verifies [ApiController] automatic validation behavior, model binding, response formatting, and HTTP status code.
Testing ProblemDetails and Error Middleware
Integration tests are useful for verifying consistent error responses.
Example:
[Fact]
public async Task UnknownEndpoint_ReturnsProblemDetailsOrNotFound()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/does-not-exist");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
Example for validation problem details:
[Fact]
public async Task CreateProduct_InvalidBody_ReturnsValidationProblemDetails()
{
using var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/products", new
{
Price = -5
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content
.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.NotNull(problem);
Assert.NotEmpty(problem.Errors);
}
This is difficult to test fully with only controller unit tests because the response can be produced by filters, middleware, or framework behavior.
Testing Middleware
Integration tests are excellent for middleware behavior.
Example custom middleware:
public sealed class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-Id"]
.FirstOrDefault() ?? Guid.NewGuid().ToString("N");
context.Response.Headers["X-Correlation-Id"] = correlationId;
await _next(context);
}
}
Test:
[Fact]
public async Task Request_WithCorrelationId_ReturnsSameCorrelationId()
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
request.Headers.Add("X-Correlation-Id", "test-correlation-id");
var response = await client.SendAsync(request);
Assert.True(response.Headers.TryGetValues(
"X-Correlation-Id",
out var values));
Assert.Contains("test-correlation-id", values);
}
This verifies the middleware in the actual pipeline order.
Testing Headers, Cookies, and Content Types
Integration tests can verify HTTP-level details.
Example:
[Fact]
public async Task GetProducts_ReturnsJsonContentType()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/products");
response.EnsureSuccessStatusCode();
Assert.Equal(
"application/json",
response.Content.Headers.ContentType?.MediaType);
}
Cookie test:
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true
});
HTTP details are part of the contract. Integration tests are a good place to verify them.
Testing Configuration
Tests often need configuration overrides.
Example using environment:
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
}
Example overriding configuration values:
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configBuilder) =>
{
var testSettings = new Dictionary<string, string?>
{
["FeatureFlags:UseFakePayments"] = "true",
["ExternalServices:Payments:BaseUrl"] = "http://localhost/fake"
};
configBuilder.AddInMemoryCollection(testSettings);
});
}
This is useful for replacing production URLs, disabling background jobs, using test connection strings, or enabling test-only behavior.
Testing External Dependencies
External systems should usually be replaced in integration tests unless the goal is to test the external integration itself.
Examples:
Example fake:
public sealed class FakeEmailSender : IEmailSender
{
public List<EmailMessage> SentMessages { get; } = new();
public Task SendAsync(EmailMessage message)
{
SentMessages.Add(message);
return Task.CompletedTask;
}
}
Register:
services.AddSingleton<IEmailSender, FakeEmailSender>();
The test can later resolve the fake from the factory service provider if needed.
Accessing Services from the Factory
Sometimes a test needs to access services from the test host, such as a fake service or database context.
Example:
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var order = await context.Orders.SingleAsync(o => o.Id == orderId);
This can be useful for:
- Seeding data.
- Verifying database state.
- Inspecting fake service calls.
- Resetting state between tests.
Be careful not to bypass the HTTP API too much. Use direct service access mainly for arrange and assert steps, not for the actual behavior being tested.
Arrange-Act-Assert Pattern
Integration tests should still follow Arrange-Act-Assert.
Example:
[Fact]
public async Task CreateOrder_WithValidRequest_PersistsOrder()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
context.Customers.Add(new Customer { Id = 1, Name = "Alice" });
await context.SaveChangesAsync();
using var client = _factory.CreateClient();
var request = new CreateOrderRequest
{
CustomerId = 1,
ProductId = 10,
Quantity = 2
};
// Act
var response = await client.PostAsJsonAsync("/api/orders", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var orderExists = await context.Orders
.AnyAsync(o => o.CustomerId == 1);
Assert.True(orderExists);
}
The actual behavior is exercised through HTTP. Direct database access is used only to set up and verify.
Test Isolation
Integration tests must avoid hidden dependencies between tests.
Common isolation strategies:
- New database per test.
- New database per test class.
- Clean database before each test.
- Transaction rollback after each test.
- Unique test data per test.
- Testcontainers with reset logic.
- Respawn-style cleanup.
- SQLite in-memory database per factory.
- Disable parallelization for tests sharing the same database.
Bad pattern:
// Test B depends on data created by Test A.
Good pattern:
// Every test creates or resets the data it needs.
Test isolation is critical because integration tests are often slower and more stateful than unit tests.
xUnit Fixtures
WebApplicationFactory is commonly used with xUnit fixtures.
Class fixture:
public sealed class OrdersApiTests
: IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
public OrdersApiTests(CustomWebApplicationFactory factory)
{
_factory = factory;
}
}
A class fixture is created once for the test class and shared by tests in that class.
Collection fixture:
[CollectionDefinition("Integration tests")]
public sealed class IntegrationTestCollection
: ICollectionFixture<CustomWebApplicationFactory>
{
}
Usage:
[Collection("Integration tests")]
public sealed class OrdersApiTests
{
private readonly CustomWebApplicationFactory _factory;
public OrdersApiTests(CustomWebApplicationFactory factory)
{
_factory = factory;
}
}
Collection fixtures are useful when multiple test classes share the same factory or database setup.
Parallel Test Execution
Parallel tests can interfere with each other if they share state.
Common problems:
- Two tests write to the same database.
- One test deletes data another test expects.
- Shared fake services keep old state.
- Shared static values leak between tests.
- A test changes configuration globally.
Options:
- Disable parallelization for integration test collection.
- Use a unique database per test.
- Reset database before each test.
- Make test data unique.
- Avoid mutable shared fakes.
- Create a new factory per test when necessary.
Example xUnit collection to group non-parallel tests:
[CollectionDefinition("Integration tests", DisableParallelization = true)]
public sealed class IntegrationTestCollection
: ICollectionFixture<CustomWebApplicationFactory>
{
}
Use this carefully. Disabling parallelization can make tests slower, but it may be needed for stateful integration tests.
WebApplicationFactory vs Direct TestServer
You can use TestServer directly, but WebApplicationFactory is usually easier for testing a real ASP.NET Core app.
WebApplicationFactory benefits:
- Discovers and boots the real application entry point.
- Creates a test host.
- Provides
CreateClient(). - Supports
WithWebHostBuilder. - Integrates with
Microsoft.AspNetCore.Mvc.Testing. - Makes service replacement patterns convenient.
- Works well with minimal hosting and top-level
Program.
Direct TestServer can be useful when:
- You are testing middleware in isolation.
- You are building a very small test-specific pipeline.
- You do not need to boot the full application.
- You want full control over the host builder.
Example direct TestServer style:
var builder = new WebHostBuilder()
.Configure(app =>
{
app.Run(async context =>
{
await context.Response.WriteAsync("Hello from TestServer");
});
});
using var server = new TestServer(builder);
using var client = server.CreateClient();
var response = await client.GetStringAsync("/");
Assert.Equal("Hello from TestServer", response);
For application-level integration tests, prefer WebApplicationFactory.
What These Tests Do Not Cover
WebApplicationFactory and TestServer are powerful, but they do not cover everything.
They usually do not test:
- Real browser behavior.
- Frontend JavaScript.
- CSS and layout.
- Real network sockets.
- Kestrel-specific behavior.
- TLS certificate behavior.
- Reverse proxy headers from a real proxy.
- CDN behavior.
- Production gateway behavior.
- Real third-party identity provider login.
- Real cloud service permissions.
- Real load, scale, and concurrency behavior.
- Actual deployed environment configuration.
For those concerns, use E2E tests, browser automation, smoke tests, contract tests, load tests, staging tests, or infrastructure tests.
What to Test with WebApplicationFactory
Good candidates:
- Important API endpoints.
- Authentication and authorization behavior.
- Model validation responses.
- Routing and parameter binding.
- Middleware behavior.
- Error handling and
ProblemDetails. - JSON serialization shape.
- Database persistence through the API.
- DI configuration.
- Filters and endpoint filters.
- Health checks.
- Versioned API behavior.
- CORS/preflight behavior when relevant.
- Headers and cookies.
- Idempotency behavior.
- Transaction boundaries.
- Important business workflows.
Avoid using integration tests for every tiny code path. Keep unit tests for pure business logic and integration tests for system wiring and important request flows.
Common Mistakes
Common mistakes include:
- Treating
WebApplicationFactorytests as unit tests. - Testing only controllers directly and missing middleware/routing behavior.
- Not exposing
Programcorrectly. - Using EF Core InMemory provider for relational behavior tests.
- Sharing one database across tests without cleanup.
- Depending on test execution order.
- Letting tests call real external services accidentally.
- Leaving production hosted services running during tests.
- Testing authenticated endpoints without a proper test authentication scheme.
- Forgetting
AllowAutoRedirect = falsewhen testing redirects. - Returning success because the client followed a redirect.
- Not setting the test environment.
- Seeding too much data globally.
- Mutating shared fake services across parallel tests.
- Making integration tests too broad and slow.
- Asserting only
200 OKand not checking response content. - Not testing negative cases like
400,401,403, and404. - Ignoring cancellation, headers, cookies, and content type when they matter.
- Using full pipeline tests for logic that would be better covered by fast unit tests.
Best Practices
Use WebApplicationFactory<Program> for ASP.NET Core server-side integration tests.
Add public partial class Program to expose the app entry point to the test project.
Create a custom factory for common test configuration.
Set the environment to Testing.
Replace external dependencies with fakes or test doubles.
Use a relational test database when relational behavior matters.
Use real database containers for provider-specific behavior and higher confidence.
Keep test data deterministic and isolated.
Do not depend on test order.
Use AllowAutoRedirect = false when testing redirects or authentication challenges.
Use a test authentication scheme for protected endpoints.
Assert status code, headers, content type, and response body when relevant.
Use direct service access mainly for arrange and assert steps.
Keep integration tests focused on meaningful request flows.
Use unit tests for isolated business logic.
Use E2E tests for frontend, browser, real network, and deployed environment behavior.