关于 dotnet-integration-testing
这项技能介绍 .NET 应用的集成测试模式,适用于测试 API 和分布式服务的开发者。内容涵盖 WebApplicationFactory 进程内测试、Testcontainers 临时基础设施、.NET Aspire 测试及数据库隔离。
主要功能
- 01使用 WebApplicationFactory 进行进程内 API 测试
- 02通过 Testcontainers 创建临时基础设施
- 03使用 .NET Aspire 测试多服务应用
- 04数据库夹具与隔离策略
- 05Redis 容器测试模式
适用场景
- 01无需部署即可测试 ASP.NET Core API
- 02通过真实基础设施验证数据库行为
- 03测试 Aspire 服务发现和健康检查
- 04避免测试数据在测试间残留
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-integration-testing安装后在 Claude Code 中输入/dotnet-integration-testing确认已加载。
详细介绍
dotnet-integration-testing
使用 WebApplicationFactory、Testcontainers 和 .NET Aspire testing,为 .NET 应用提供集成测试模式。涵盖进程内 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,只是命名空间略有差异。
**不在范围内:**测试项目脚手架(创建项目、添加包引用)由 [skill:dotnet-add-testing] 负责。测试策略和测试类型选择由 [skill:dotnet-testing-strategy] 介绍。用于验证 API 响应结构的快照测试由 [skill:dotnet-snapshot-testing] 介绍。
**前提条件:**已通过 [skill:dotnet-add-testing] 搭建测试项目并引用集成测试包。Docker 守护程序正在运行(Testcontainers 所需)。运行 [skill:dotnet-version-detection],确认基准版本为 .NET 8.0+。
交叉引用:[skill:dotnet-testing-strategy] 用于判断何时适合进行集成测试;[skill:dotnet-xunit] 用于 xUnit Fixture 和并行执行配置;[skill:dotnet-snapshot-testing] 用于使用 Verify 验证 API 响应结构。
WebApplicationFactory
WebApplicationFactory<TEntryPoint> 为 ASP.NET Core 应用创建进程内测试服务器。测试发送 HTTP 请求时无需承担网络开销,同时会经过完整的中间件管道、路由、模型绑定和序列化流程。
包
<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 类必须能被测试项目访问。可以将其设为 public,或添加 InternalsVisibleTo 特性:
// In the API project (e.g., Program.cs or a separate file)
[assembly: InternalsVisibleTo("MyApp.Api.IntegrationTests")]
或者在 csproj 中:
<ItemGroup>
<InternalsVisibleTo Include="MyApp.Api.IntegrationTests" />
</ItemGroup>
自定义测试服务器
使用 WebApplicationFactory<T>.WithWebHostBuilder 重写服务、配置或中间件:
public class CustomWebAppFactory : WebApplicationFactory<Program>
{
// Provide connection string from test fixture (e.g., 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 =>
{
// Replace real services with test doubles
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
// Replace database context with test database
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()
{
// Ensure schema exists
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
// Create
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();
// Retrieve
var getResponse = await _client.GetAsync(location);
getResponse.EnsureSuccessStatusCode();
var order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
Assert.Equal("cust-1", order!.CustomerId);
}
}
.NET Aspire Testing
.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();
// Wait for resources to be healthy
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>();
// Override configuration for the API service
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);
// Transaction rolls back after test -- database stays clean
}
}
使用 Respawn 实现逐测试隔离
使用 Respawn 在测试之间重置数据库状态,通过删除数据而非回滚事务来实现。当无法回滚事务时(例如,测试会自行提交事务的代码)此方法很有用:
// NuGet: Respawn
// Combined fixture: owns the container AND the 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();
// Run migrations or EnsureCreated before creating respawner
// so it knows which tables to clean
_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(删除数据) | 快 | 高 | 中 | 代码自行提交事务的测试 |
| 每个类使用全新容器 | 慢 | 最高 | 低 | 会修改架构或需要完全隔离的测试 |
| 共享容器 + 清理 | 中等 | 中 | 中 | 许多类共享基础设施的测试套件 |
容器生命周期建议
Per-test: Too slow. Never spin up a container per test.
Per-class: Good isolation, acceptable speed with ICollectionFixture.
Per-collection: Best balance -- share one container across related test classes.
Per-assembly: Fastest but requires careful cleanup between tests.
使用 ICollectionFixture<T>(参见 [skill: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 到 SQL 的转换和约束执行。 - 通过
ICollectionFixture在测试类之间共享容器,避免每个类都启动新容器所带来的开销。 - 选择合适的隔离策略。 事务回滚速度最快、最简单;无法控制事务边界时使用 Respawn。
- 始终清理测试数据。 一个测试遗留的数据会导致另一个测试出现不稳定的失败。使用事务回滚、Respawn 或全新容器。
- 使
Microsoft.AspNetCore.Mvc.Testing版本与 TFM 匹配。 使用错误的版本会导致运行时绑定失败。
Agent 常见问题
- 不要硬编码
Microsoft.AspNetCore.Mvc.Testing版本。 包版本必须与项目的目标框架主版本匹配。例如,指定Version="8.0.0"会导致 net9.0 项目出错。 - 不要忘记为
Program类添加InternalsVisibleTo。 否则,WebApplicationFactory<Program>无法访问入口点,测试会在编译时失败。 - 不要将
EnsureCreated()与 Respawn 配合使用。EnsureCreated()不会跟踪迁移。对于生产架构,请使用Database.MigrateAsync();对于简单的测试架构,才使用EnsureCreated()。 - 不要在
HttpClient之前释放WebApplicationFactory。 工厂拥有测试服务器;释放工厂会使所有客户端失效。让 xUnit 通过IClassFixture管理释放。 - 不要在 Testcontainers 中使用
localhost端口。 Testcontainers 会将随机主机端口映射到容器端口。始终使用容器对象中的连接字符串(例如_container.GetConnectionString()),切勿使用硬编码端口。 - 不要在 CI 中跳过 Docker 可用性检查。 Testcontainers 需要正在运行的 Docker 守护进程。确保 CI 环境中有可用的 Docker,或在 Docker 不可用时有条件地跳过测试。