WebApplicationFactory 集成测试
大约 9 分钟约 2620 字
WebApplicationFactory 集成测试
简介
集成测试验证多个组件协同工作的正确性,比单元测试更接近真实运行环境。ASP.NET Core 提供了 WebApplicationFactory,可以在内存中启动完整的应用,发送真实 HTTP 请求并验证响应。配合 TestServer 和内存数据库,可以在不依赖外部服务的情况下完成端到端测试。
特点
基础集成测试
WebApplicationFactory
/// <summary>
/// WebApplicationFactory 基础用法
/// </summary>
using Microsoft.AspNetCore.Mvc.Testing;
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 替换数据库为内存数据库
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb");
});
// 替换其他外部服务
services.RemoveAll<IEmailService>();
services.AddScoped<IEmailService, FakeEmailService>();
});
builder.UseEnvironment("Testing");
}
}
// 使用
public class UsersApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public UsersApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetUsers_ReturnsSuccessAndJson()
{
var response = await _client.GetAsync("/api/users");
response.EnsureSuccessStatusCode();
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
var users = await response.Content.ReadFromJsonAsync<List<UserDto>>();
Assert.NotNull(users);
}
}CRUD 完整测试
API 端到端测试
/// <summary>
/// 完整 CRUD 集成测试
/// </summary>
public class ProductsApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public ProductsApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateProduct_ReturnsCreated()
{
var request = new CreateProductRequest
{
Name = "测试商品",
Price = 99.99m,
Category = "电子产品"
};
var response = await _client.PostAsJsonAsync("/api/products", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var product = await response.Content.ReadFromJsonAsync<ProductDto>();
Assert.NotNull(product);
Assert.Equal("测试商品", product!.Name);
Assert.True(product.Id > 0);
}
[Fact]
public async Task GetProduct_ExistingId_ReturnsProduct()
{
// 先创建
var created = await CreateTestProductAsync("查询测试商品", 50m);
// 再查询
var response = await _client.GetAsync($"/api/products/{created!.Id}");
response.EnsureSuccessStatusCode();
var product = await response.Content.ReadFromJsonAsync<ProductDto>();
Assert.Equal("查询测试商品", product!.Name);
}
[Fact]
public async Task UpdateProduct_ValidData_ReturnsNoContent()
{
var created = await CreateTestProductAsync("旧名称", 10m);
var update = new UpdateProductRequest { Name = "新名称", Price = 20m };
var response = await _client.PutAsJsonAsync($"/api/products/{created!.Id}", update);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
[Fact]
public async Task DeleteProduct_ExistingId_ReturnsNoContent()
{
var created = await CreateTestProductAsync("待删除商品", 10m);
var response = await _client.DeleteAsync($"/api/products/{created!.Id}");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
[Fact]
public async Task GetProduct_NonExistingId_ReturnsNotFound()
{
var response = await _client.GetAsync("/api/products/99999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private async Task<ProductDto?> CreateTestProductAsync(string name, decimal price)
{
var request = new CreateProductRequest { Name = name, Price = price, Category = "测试" };
var response = await _client.PostAsJsonAsync("/api/products", request);
return await response.Content.ReadFromJsonAsync<ProductDto>();
}
}认证测试
带身份验证的测试
/// <summary>
/// 模拟认证的集成测试
/// </summary>
public class AuthTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
public AuthTests(CustomWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task ProtectedEndpoint_WithoutAuth_Returns401()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/orders");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task ProtectedEndpoint_WithAuth_ReturnsData()
{
var client = _factory.CreateClient();
var token = await GetAuthTokenAsync(client);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
}
private async Task<string> GetAuthTokenAsync(HttpClient client)
{
var login = new LoginRequest { Username = "admin", Password = "admin123" };
var response = await client.PostAsJsonAsync("/api/auth/login", login);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>();
return result!.Token;
}
}数据库测试
真实数据库测试
/// <summary>
/// 使用 Respawn 清理测试数据
/// </summary>
public class DatabaseIntegrationTests : IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
private readonly Respawner _respawner;
private readonly string _connectionString;
public DatabaseIntegrationTests()
{
_connectionString = "Server=localhost;Database=test_db;";
_factory = new CustomWebApplicationFactory();
_respawner = Respawner.CreateAsync(_connectionString).GetAwaiter().GetResult();
}
public async Task InitializeAsync()
{
// 每次测试前清空数据库
await _respawner.ResetAsync(_connectionString);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task SeedAndQuery_DataIsConsistent()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Products.AddRange(
new Product { Name = "商品A", Price = 100 },
new Product { Name = "商品B", Price = 200 }
);
await db.SaveChangesAsync();
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/products");
var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
Assert.Equal(2, products!.Count);
}
}自定义 WebApplicationFactory 高级配置
/// <summary>
/// 高级 WebApplicationFactory 配置
/// </summary>
public class IntegrationTestWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 替换数据库
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("IntegrationTestDb");
});
// 替换 HTTP 客户端(避免真实外部调用)
services.RemoveAll<IHttpClientFactory>();
services.AddSingleton<IHttpClientFactory>(new MockHttpClientFactory());
// 替换消息队列
services.RemoveAll<IMessagePublisher>();
services.AddSingleton<IMessagePublisher, InMemoryMessagePublisher>();
// 替换文件存储
services.RemoveAll<IFileStorage>();
services.AddSingleton<IFileStorage, InMemoryFileStorage>();
// 替换缓存
services.RemoveAll<IDistributedCache>();
services.AddDistributedMemoryCache();
// 替换认证
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", null);
});
builder.UseEnvironment("Testing");
}
protected override void ConfigureConfiguration(IConfiguration configuration)
{
// 覆盖测试环境配置
var testSettings = new Dictionary<string, string?>
{
["Jwt:Secret"] = "test-secret-key-for-integration-testing-only",
["Jwt:Issuer"] = "test-issuer",
["ConnectionStrings:Redis"] = "localhost:6379"
};
configuration.AddInMemoryCollection(testSettings);
}
}
// Mock HTTP 客户端工厂
public class MockHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name)
{
var handler = new MockHttpMessageHandler();
return new HttpClient(handler);
}
}
public class MockHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"result\": \"mock\"}")
};
return Task.FromResult(response);
}
}
// 测试认证处理器
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[] { new Claim(ClaimTypes.Name, "test-user"), new Claim("sub", "1") };
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}中间件测试
/// <summary>
/// 中间件集成测试
/// </summary>
public class MiddlewareTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public MiddlewareTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CorrelationIdMiddleware_SetsHeader()
{
var request = new HttpRequestMessage(HttpMethod.Get, "/api/users");
var response = await _client.SendAsync(request);
Assert.True(response.Headers.Contains("X-Correlation-Id"));
}
[Fact]
public async Task CorrelationIdMiddleware_ForwardsExistingId()
{
var request = new HttpRequestMessage(HttpMethod.Get, "/api/users");
request.Headers.Add("X-Correlation-Id", "test-correlation-001");
var response = await _client.SendAsync(request);
var correlationId = response.Headers.GetValues("X-Correlation-Id").First();
Assert.Equal("test-correlation-001", correlationId);
}
[Fact]
public async Task RequestTimingMiddleware_ReturnsTiming()
{
var response = await _client.GetAsync("/api/users");
response.EnsureSuccessStatusCode();
Assert.True(response.Headers.Contains("X-Response-Time"));
}
}分页与过滤测试
/// <summary>
/// 分页、排序、过滤集成测试
/// </summary>
public class PagedQueryTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public PagedQueryTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetProducts_DefaultPage_ReturnsFirst10()
{
var response = await _client.GetAsync("/api/products");
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
Assert.NotNull(result);
Assert.True(result.Items.Count <= 10);
Assert.True(result.Page >= 1);
Assert.True(result.TotalCount >= 0);
}
[Fact]
public async Task GetProducts_CustomPageSize_ReturnsCorrectCount()
{
var response = await _client.GetAsync("/api/products?pageSize=5");
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
Assert.NotNull(result);
Assert.True(result.Items.Count <= 5);
}
[Fact]
public async Task GetProducts_WithCategoryFilter_ReturnsFiltered()
{
var response = await _client.GetAsync("/api/products?category=electronics");
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
Assert.NotNull(result);
Assert.All(result.Items, item => Assert.Equal("electronics", item.Category));
}
[Fact]
public async Task GetProducts_WithKeywordSearch_ReturnsMatching()
{
var response = await _client.GetAsync("/api/products?keyword=laptop");
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
Assert.NotNull(result);
Assert.All(result.Items, item =>
Assert.Contains("laptop", item.Name, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetProducts_WithSorting_ReturnsOrdered()
{
var response = await _client.GetAsync("/api/products?sortBy=price&order=asc");
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
Assert.NotNull(result);
for (int i = 1; i < result.Items.Count; i++)
{
Assert.True(result.Items[i - 1].Price <= result.Items[i].Price);
}
}
}测试数据构建器模式
/// <summary>
/// 测试数据构建器 — 简化测试数据创建
/// </summary>
public class ProductBuilder
{
private string _name = "测试商品";
private decimal _price = 99.99m;
private string _category = "默认分类";
private bool _isActive = true;
public ProductBuilder WithName(string name)
{
_name = name;
return this;
}
public ProductBuilder WithPrice(decimal price)
{
_price = price;
return this;
}
public ProductBuilder WithCategory(string category)
{
_category = category;
return this;
}
public ProductBuilder Inactive()
{
_isActive = false;
return this;
}
public Product Build()
{
return new Product
{
Name = _name,
Price = _price,
Category = _category,
IsActive = _isActive,
CreatedAt = DateTime.UtcNow
};
}
public CreateProductRequest BuildRequest()
{
return new CreateProductRequest
{
Name = _name,
Price = _price,
Category = _category
};
}
}
// 使用示例
[Fact]
public async Task CreateProduct_WithHighPrice_ReturnsCreated()
{
var request = new ProductBuilder()
.WithName("高端商品")
.WithPrice(99999.99m)
.WithCategory("奢侈品")
.BuildRequest();
var response = await _client.PostAsJsonAsync("/api/products", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
// 批量数据生成
public static class TestDataGenerator
{
public static List<Product> GenerateProducts(int count)
{
return Enumerable.Range(1, count)
.Select(i => new ProductBuilder()
.WithName($"商品{i}")
.WithPrice(i * 10m)
.WithCategory(i % 2 == 0 ? "电子" : "服装")
.Build())
.ToList();
}
}测试隔离与清理策略
/// <summary>
/// 测试隔离策略
/// </summary>
// 策略1:每个测试类共享 Factory(适合快速测试)
public class SharedFactoryTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public SharedFactoryTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
}
// 策略2:每个测试独立 Factory(完全隔离)
public class IsolatedTests
{
private CustomWebApplicationFactory _factory = null!;
private HttpClient _client = null!;
public IsolatedTests()
{
_factory = new CustomWebApplicationFactory();
_client = _factory.CreateClient();
}
[Fact]
public async Task Test1() { /* ... */ }
[Fact]
public async Task Test2() { /* ... */ }
}
// 策略3:Collection 共享(相关测试共享状态)
[Collection("DatabaseCollection")]
public class OrderTests
{
private readonly HttpClient _client;
public OrderTests(DatabaseFixture fixture) { _client = fixture.Client; }
}
[Collection("DatabaseCollection")]
public class InventoryTests
{
private readonly HttpClient _client;
public InventoryTests(DatabaseFixture fixture) { _client = fixture.Client; }
}
public class DatabaseFixture : IDisposable
{
public HttpClient Client { get; }
private readonly WebApplicationFactory<Program> _factory;
public DatabaseFixture()
{
_factory = new CustomWebApplicationFactory();
Client = _factory.CreateClient();
}
public void Dispose()
{
_factory.Dispose();
}
}优点
缺点
总结
集成测试推荐使用 WebApplicationFactory + 内存数据库。测试范围:API 端到端、中间件管道、认证授权。数据库测试用 InMemoryDatabase 做快速验证,用 Respawn + 真实数据库做关键路径验证。测试隔离:每个测试独立准备数据,不依赖执行顺序。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
适用场景
- 当你准备把《WebApplicationFactory 集成测试》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
复盘问题
- 如果把《WebApplicationFactory 集成测试》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《WebApplicationFactory 集成测试》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《WebApplicationFactory 集成测试》最大的收益和代价分别是什么?
