名称: .NET集成测试 描述: “使用真实基础设施进行测试。WebApplicationFactory、Testcontainers、Aspire、fixtures。” 用户可调用: false
.NET集成测试
.NET应用程序的集成测试模式,使用WebApplicationFactory、Testcontainers和.NET Aspire进行测试。涵盖进程内API测试、通过容器的可处置基础设施、数据库fixture管理和测试隔离策略。
版本假设: .NET 8.0+基线,Testcontainers 3.x+,.NET Aspire 9.0+。Microsoft.AspNetCore.Mvc.Testing的包版本必须匹配项目的目标框架主版本(例如,net8.0对应8.x,net9.0对应9.x,net10.0对应10.x)。以下示例使用Testcontainers 4.x API;模式同样适用于3.x,仅命名空间有细微差异。
范围
- 使用WebApplicationFactory进行进程内API测试
- 通过Testcontainers实现可处置基础设施
- .NET Aspire分布式应用程序测试
- 数据库fixture管理和测试隔离
- 认证和授权测试设置
超出范围
- 测试项目脚手架(创建项目、包引用)——参见[技能:dotnet-add-testing]
- 测试策略和测试类型选择——参见[技能:dotnet-testing-strategy]
- 用于验证API响应结构的快照测试——参见[技能:dotnet-snapshot-testing]
先决条件: 测试项目已通过[技能:dotnet-add-testing]搭建,并引用了集成测试包。Docker守护进程运行(Testcontainers要求)。运行[技能:dotnet-version-detection]确认.NET 8.0+基线。
交叉参考: [技能:dotnet-testing-strategy]用于决定何时使用集成测试,[技能:dotnet-xunit]用于xUnit fixtures和并行执行配置,[技能:dotnet-snapshot-testing]用于使用Verify验证API响应结构。
WebApplicationFactory
WebApplicationFactory<TEntryPoint>为ASP.NET Core应用程序创建进程内测试服务器。测试发送HTTP请求而无需网络开销,执行完整的中间件管道、路由、模型绑定和序列化。
包
<!-- 版本必须匹配目标框架:net8.0对应8.x,net9.0对应9.x等 -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
基本用法
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetOrders_ReturnsOkWithJsonArray()
{
var response = await _client.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
var orders = await response.Content
.ReadFromJsonAsync<List<OrderDto>>();
Assert.NotNull(orders);
}
[Fact]
public async Task CreateOrder_ValidPayload_Returns201()
{
var request = new CreateOrderRequest
{
CustomerId = "cust-123",
Items = [new("SKU-001", Quantity: 2)]
};
var response = await _client.PostAsJsonAsync("/api/orders", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
}
重要: Program类必须对测试项目可访问。要么使其公开,要么添加InternalsVisibleTo属性:
// 在API项目中(例如,Program.cs或单独文件)
[assembly: InternalsVisibleTo("MyApp.Api.IntegrationTests")]
或在csproj中:
<ItemGroup>
<InternalsVisibleTo Include="MyApp.Api.IntegrationTests" />
</ItemGroup>
自定义测试服务器
使用WebApplicationFactory<T>.WithWebHostBuilder重写服务、配置或中间件:
public class CustomWebAppFactory : WebApplicationFactory<Program>
{
// 从测试fixture(例如,Testcontainers)提供连接字符串
public string ConnectionString { get; set; } = "";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = ConnectionString,
["Features:EnableNewCheckout"] = "true"
});
});
builder.ConfigureTestServices(services =>
{
// 用测试替代品替换真实服务
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
// 用测试数据库替换数据库上下文
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(ConnectionString));
});
}
}
认证请求
通过配置认证处理程序测试认证端点:
public class AuthenticatedWebAppFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", options => { });
});
}
}
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, "Test User"),
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));
}
}
Testcontainers
Testcontainers在Docker容器中为测试启动真实基础设施(数据库、消息代理、缓存)。每个测试运行获得一个全新、可处置的环境。
包
<PackageReference Include="Testcontainers" Version="4.*" />
<!-- 数据库特定模块 -->
<PackageReference Include="Testcontainers.PostgreSql" Version="4.*" />
<PackageReference Include="Testcontainers.MsSql" Version="4.*" />
<PackageReference Include="Testcontainers.Redis" Version="4.*" />
PostgreSQL示例
public class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
}
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
}
[CollectionDefinition("Postgres")]
public class PostgresCollection : ICollectionFixture<PostgresFixture> { }
[Collection("Postgres")]
public class OrderRepositoryTests
{
private readonly PostgresFixture _postgres;
public OrderRepositoryTests(PostgresFixture postgres)
{
_postgres = postgres;
}
[Fact]
public async Task Insert_ValidOrder_CanBeRetrieved()
{
await using var context = CreateContext(_postgres.ConnectionString);
await context.Database.EnsureCreatedAsync();
var order = new Order { CustomerId = "cust-1", Total = 99.99m };
context.Orders.Add(order);
await context.SaveChangesAsync();
var retrieved = await context.Orders.FindAsync(order.Id);
Assert.NotNull(retrieved);
Assert.Equal(99.99m, retrieved.Total);
}
private static AppDbContext CreateContext(string connectionString)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connectionString)
.Options;
return new AppDbContext(options);
}
}
SQL Server示例
public class SqlServerFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
}
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
}
结合WebApplicationFactory与Testcontainers
最常见的模式:使用Testcontainers处理数据库,WebApplicationFactory处理API:
public class ApiTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
public async ValueTask InitializeAsync()
{
await _postgres.StartAsync();
}
public new async ValueTask DisposeAsync()
{
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
}
public class OrdersApiIntegrationTests : IClassFixture<ApiTestFactory>
{
private readonly HttpClient _client;
private readonly ApiTestFactory _factory;
public OrdersApiIntegrationTests(ApiTestFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task CreateAndRetrieveOrder_RoundTrip()
{
// 确保架构存在
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
// 创建
var createResponse = await _client.PostAsJsonAsync("/api/orders",
new { CustomerId = "cust-1", Items = new[] { new { Sku = "SKU-1", Quantity = 2 } } });
createResponse.EnsureSuccessStatusCode();
var location = createResponse.Headers.Location!.ToString();
// 检索
var getResponse = await _client.GetAsync(location);
getResponse.EnsureSuccessStatusCode();
var order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
Assert.Equal("cust-1", order!.CustomerId);
}
}
.NET Aspire测试
.NET Aspire提供DistributedApplicationTestingBuilder用于测试使用Aspire编排的多服务应用程序。这测试实际的分布式拓扑,包括服务发现、配置和健康检查。
包
<PackageReference Include="Aspire.Hosting.Testing" Version="9.*" />
基本Aspire测试
public class AspireIntegrationTests
{
[Fact]
public async Task ApiService_ReturnsHealthy()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
await using var app = await builder.BuildAsync();
await app.StartAsync();
var httpClient = app.CreateHttpClient("api-service");
var response = await httpClient.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task ApiService_WithDatabase_ReturnsOrders()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
await using var app = await builder.BuildAsync();
await app.StartAsync();
// 等待资源健康
var resourceNotification = app.Services
.GetRequiredService<ResourceNotificationService>();
await resourceNotification
.WaitForResourceHealthyAsync("api-service")
.WaitAsync(TimeSpan.FromSeconds(60));
var httpClient = app.CreateHttpClient("api-service");
var response = await httpClient.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
}
}
Aspire与服务覆盖
替换Aspire应用模型中的服务以进行测试:
[Fact]
public async Task ApiService_WithMockedExternalDependency()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
// 覆盖API服务的配置
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
});
await using var app = await builder.BuildAsync();
await app.StartAsync();
var httpClient = app.CreateHttpClient("api-service");
var response = await httpClient.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
}
数据库Fixture模式
使用事务的每测试隔离
使用事务范围回滚每个测试的更改:
public class TransactionalTestBase : IClassFixture<PostgresFixture>, IAsyncLifetime
{
private readonly PostgresFixture _postgres;
private AppDbContext _context = null!;
private IDbContextTransaction _transaction = null!;
public TransactionalTestBase(PostgresFixture postgres)
{
_postgres = postgres;
}
protected AppDbContext Context => _context;
public async ValueTask InitializeAsync()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.ConnectionString)
.Options;
_context = new AppDbContext(options);
await _context.Database.EnsureCreatedAsync();
_transaction = await _context.Database.BeginTransactionAsync();
}
public async ValueTask DisposeAsync()
{
await _transaction.RollbackAsync();
await _transaction.DisposeAsync();
await _context.DisposeAsync();
}
}
public class OrderTests : TransactionalTestBase
{
public OrderTests(PostgresFixture postgres) : base(postgres) { }
[Fact]
public async Task Insert_ValidOrder_Persists()
{
Context.Orders.Add(new Order { CustomerId = "cust-1", Total = 50m });
await Context.SaveChangesAsync();
var count = await Context.Orders.CountAsync();
Assert.Equal(1, count);
// 测试后事务回滚——数据库保持清洁
}
}
使用Respawn的每测试隔离
使用Respawn通过删除数据重置数据库状态,这在事务回滚不可行时有用(例如,测试代码提交自己的事务):
// NuGet: Respawn
// 组合fixture:拥有容器和respawner
public class RespawnablePostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
private Respawner _respawner = null!;
private NpgsqlConnection _connection = null!;
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
_connection = new NpgsqlConnection(ConnectionString);
await _connection.OpenAsync();
// 在创建respawner之前运行迁移或EnsureCreated
// 以便它知道要清理哪些表
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
TablesToIgnore = ["__EFMigrationsHistory"]
});
}
public async Task ResetDatabaseAsync()
{
await _respawner.ResetAsync(_connection);
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
await _container.DisposeAsync();
}
}
测试隔离策略
策略比较
| 策略 | 速度 | 隔离性 | 复杂性 | 最佳适用 |
|---|---|---|---|---|
| 事务回滚 | 最快 | 高 | 低 | 使用单个DbContext的测试 |
| Respawn(数据删除) | 快 | 高 | 中等 | 代码提交自己事务的测试 |
| 每类新容器 | 慢 | 最高 | 低 | 修改架构或需要完全隔离的测试 |
| 共享容器 + 清理 | 中等 | 中等 | 中等 | 多个类共享基础设施的测试套件 |
容器生命周期建议
每测试: 太慢。永远不要每个测试启动一个容器。
每类: 良好的隔离性,速度可接受,使用ICollectionFixture。
每集合: 最佳平衡——在相关测试类之间共享一个容器。
每程序集: 最快但需要测试间仔细清理。
使用ICollectionFixture<T>(参见[技能:dotnet-xunit])在多个测试类之间共享单个容器,同时顺序运行这些类以避免数据冲突。
使用Redis进行测试
public class RedisFixture : IAsyncLifetime
{
private readonly RedisContainer _container = new RedisBuilder()
.WithImage("redis:7-alpine")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync() => await _container.StartAsync();
public async ValueTask DisposeAsync() => await _container.DisposeAsync();
}
[CollectionDefinition("Redis")]
public class RedisCollection : ICollectionFixture<RedisFixture> { }
[Collection("Redis")]
public class CacheServiceTests
{
private readonly RedisFixture _redis;
public CacheServiceTests(RedisFixture redis) => _redis = redis;
[Fact]
public async Task SetAndGet_RoundTrip_ReturnsOriginalValue()
{
var multiplexer = await ConnectionMultiplexer.ConnectAsync(
_redis.ConnectionString);
var cache = new RedisCacheService(multiplexer);
await cache.SetAsync("key-1", new Order { Id = 1, Total = 99m });
var result = await cache.GetAsync<Order>("key-1");
Assert.NotNull(result);
Assert.Equal(99m, result.Total);
}
}
关键原则
- 使用WebApplicationFactory进行API测试。 比测试部署实例更快、更可靠、更确定。
- 使用Testcontainers处理真实基础设施。 不要模拟
DbContext——针对真实数据库测试以验证LINQ-to-SQL翻译和约束执行。 - 通过
ICollectionFixture在测试类之间共享容器,避免每个类启动新容器的开销。 - 选择正确的隔离策略。 事务回滚是最快最简单的;当无法控制事务边界时使用Respawn。
- 始终清理测试数据。 一个测试的残留数据会导致另一个测试的脆弱失败。使用事务回滚、Respawn或新容器。
- 匹配
Microsoft.AspNetCore.Mvc.Testing版本到TFM。 使用错误版本会导致运行时绑定失败。
代理注意事项
- 不要硬编码
Microsoft.AspNetCore.Mvc.Testing版本。 包版本必须匹配项目的目标框架主版本。指定例如Version="8.0.0"会破坏net9.0项目。 - 不要忘记
InternalsVisibleTo用于Program类。 没有它,WebApplicationFactory<Program>无法访问入口点,测试在编译时失败。 - 不要将
EnsureCreated()与Respawn一起使用。EnsureCreated()不跟踪迁移。对于生产架构使用Database.MigrateAsync(),或仅用于简单测试架构的EnsureCreated()。 - 不要在
HttpClient之前处置WebApplicationFactory。 工厂拥有测试服务器;处置它会使所有客户端无效。让xUnit通过IClassFixture管理处置。 - 不要在Testcontainers中使用
localhost端口。 Testcontainers将随机主机端口映射到容器端口。始终使用容器对象的连接字符串(例如,_container.GetConnectionString()),永远不要硬编码端口。 - 不要在CI中跳过Docker可用性检查。 Testcontainers需要运行的Docker守护进程。确保CI环境有Docker可用,或在Docker不可用时使用条件测试跳过。