集成测试与 .NET 阿斯皮尔 + xUnit
何时使用此技能
使用此技能时:
- 编写 .NET 阿斯皮尔应用程序的集成测试
- 使用真实数据库连接测试 ASP.NET Core 应用程序
- 验证分布式应用程序中的服务间通信
- 在容器中使用实际基础设施(SQL Server、Redis、消息队列)进行测试
- 结合 Playwright UI 测试与阿斯皮尔编排的服务
- 使用适当的服务发现和网络测试微服务
核心原则
- 真实依赖 - 通过阿斯皮尔使用实际基础设施(数据库、缓存),而不是模拟
- 动态端口绑定 - 让阿斯皮尔动态分配端口(
127.0.0.1:0)以避免冲突 - 固定装置生命周期 - 使用
IAsyncLifetime进行适当的测试固定装置设置和拆卸 - 端点发现 - 从不硬编码 URL;在运行时从阿斯皮尔发现端点
- 并行隔离 - 使用 xUnit 集合控制测试并行化
- 健康检查 - 始终等待服务健康后再运行测试
高级测试架构
┌─────────────────┐ ┌──────────────────────┐
│ xUnit 测试文件 │──uses────────────►│ 阿斯皮尔固定装置 │
└─────────────────┘ │ (IAsyncLifetime) │
└──────────────────────┘
│
│ starts
▼
┌───────────────────────────┐
│ 分布式应用程序 │
│ (来自 AppHost) │
└───────────────────────────┘
│ exposes
▼
┌──────────────────────────────┐
│ 动态 HTTP 端点 │
└──────────────────────────────┘
│ consumed by
▼
┌─────────────────────────┐
│ HttpClient / Playwright│
└─────────────────────────┘
必需的 NuGet 包
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" Version="$(AspireVersion)" />
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
</ItemGroup>
重要:集成测试的文件观察器修复
当运行许多集成测试,每个测试都启动一个 IHost 时,默认的 .NET 主机构建器会为配置重新加载启用文件观察器。这在 Linux 上耗尽文件描述符限制。
在任何测试运行之前将此添加到您的测试项目中:
// TestEnvironmentInitializer.cs
using System.Runtime.CompilerServices;
namespace YourApp.Tests;
internal static class TestEnvironmentInitializer
{
[ModuleInitializer]
internal static void Initialize()
{
// 在测试主机中禁用配置文件监视
// 防止在 Linux 上文件描述符耗尽(inotify 监视限制)
Environment.SetEnvironmentVariable("DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE", "false");
}
}
为什么这很重要: [ModuleInitializer] 在任何测试代码执行之前运行,为测试期间创建的所有 IHost 实例全局设置环境变量。
模式 1:基本阿斯皮尔测试固定装置(现代 API)
using Aspire.Hosting;
using Aspire.Hosting.Testing;
public sealed class AspireAppFixture : IAsyncLifetime
{
private DistributedApplication? _app;
public DistributedApplication App => _app
?? throw new InvalidOperationException("App not initialized");
public async Task InitializeAsync()
{
// 将配置覆盖作为命令行参数传递(比 Configuration 字典更清晰)
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.YourApp_AppHost>([
"YourApp:UseVolumes=false", // 无持久性 - 每次测试都干净
"YourApp:Environment=IntegrationTest",
"YourApp:Replicas=1" // 测试单实例
]);
_app = await builder.BuildAsync();
// 第一阶段:启动应用程序(容器启动)
using var startupCts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
await _app.StartAsync(startupCts.Token);
// 第二阶段:等待服务变得健康(使用内置 API)
using var healthCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
await _app.ResourceNotifications.WaitForResourceHealthyAsync("api", healthCts.Token);
}
public Uri GetEndpoint(string resourceName, string scheme = "https")
{
return _app?.GetEndpoint(resourceName, scheme)
?? throw new InvalidOperationException($"Endpoint for '{resourceName}' not found");
}
public async Task DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
}
}
模式 2:在测试中使用固定装置
// 定义一个集合以在多个测试类之间共享固定装置
[CollectionDefinition("Aspire collection")]
public class AspireCollection : ICollectionFixture<AspireAppFixture> { }
// 在您的测试类中使用固定装置
[Collection("Aspire collection")]
public class IntegrationTests
{
private readonly AspireAppFixture _fixture;
public IntegrationTests(AspireAppFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Application_ShouldStart()
{
// 获取 Web 应用程序资源
var webApp = _fixture.App.GetResource("yourapp");
// 获取 HTTP 端点
var httpClient = _fixture.App.CreateHttpClient("yourapp");
// 发送请求
var response = await httpClient.GetAsync("/");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
模式 3:端点发现
public static class DistributedApplicationExtensions
{
public static ResourceEndpoint GetEndpoint(
this DistributedApplication app,
string resourceName,
string? endpointName = null)
{
var resource = app.GetResource(resourceName);
if (resource is null)
throw new InvalidOperationException(
$"Resource '{resourceName}' not found");
var endpoint = endpointName is null
? resource.GetEndpoints().FirstOrDefault()
: resource.GetEndpoint(endpointName);
if (endpoint is null)
throw new InvalidOperationException(
$"Endpoint '{endpointName}' not found on resource '{resourceName}'");
return endpoint;
}
public static string GetEndpointUrl(
this DistributedApplication app,
string resourceName,
string? endpointName = null)
{
var endpoint = app.GetEndpoint(resourceName, endpointName);
return endpoint.Url;
}
}
// 测试中的用法
[Fact]
public async Task CanAccessWebApplication()
{
var url = _fixture.App.GetEndpointUrl("yourapp");
var client = new HttpClient { BaseAddress = new Uri(url) };
var response = await client.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
模式 4:使用数据库依赖项进行测试
public class DatabaseIntegrationTests
{
private readonly AspireAppFixture _fixture;
public DatabaseIntegrationTests(AspireAppFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Database_ShouldBeInitialized()
{
// 从阿斯皮尔获取连接字符串
var dbResource = _fixture.App.GetResource("yourdb");
var connectionString = await dbResource
.GetConnectionStringAsync();
// 测试数据库访问
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
var result = await connection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES");
Assert.True(result > 0, "Database should have tables");
}
}
模式 5:与 Playwright 结合进行 UI 测试
using Microsoft.Playwright;
public sealed class AspirePlaywrightFixture : IAsyncLifetime
{
private DistributedApplication? _app;
private IPlaywright? _playwright;
private IBrowser? _browser;
public DistributedApplication App => _app!;
public IBrowser Browser => _browser!;
public async Task InitializeAsync()
{
// 启动阿斯皮尔应用程序
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.YourApp_AppHost>();
_app = await appHost.BuildAsync();
await _app.StartAsync();
// 等待应用程序完全准备就绪
await Task.Delay(2000); // 或使用适当的健康检查轮询
// 启动 Playwright
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = true
});
}
public async Task DisposeAsync()
{
if (_browser is not null)
await _browser.DisposeAsync();
_playwright?.Dispose();
if (_app is not null)
await _app.DisposeAsync();
}
}
[Collection("Aspire Playwright collection")]
public class UIIntegrationTests
{
private readonly AspirePlaywrightFixture _fixture;
public UIIntegrationTests(AspirePlaywrightFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task HomePage_ShouldLoad()
{
var url = _fixture.App.GetEndpointUrl("yourapp");
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(url);
var title = await page.TitleAsync();
Assert.NotEmpty(title);
}
}
模式 6:测试用条件资源配置
设计您的 AppHost 以支持交互式开发(F5/CLI)与自动化测试固定装置的不同配置。模式超越了仅仅是卷 - 它涵盖了执行模式、身份验证、外部服务等。
核心原则
在 AppHost 中默认为生产类行为。 测试明确覆盖它们需要不同的内容。这可以及早发现配置差距(例如,只有在集群模式下才出现的缺失 DI 注册)。
AppHost 中的配置类
// 在您的 AppHost 项目中
public class AppHostConfiguration
{
// 基础设施设置
public bool UseVolumes { get; set; } = true; // 在开发中持久化数据,在测试中干净启动
// 执行模式设置(适用于 Akka.NET 或类似)
public string ExecutionMode { get; set; } = "Clustered"; // 在开发中完整的集群,在 LocalTest 可选
// 功能开关
public bool EnableTestAuth { get; set; } = false; // /dev-login 端点用于测试
public bool UseFakeExternalServices { get; set; } = false; // 假 Gmail、Stripe 等
// 规模设置
public int Replicas { get; set; } = 1;
}
AppHost 条件逻辑
var builder = DistributedApplication.CreateBuilder(args);
// 从命令行参数或 appsettings 绑定配置
var config = builder.Configuration.GetSection("App")
.Get<AppHostConfiguration>() ?? new AppHostConfiguration();
// 根据条件配置数据库
var postgres = builder.AddPostgres("postgres").WithPgAdmin();
if (config.UseVolumes)
{
postgres.WithDataVolume();
}
var db = postgres.AddDatabase("appdb");
// 迁移
var migrations = builder.AddProject<Projects.YourApp_Migrations>("migrations")
.WaitFor(db)
.WithReference(db);
// 根据环境配置 API
var api = builder.AddProject<Projects.YourApp_Api>("api")
.WaitForCompletion(migrations)
.WithReference(db)
.WithEnvironment("AkkaSettings__ExecutionMode", config.ExecutionMode)
.WithEnvironment("Testing__EnableTestAuth", config.EnableTestAuth.ToString())
.WithEnvironment("ExternalServices__UseFakes", config.UseFakeExternalServices.ToString());
// 条件副本
if (config.Replicas > 1)
{
api.WithReplicas(config.Replicas);
}
builder.Build().Run();
测试固定装置覆盖
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.YourApp_AppHost>([
"App:UseVolumes=false", // 每次测试清理数据库
"App:ExecutionMode=LocalTest", // 更快,无需集群开销(可选)
"App:EnableTestAuth=true", // 启用 /dev-login 端点
"App:UseFakeExternalServices=true" // 无真实 OAuth、电子邮件、支付
]);
常见条件设置
| 设置 | F5/开发 | 测试固定装置 | 目的 |
|---|---|---|---|
UseVolumes |
true(持久化数据) |
false(干净启动) |
数据库隔离 |
ExecutionMode |
Clustered(现实) |
LocalTest 或 Clustered |
演员系统模式 |
EnableTestAuth |
false(使用真实 OAuth) |
true(/dev-login) |
测试中绕过 OAuth |
UseFakeServices |
false(真实集成) |
true(无外部调用) |
外部 API 隔离 |
Replicas |
1 或更多 |
1(简单) |
规模配置 |
SeedData |
false |
true |
预填充测试数据 |
测试身份验证模式
当 EnableTestAuth=true 时,您的 API 可以暴露一个仅限测试的身份验证端点:
// 在 API 启动时,根据条件添加测试身份验证
if (builder.Configuration.GetValue<bool>("Testing:EnableTestAuth"))
{
app.MapPost("/dev-login", async (DevLoginRequest request, IAuthService auth) =>
{
// 为指定用户生成真实的身份验证令牌
var token = await auth.GenerateTokenAsync(request.UserId, request.Roles);
return Results.Ok(new { token });
});
}
// 在测试中
public async Task<string> LoginAsTestUser(string userId, string[] roles)
{
var response = await _httpClient.PostAsJsonAsync("/dev-login",
new { UserId = userId, Roles = roles });
var result = await response.Content.ReadFromJsonAsync<DevLoginResponse>();
return result!.Token;
}
假外部服务模式
// 在您的服务注册中
public static IServiceCollection AddExternalServices(
this IServiceCollection services,
IConfiguration config)
{
if (config.GetValue<bool>("ExternalServices:UseFakes"))
{
// 测试假 - 无外部调用
services.AddSingleton<IEmailSender, FakeEmailSender>();
services.AddSingleton<IPaymentProcessor, FakePaymentProcessor>();
services.AddSingleton<IOAuthProvider, FakeOAuthProvider>();
}
else
{
// 真实实现
services.AddSingleton<IEmailSender, SendGridEmailSender>();
services.AddSingleton<IPaymentProcessor, StripePaymentProcessor>();
services.AddSingleton<IOAuthProvider, Auth0Provider>();
}
return services;
}
为什么默认为生产类行为
从生产类默认开始并在测试中覆盖可以及早发现仅在真实条件下出现的问题:
- DI 注册差距 - 只有在集群模式下注册的服务
- 配置错误 - 生产所需的设置但缺失
- 集成问题 - 与真实数据库连接、身份验证流程等问题
- 性能特征 - 测试更接近生产行为
测试明确选择退出特定的生产行为,而不是选择进入一个可能错过真实问题的测试模式。
模式 7:使用 Respawn 重置数据库
对于修改数据的测试,使用 Respawn 在测试之间重置:
using Respawn;
public class AspireFixtureWithReset : IAsyncLifetime
{
private DistributedApplication? _app;
private Respawner? _respawner;
private string? _connectionString;
public async Task InitializeAsync()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.YourApp_AppHost>([
"YourApp:UseVolumes=false"
]);
_app = await builder.BuildAsync();
await _app.StartAsync();
// 等待数据库和迁移
await _app.ResourceNotifications.WaitForResourceHealthyAsync("api");
// 获取连接字符串并创建 respawner
var dbResource = _app.GetResource("appdb");
_connectionString = await dbResource.GetConnectionStringAsync();
_respawner = await Respawner.CreateAsync(_connectionString, new RespawnerOptions
{
TablesToIgnore = new[]
{
"__EFMigrationsHistory",
"schema_version", // DbUp
"AspNetRoles" // 种子参考数据
},
DbAdapter = DbAdapter.Postgres
});
}
/// <summary>
/// 将数据库重置为干净状态。
/// </summary>
public async Task ResetDatabaseAsync()
{
if (_respawner is not null && _connectionString is not null)
{
await _respawner.ResetAsync(_connectionString);
}
}
public async Task DisposeAsync()
{
if (_app is not null)
await _app.DisposeAsync();
}
}
模式 8:等待资源准备就绪
public static class ResourceExtensions
{
public static async Task WaitForHealthyAsync(
this DistributedApplication app,
string resourceName,
TimeSpan? timeout = null)
{
timeout ??= TimeSpan.FromSeconds(30);
var cts = new CancellationTokenSource(timeout.Value);
var resource = app.GetResource(resourceName);
while (!cts.Token.IsCancellationRequested)
{
try
{
var httpClient = app.CreateHttpClient(resourceName);
var response = await httpClient.GetAsync(
"/health",
cts.Token);
if (response.IsSuccessStatusCode)
return;
}
catch
{
// 资源尚未准备就绪
}
await Task.Delay(500, cts.Token);
}
throw new TimeoutException(
$"Resource '{resourceName}' did not become healthy within {timeout}");
}
}
// 用法
[Fact]
public async Task ServicesShouldBeHealthy()
{
await _fixture.App.WaitForHealthyAsync("yourapp");
await _fixture.App.WaitForHealthyAsync("yourapi");
// 现在进行测试
}
模式 9:使用消息队列进行测试
[Fact]
public async Task MessageQueue_ShouldProcessMessages()
{
// 从阿斯皮尔获取 RabbitMQ 连接
var rabbitMqResource = _fixture.App.GetResource("messaging");
var connectionString = await rabbitMqResource
.GetConnectionStringAsync();
var factory = new ConnectionFactory
{
Uri = new Uri(connectionString)
};
using var connection = await factory.CreateConnectionAsync();
using var channel = await connection.CreateChannelAsync();
// 发布测试消息
await channel.QueueDeclareAsync("test-queue", durable: false);
await channel.BasicPublishAsync(
exchange: "",
routingKey: "test-queue",
body: Encoding.UTF8.GetBytes("test message"));
// 等待处理
await Task.Delay(1000);
// 验证消息已处理
// (检查数据库、文件系统或其他副作用)
}
常见模式总结
| 模式 | 用例 |
|---|---|
| 基本固定装置 | 简单的 HTTP 端点测试 |
| 端点发现 | 避免硬编码 URL |
| 数据库测试 | 验证数据访问层 |
| Playwright 集成 | 完整的 UI 测试与真实后端 |
| 配置覆盖 | 特定于测试的设置 |
| 健康检查 | 确保服务准备就绪 |
| 服务通信 | 测试分布式系统交互 |
| 消息队列测试 | 验证异步消息传递 |
棘手/非显而易见的提示
| 问题 | 解决方案 |
|---|---|
| 测试立即超时 | 调用 await _app.StartAsync() 并在运行测试之前等待服务健康 |
| 测试之间的端口冲突 | 使用 xUnit CollectionDefinition 共享固定装置,避免启动多个实例 |
| 由于时机问题导致的不稳定测试 | 实施适当的健康检查轮询而不是 Task.Delay() |
| 无法连接到 SQL Server | 确保通过 GetConnectionStringAsync() 动态获取连接字符串 |
| 平行测试相互干扰 | 使用 [Collection] 属性顺序运行相关测试 |
| 阿斯皮尔仪表板冲突 | 一次只能运行一个阿斯皮尔仪表板;测试将重用相同的仪表板实例 |
CI/CD 集成
GitHub Actions 示例
name: 集成测试
on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main, dev ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 设置 .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: 恢复依赖项
run: dotnet restore
- name: 构建
run: dotnet build --no-restore -c Release
- name: 运行集成测试
run: |
dotnet test tests/YourApp.IntegrationTests \
--no-build \
-c Release \
--logger trx \
--collect:"XPlat Code Coverage"
- name: 发布测试结果
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: "**/TestResults/*.trx"
最佳实践
- 使用
IAsyncLifetime- 确保适当的异步初始化和清理 - 通过集合共享固定装置 - 通过重用应用程序实例减少测试执行时间
- 动态发现端点 - 从不硬编码 localhost:5000 或类似
- 等待健康检查 - 不要假设服务立即准备就绪
- 使用真实依赖项进行测试 - 阿斯皮尔使使用真实的 SQL、Redis 等变得容易
- 清理资源 - 始终正确实现
DisposeAsync - 使用有意义的测试数据 - 用现实的测试数据填充数据库
- 测试失败场景 - 验证错误处理和弹性
- 保持测试隔离 - 每个测试应该是独立的和无序的
- 监控测试执行时间 - 如果测试很慢,考虑并行化或优化
高级:自定义资源等待器
public static class ResourceWaiters
{
public static async Task WaitForSqlServerAsync(
this DistributedApplication app,
string resourceName,
CancellationToken ct = default)
{
var resource = app.GetResource(resourceName);
var connectionString = await resource.GetConnectionStringAsync(ct);
var retryCount = 0;
const int maxRetries = 30;
while (retryCount < maxRetries)
{
try
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);
return; // 成功!
}
catch (SqlException)
{
retryCount++;
await Task.Delay(1000, ct);
}
}
throw new TimeoutException(
$"SQL Server resource '{resourceName}' did not become ready");
}
public static async Task WaitForRedisAsync(
this DistributedApplication app,
string resourceName,
CancellationToken ct = default)
{
var resource = app.GetResource(resourceName);
var connectionString = await resource.GetConnectionStringAsync(ct);
var retryCount = 0;
const int maxRetries = 30;
while (retryCount < maxRetries)
{
try
{
var redis = await ConnectionMultiplexer.ConnectAsync(
connectionString);
await redis.GetDatabase().PingAsync();
return; // 成功!
}
catch
{
retryCount++;
await Task.Delay(1000, ct);
}
}
throw new TimeoutException(
$"Redis resource '{resourceName}' did not become ready");
}
}
// 用法
public async Task InitializeAsync()
{
_app = await appHost.BuildAsync();
await _app.StartAsync();
// 等待依赖项准备就绪
await _app.WaitForSqlServerAsync("yourdb");
await _app.WaitForRedisAsync("cache");
}
阿斯皮尔 CLI 和 MCP 集成
阿斯皮尔 13.1+ 包括 MCP(模型上下文协议)集成,适用于 Claude Code 等 AI 编码助手。这允许 AI 工具查询应用程序状态、查看日志和检查跟踪。
安装阿斯皮尔 CLI
# 全局安装阿斯皮尔 CLI
dotnet tool install -g aspire.cli
# 或更新现有安装
dotnet tool update -g aspire.cli
为 Claude Code 初始化 MCP
# 导航到您的阿斯皮尔项目
cd src/MyApp.AppHost
# 初始化 MCP 配置(自动检测 Claude Code)
aspire mcp init
这会为 Claude Code 连接到您的运行中的阿斯皮尔应用程序创建必要的配置文件。
启用 MCP 运行
# 用 MCP 服务器运行您的阿斯皮尔应用程序
aspire run
# CLI 将输出 MCP 端点 URL
# Claude Code 然后可以连接并查询:
# - 资源状态和健康状态
# - 实时控制台日志
# - 分布式跟踪
# - 可用的阿斯皮尔集成
MCP 功能
连接后,AI 助手可以:
- 查询资源 - 获取资源状态、端点、健康状态
- 用日志调试 - 访问所有服务的实时控制台输出
- 调查遥测 - 查看结构化日志和分布式跟踪
- 执行命令 - 运行特定于资源的命令
- 发现集成 - 列出可用的阿斯皮尔托管集成(Redis、PostgreSQL、Azure 服务)
开发优势
- AI 助手可以看到您的实际运行应用程序状态
- 调试帮助使用真实遥测数据
- 无需手动复制/粘贴日志
- AI 可以帮助关联分布式跟踪跨度
更多详情,请参见:
调试提示
- 运行阿斯皮尔仪表板 - 当测试失败时,检查
http://localhost:15888上的仪表板 - 使用带有 MCP 的阿斯皮尔 CLI - 让 AI 助手查询真实应用程序状态
- 启用详细日志 - 设置
ASPIRE_ALLOW_UNSECURED_TRANSPORT=true以获得更详细的输出 - 检查容器日志 - 使用
docker logs检查容器输出 - 在固定装置中使用断点 - 调试固定装置初始化以捕获启动问题
- 验证资源名称 - 确保 AppHost 和测试之间的资源名称匹配