ASP.NET Core 测试深入
大约 9 分钟约 2693 字
ASP.NET Core 测试深入
简介
ASP.NET Core 测试深入涵盖集成测试、端到端测试与测试基础设施的高级话题。掌握 WebApplicationFactory 的高级用法、内存数据库集成、Mock HTTP 请求、自定义服务器 fixture、测试中间件和管道,可以构建覆盖真实请求生命周期的端到端测试体系。
特点
WebApplicationFactory 进阶
基本用法
/// <summary>
/// WebApplicationFactory 基础用法
/// </summary>
public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task GetProducts_ReturnsOk()
{
// Act
var response = await _client.GetAsync("/api/products");
// Assert
response.EnsureSuccessStatusCode();
var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
Assert.NotNull(products);
Assert.NotEmpty(products);
}
}自定义宿主配置
/// <summary>
/// 自定义 WebApplicationFactory — 替换服务、修改配置
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 1. 移除真实数据库
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
// 2. 替换为内存数据库
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// 3. 替换外部服务为 Mock
services.AddScoped<IEmailService, MockEmailService>();
services.AddScoped<IPaymentGateway, MockPaymentGateway>();
// 4. 覆盖认证
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => {});
// 5. 构建 ServiceProvider 确保无配置错误
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
SeedTestData(db);
});
builder.Configure(app =>
{
// 注入测试专用中间件
app.UseMiddleware<TestRequestLoggingMiddleware>();
});
// 使用自定义配置
builder.UseSetting("Testing:Enabled", "true");
builder.UseEnvironment("Testing");
}
private static void SeedTestData(AppDbContext db)
{
db.Products.AddRange(
new Product { Id = 1, Name = "测试产品A", Price = 100 },
new Product { Id = 2, Name = "测试产品B", Price = 200 }
);
db.SaveChanges();
}
}测试认证与授权
/// <summary>
/// 测试认证处理器 — 模拟已认证用户
/// </summary>
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.NameIdentifier, "test-user-id"),
new Claim(ClaimTypes.Name, "测试用户"),
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));
}
}
// 使用方式
public class AuthTests : IClassFixture<CustomWebApplicationFactory>
{
[Fact]
public async Task GetProfile_RequiresAuth_ReturnsUnauthorized()
{
// 不设置认证 → 401
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/profile");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetProfile_WithTestAuth_ReturnsOk()
{
// 使用测试认证处理器
var client = _factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", _ => {});
});
})
.CreateClient();
var response = await client.GetAsync("/api/profile");
response.EnsureSuccessStatusCode();
}
}数据库集成测试
内存数据库策略
/// <summary>
/// 每个测试独立的数据库实例
/// </summary>
public class ProductTests : IDisposable
{
private readonly AppDbContext _db;
private readonly ProductService _service;
public ProductTests()
{
// 每次创建新的数据库实例
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_db = new AppDbContext(options);
_db.Database.EnsureCreated();
SeedData();
_service = new ProductService(_db);
}
[Fact]
public async Task Create_ValidProduct_SavesToDatabase()
{
// Act
var product = await _service.CreateAsync(new CreateProductDto
{
Name = "新产品",
Price = 99.9m
});
// Assert
Assert.NotNull(product);
Assert.Equal("新产品", product.Name);
Assert.True(product.Id > 0);
// 验证数据库中的数据
var saved = await _db.Products.FindAsync(product.Id);
Assert.NotNull(saved);
Assert.Equal(99.9m, saved.Price);
}
[Fact]
public async Task Delete_NonExistent_ThrowsNotFoundException()
{
await Assert.ThrowsAsync<NotFoundException>(
() => _service.DeleteAsync(99999));
}
private void SeedData()
{
_db.Categories.AddRange(
new Category { Id = 1, Name = "电子产品" },
new Category { Id = 2, Name = "服装" }
);
_db.SaveChanges();
}
public void Dispose()
{
_db.Database.EnsureDeleted();
_db.Dispose();
}
}SQLite 文件数据库(更接近真实行为)
/// <summary>
/// 使用 SQLite 文件模式 — 支持 FK 约束、事务等完整行为
/// </summary>
public class SqliteTestBase : IDisposable
{
private readonly string _dbPath;
protected readonly AppDbContext Db;
public SqliteTestBase()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.db");
var connection = new SqliteConnection($"Data Source={_dbPath}");
connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
Db = new AppDbContext(options);
Db.Database.EnsureCreated();
}
public void Dispose()
{
Db.Database.EnsureDeleted();
Db.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
}Testcontainers(最接近生产环境)
/// <summary>
/// 使用 Testcontainers 运行真实数据库容器
/// </summary>
public class ContainerTestBase : IAsyncLifetime
{
private readonly MsSqlContainer _container;
protected AppDbContext Db = null!;
public ContainerTestBase()
{
_container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("Test@12345")
.Build();
}
public async Task InitializeAsync()
{
await _container.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_container.GetConnectionString())
.Options;
Db = new AppDbContext(options);
await Db.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await Db.DisposeAsync();
await _container.DisposeAsync();
}
}Mock HTTP 请求
使用 HttpMessageHandler 模拟外部 API
/// <summary>
/// Mock HttpClient 调用外部服务
/// </summary>
public class PaymentServiceTests
{
[Fact]
public async Task ProcessPayment_GatewayReturnsSuccess_ReturnsTrue()
{
// Arrange — 创建 Mock Handler
var mockResponse = new
{
transactionId = "txn_12345",
status = "success"
};
var mockHandler = new MockHttpMessageHandler(
HttpStatusCode.OK,
JsonSerializer.Serialize(mockResponse));
var client = new HttpClient(mockHandler)
{
BaseAddress = new Uri("https://payment-gateway.example.com")
};
// 替换服务中的 HttpClient
var services = new ServiceCollection();
services.AddSingleton(client);
services.AddScoped<PaymentService>();
var provider = services.BuildServiceProvider();
var paymentService = provider.GetRequiredService<PaymentService>();
// Act
var result = await paymentService.ProcessAsync(100.0m, "order_001");
// Assert
Assert.True(result.Success);
Assert.Equal("txn_12345", result.TransactionId);
Assert.Equal("POST", mockHandler.LastRequest.Method.Method);
Assert.Contains("order_001", mockHandler.LastRequest.Content?.ReadAsStringAsync().Result ?? "");
}
}
/// <summary>
/// 自定义 Mock HttpMessageHandler
/// </summary>
public class MockHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly string _content;
private readonly Dictionary<string, string> _responsesByPath;
public HttpRequestMessage? LastRequest { get; private set; }
public MockHttpMessageHandler(HttpStatusCode statusCode, string content)
{
_statusCode = statusCode;
_content = content;
_responsesByPath = new Dictionary<string, string>();
}
public MockHttpMessageHandler AddResponse(string path, string json)
{
_responsesByPath[path] = json;
return this;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
var content = _responsesByPath.TryGetValue(
request.RequestUri?.AbsolutePath ?? "", out var pathContent)
? pathContent
: _content;
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(content, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}使用 WireMock.Net 模拟外部服务
/// <summary>
/// WireMock — 功能更强大的 HTTP Mock 服务
/// </summary>
public class WireMockTests : IAsyncLifetime
{
private WireMockServer _mockServer = null!;
private HttpClient _client = null!;
public async Task InitializeAsync()
{
_mockServer = WireMockServer.Start();
// 配置 Mock 响应
_mockServer
.Given(Request.Create()
.WithPath("/api/users/1")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(@"{ ""id"": 1, ""name"": ""张三"" }"));
// 配置错误场景
_mockServer
.Given(Request.Create()
.WithPath("/api/users/999")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(404)
.WithBody(@"{ ""error"": ""用户不存在"" }"));
_client = new HttpClient { BaseAddress = new Uri(_mockServer.Url!) };
}
[Fact]
public async Task GetUser_Exists_ReturnsUser()
{
var response = await _client.GetAsync("/api/users/1");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadFromJsonAsync<UserDto>();
Assert.Equal("张三", user!.Name);
}
[Fact]
public async Task GetUser_NotFound_Returns404()
{
var response = await _client.GetAsync("/api/users/999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
public async Task DisposeAsync()
{
_mockServer.Stop();
_client.Dispose();
}
}中间件与管道测试
单独测试中间件
/// <summary>
/// 使用 TestServer 独立测试中间件
/// </summary>
public class RequestLoggingMiddlewareTests
{
private readonly TestServer _server;
private readonly HttpClient _client;
public RequestLoggingMiddlewareTests()
{
var builder = WebHost.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<MockLogger>();
})
.Configure(app =>
{
app.UseMiddleware<RequestLoggingMiddleware>();
app.Run(async context =>
{
await context.Response.WriteAsync("Hello");
});
});
_server = new TestServer(builder);
_client = _server.CreateClient();
}
[Fact]
public async Task Middleware_LogsRequestPath()
{
// Act
var response = await _client.GetAsync("/api/test");
// Assert
Assert.Equal("Hello", await response.Content.ReadAsStringAsync());
// 验证 MockLogger 记录了请求路径
}
}测试过滤器管道
/// <summary>
/// 测试 Action Filter
/// </summary>
public class ValidationFilterTests
{
private readonly ValidationFilter _filter;
private readonly FilterContext _context;
public ValidationFilterTests()
{
_filter = new ValidationFilter();
// 构建 ActionFilterContext
var httpContext = new DefaultHttpContext();
var actionContext = new ActionContext(
httpContext,
new RouteData(),
new ActionDescriptor());
_context = new ActionExecutedContext(
actionContext,
new List<IFilterMetadata>(),
new Mock<Controller>().Object);
}
[Fact]
public void OnActionExecuted_NoException_DoesNothing()
{
// Arrange
_context.Exception = null;
// Act
_filter.OnActionExecuted(_context);
// Assert
Assert.Null(_context.Exception);
}
[Fact]
public void OnActionExecuted_BusinessException_SetsStatusCode()
{
// Arrange
_context.Exception = new BizException("业务错误") { StatusCode = 400 };
// Act
_filter.OnActionExecuted(_context);
// Assert
Assert.True(_context.ExceptionHandled);
Assert.Equal(400, _context.HttpContext.Response.StatusCode);
}
}并行测试隔离
避免测试间干扰
/// <summary>
/// 并行安全的测试基类
/// </summary>
[Collection("Sequential")] // 强制顺序执行(需要隔离时使用)
public class OrderIntegrationTests : IClassFixture<CustomWebApplicationFactory>,
IAsyncLifetime
{
private readonly CustomWebApplicationFactory _factory;
private readonly HttpClient _client;
private readonly string _dbName;
public OrderIntegrationTests(CustomWebApplicationFactory factory)
{
_dbName = $"TestDb_{Guid.NewGuid()}";
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase(_dbName));
});
});
_client = _factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
// 清理测试数据库
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureDeletedAsync();
}
[Fact]
public async Task CreateOrder_ValidRequest_ReturnsCreated()
{
var response = await _client.PostAsJsonAsync("/api/orders", new
{
productId = 1,
quantity = 2
});
response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}
// xunit.runner.json — 控制并行行为
// {
// "parallelizeAssembly": true,
// "parallelizeTestCollections": true,
// "maxParallelThreads": 4
// }优点
缺点
总结
ASP.NET Core 测试深入要点:WebApplicationFactory 是集成测试的核心,通过 ConfigureWebHost 替换服务、修改配置。测试认证使用自定义 AuthenticationHandler 模拟已登录用户。数据库隔离优先使用内存 SQLite(轻量),需要完整行为用 Testcontainers。外部 HTTP 依赖用 MockHttpMessageHandler 或 WireMock.Net 替代。中间件测试用 TestServer 构建最小管道。并行测试通过 IClassFixture + 独立数据库名或 [Collection("Sequential")] 保证隔离。CI 中集成测试应与单元测试分开运行,避免构建时间过长。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
适用场景
- 当你准备把《ASP.NET Core 测试深入》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
复盘问题
- 如果把《ASP.NET Core 测试深入》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《ASP.NET Core 测试深入》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《ASP.NET Core 测试深入》最大的收益和代价分别是什么?
