mcpField
D
dotnet-integration-testing

dotnet-integration-testing

github.com/wshaddix测试与质量86101 次安装35 周前更新
代码仓库

关于 dotnet-integration-testing

这项技能介绍 .NET 应用的集成测试模式,适用于测试 API 和分布式服务的开发者。内容涵盖 WebApplicationFactory 进程内测试、Testcontainers 临时基础设施、.NET Aspire 测试及数据库隔离。

主要功能

  1. 01使用 WebApplicationFactory 进行进程内 API 测试
  2. 02通过 Testcontainers 创建临时基础设施
  3. 03使用 .NET Aspire 测试多服务应用
  4. 04数据库夹具与隔离策略
  5. 05Redis 容器测试模式

适用场景

  1. 01无需部署即可测试 ASP.NET Core API
  2. 02通过真实基础设施验证数据库行为
  3. 03测试 Aspire 服务发现和健康检查
  4. 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 常见问题

  1. 不要硬编码 Microsoft.AspNetCore.Mvc.Testing 版本。 包版本必须与项目的目标框架主版本匹配。例如,指定 Version="8.0.0" 会导致 net9.0 项目出错。
  2. 不要忘记为 Program 类添加 InternalsVisibleTo。 否则,WebApplicationFactory<Program> 无法访问入口点,测试会在编译时失败。
  3. 不要将 EnsureCreated() 与 Respawn 配合使用。 EnsureCreated() 不会跟踪迁移。对于生产架构,请使用 Database.MigrateAsync();对于简单的测试架构,才使用 EnsureCreated()。
  4. 不要在 HttpClient 之前释放 WebApplicationFactory。 工厂拥有测试服务器;释放工厂会使所有客户端失效。让 xUnit 通过 IClassFixture 管理释放。
  5. 不要在 Testcontainers 中使用 localhost 端口。 Testcontainers 会将随机主机端口映射到容器端口。始终使用容器对象中的连接字符串(例如 _container.GetConnectionString()),切勿使用硬编码端口。
  6. 不要在 CI 中跳过 Docker 可用性检查。 Testcontainers 需要正在运行的 Docker 守护进程。确保 CI 环境中有可用的 Docker,或在 Docker 不可用时有条件地跳过测试。

参考资料

评论

支持 Markdown