名称: testcontainers-integration-tests 描述: 使用 TestContainers 在 .NET 中编写集成测试,结合 xUnit。覆盖使用 Docker 容器中的真实数据库、消息队列和缓存进行基础设施测试,而不是模拟对象。 许可证: MIT 可调用: false 元数据: 版本: “1.0.0” 域: 测试 触发词: C#, .NET, 集成测试, TestContainers, xUnit, Docker, 数据库测试, 集成测试 角色: 专家 范围: 实现 输出格式: 代码 相关技能: csharp-开发人员, efcore-模式, dotnet-core-专家
使用 TestContainers 进行集成测试
何时使用此技能
在以下情况使用此技能:
- 编写需要真实基础设施(数据库、缓存、消息队列)的集成测试
- 针对实际数据库测试数据访问层
- 验证消息队列集成
- 测试 Redis 缓存行为
- 避免对基础设施组件使用模拟对象
- 确保测试在类似生产环境中工作
- 测试数据库迁移和模式更改
核心原则
- 真实基础设施优于模拟 - 使用容器中的实际数据库/服务,而不是模拟对象
- 测试隔离 - 每个测试都使用新的容器或新的数据
- 自动清理 - TestContainers 处理容器生命周期和清理
- 快速启动 - 在适当的类中跨测试重用容器
- CI/CD 兼容 - 在支持 Docker 的 CI 环境中无缝工作
- 端口随机化 - 容器使用随机端口以避免冲突
为什么使用 TestContainers 而不是模拟对象?
❌ 模拟基础设施的问题
// 差: 模拟数据库
public class OrderRepositoryTests
{
private readonly Mock<IDbConnection> _mockDb = new();
[Fact]
public async Task GetOrder_ReturnsOrder()
{
// 这不会测试真实的 SQL 行为、约束或性能
_mockDb.Setup(db => db.QueryAsync<Order>(It.IsAny<string>()))
.ReturnsAsync(new[] { new Order { Id = 1 } });
var repo = new OrderRepository(_mockDb.Object);
var order = await repo.GetOrderAsync(1);
Assert.NotNull(order);
}
}
问题:
- 不测试实际的 SQL 查询
- 错过数据库约束、索引和性能
- 可能导致虚假信心
- 不会捕获 SQL 语法错误或模式不匹配
✅ 更好:使用 TestContainers 和真实数据库
// 好: 针对真实数据库测试
public class OrderRepositoryTests : IAsyncLifetime
{
private readonly TestcontainersContainer _dbContainer;
private IDbConnection _connection;
public OrderRepositoryTests()
{
_dbContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithEnvironment("ACCEPT_EULA", "Y")
.WithEnvironment("SA_PASSWORD", "Your_password123")
.WithPortBinding(1433, true)
.Build();
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
var port = _dbContainer.GetMappedPublicPort(1433);
var connectionString = $"Server=localhost,{port};Database=TestDb;User Id=sa;Password=Your_password123;TrustServerCertificate=true";
_connection = new SqlConnection(connectionString);
await _connection.OpenAsync();
// 运行迁移
await RunMigrationsAsync(_connection);
}
public async Task DisposeAsync()
{
await _connection.DisposeAsync();
await _dbContainer.DisposeAsync();
}
[Fact]
public async Task GetOrder_WithRealDatabase_ReturnsOrder()
{
// 安排:插入真实测试数据
await _connection.ExecuteAsync(
"INSERT INTO Orders (Id, CustomerId, Total) VALUES (1, 'CUST1', 100.00)");
var repo = new OrderRepository(_connection);
// 执行:针对真实数据库执行
var order = await repo.GetOrderAsync(1);
// 断言:验证实际数据库行为
Assert.NotNull(order);
Assert.Equal(1, order.Id);
Assert.Equal("CUST1", order.CustomerId);
Assert.Equal(100.00m, order.Total);
}
}
优点:
- 测试真实的 SQL 查询和数据库行为
- 捕获约束违规、索引问题和性能问题
- 验证迁移是否正确工作
- 在数据访问层提供真正信心
所需 NuGet 包
<ItemGroup>
<PackageReference Include="Testcontainers" Version="*" />
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
<!-- 数据库特定包 -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="*" />
<PackageReference Include="Npgsql" Version="*" /> <!-- 对于 PostgreSQL -->
<PackageReference Include="MySqlConnector" Version="*" /> <!-- 对于 MySQL -->
<!-- 其他基础设施 -->
<PackageReference Include="StackExchange.Redis" Version="*" /> <!-- 对于 Redis -->
<PackageReference Include="RabbitMQ.Client" Version="*" /> <!-- 对于 RabbitMQ -->
</ItemGroup>
模式 1: SQL Server 集成测试
using Testcontainers;
using Xunit;
public class SqlServerTests : IAsyncLifetime
{
private readonly TestcontainersContainer _dbContainer;
private IDbConnection _db;
public SqlServerTests()
{
_dbContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithEnvironment("ACCEPT_EULA", "Y")
.WithEnvironment("SA_PASSWORD", "Your_password123")
.WithPortBinding(1433, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
.Build();
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
var port = _dbContainer.GetMappedPublicPort(1433);
var connectionString = $"Server=localhost,{port};Database=master;User Id=sa;Password=Your_password123;TrustServerCertificate=true";
_db = new SqlConnection(connectionString);
await _db.OpenAsync();
// 创建测试数据库
await _db.ExecuteAsync("CREATE DATABASE TestDb");
await _db.ExecuteAsync("USE TestDb");
// 运行模式迁移
await _db.ExecuteAsync(@"
CREATE TABLE Orders (
Id INT PRIMARY KEY,
CustomerId NVARCHAR(50) NOT NULL,
Total DECIMAL(18,2) NOT NULL,
CreatedAt DATETIME2 DEFAULT GETUTCDATE()
)");
}
public async Task DisposeAsync()
{
await _db.DisposeAsync();
await _dbContainer.DisposeAsync();
}
[Fact]
public async Task CanInsertAndRetrieveOrder()
{
// 安排
await _db.ExecuteAsync(@"
INSERT INTO Orders (Id, CustomerId, Total)
VALUES (1, 'CUST001', 99.99)");
// 执行
var order = await _db.QuerySingleAsync<Order>(
"SELECT * FROM Orders WHERE Id = @Id",
new { Id = 1 });
// 断言
Assert.Equal(1, order.Id);
Assert.Equal("CUST001", order.CustomerId);
Assert.Equal(99.99m, order.Total);
}
}
模式 2: PostgreSQL 集成测试
public class PostgreSqlTests : IAsyncLifetime
{
private readonly TestcontainersContainer _dbContainer;
private NpgsqlConnection _connection;
public PostgreSqlTests()
{
_dbContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("postgres:latest")
.WithEnvironment("POSTGRES_PASSWORD", "postgres")
.WithEnvironment("POSTGRES_DB", "testdb")
.WithPortBinding(5432, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432))
.Build();
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
var port = _dbContainer.GetMappedPublicPort(5432);
var connectionString = $"Host=localhost;Port={port};Database=testdb;Username=postgres;Password=postgres";
_connection = new NpgsqlConnection(connectionString);
await _connection.OpenAsync();
// 创建模式
await _connection.ExecuteAsync(@"
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id VARCHAR(50) NOT NULL,
total NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)");
}
public async Task DisposeAsync()
{
await _connection.DisposeAsync();
await _dbContainer.DisposeAsync();
}
[Fact]
public async Task PostgreSql_ShouldHandleTransactions()
{
using var transaction = await _connection.BeginTransactionAsync();
await _connection.ExecuteAsync(
"INSERT INTO orders (customer_id, total) VALUES (@CustomerId, @Total)",
new { CustomerId = "CUST1", Total = 100.00m },
transaction);
await transaction.RollbackAsync();
var count = await _connection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM orders");
Assert.Equal(0, count); // 回滚应阻止插入
}
}
模式 3: Redis 集成测试
public class RedisTests : IAsyncLifetime
{
private readonly TestcontainersContainer _redisContainer;
private IConnectionMultiplexer _redis;
public RedisTests()
{
_redisContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("redis:alpine")
.WithPortBinding(6379, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
.Build();
}
public async Task InitializeAsync()
{
await _redisContainer.StartAsync();
var port = _redisContainer.GetMappedPublicPort(6379);
_redis = await ConnectionMultiplexer.ConnectAsync($"localhost:{port}");
}
public async Task DisposeAsync()
{
await _redis.DisposeAsync();
await _redisContainer.DisposeAsync();
}
[Fact]
public async Task Redis_ShouldCacheValues()
{
var db = _redis.GetDatabase();
// 设置值
await db.StringSetAsync("key1", "value1");
// 获取值
var value = await db.StringGetAsync("key1");
Assert.Equal("value1", value.ToString());
}
[Fact]
public async Task Redis_ShouldExpireKeys()
{
var db = _redis.GetDatabase();
await db.StringSetAsync("temp-key", "temp-value",
expiry: TimeSpan.FromSeconds(1));
// 键应存在
Assert.True(await db.KeyExistsAsync("temp-key"));
// 等待过期
await Task.Delay(1100);
// 键应消失
Assert.False(await db.KeyExistsAsync("temp-key"));
}
}
模式 4: RabbitMQ 集成测试
public class RabbitMqTests : IAsyncLifetime
{
private readonly TestcontainersContainer _rabbitContainer;
private IConnection _connection;
public RabbitMqTests()
{
_rabbitContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("rabbitmq:management-alpine")
.WithPortBinding(5672, true) // AMQP
.WithPortBinding(15672, true) // 管理 UI
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5672))
.Build();
}
public async Task InitializeAsync()
{
await _rabbitContainer.StartAsync();
var port = _rabbitContainer.GetMappedPublicPort(5672);
var factory = new ConnectionFactory
{
HostName = "localhost",
Port = port,
UserName = "guest",
Password = "guest"
};
_connection = await factory.CreateConnectionAsync();
}
public async Task DisposeAsync()
{
await _connection.CloseAsync();
await _rabbitContainer.DisposeAsync();
}
[Fact]
public async Task RabbitMq_ShouldPublishAndConsumeMessage()
{
using var channel = await _connection.CreateChannelAsync();
var queueName = "test-queue";
await channel.QueueDeclareAsync(queueName, durable: false,
exclusive: false, autoDelete: true);
// 发布消息
var message = "Hello, RabbitMQ!";
var body = Encoding.UTF8.GetBytes(message);
await channel.BasicPublishAsync(exchange: "",
routingKey: queueName,
body: body);
// 消费消息
var consumer = new EventingBasicConsumer(channel);
var tcs = new TaskCompletionSource<string>();
consumer.Received += (model, ea) =>
{
var receivedMessage = Encoding.UTF8.GetString(ea.Body.ToArray());
tcs.SetResult(receivedMessage);
};
await channel.BasicConsumeAsync(queueName, autoAck: true,
consumer: consumer);
// 等待消息
var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(message, received);
}
}
模式 5: 多容器网络
当您需要多个容器通信时:
public class MultiContainerTests : IAsyncLifetime
{
private readonly INetwork _network;
private readonly TestcontainersContainer _dbContainer;
private readonly TestcontainersContainer _redisContainer;
public MultiContainerTests()
{
_network = new TestcontainersNetworkBuilder()
.Build();
_dbContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("postgres:latest")
.WithNetwork(_network)
.WithNetworkAliases("db")
.WithEnvironment("POSTGRES_PASSWORD", "postgres")
.Build();
_redisContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("redis:alpine")
.WithNetwork(_network)
.WithNetworkAliases("redis")
.Build();
}
public async Task InitializeAsync()
{
await _network.CreateAsync();
await Task.WhenAll(
_dbContainer.StartAsync(),
_redisContainer.StartAsync());
}
public async Task DisposeAsync()
{
await Task.WhenAll(
_dbContainer.DisposeAsync().AsTask(),
_redisContainer.DisposeAsync().AsTask());
await _network.DisposeAsync();
}
[Fact]
public async Task Containers_CanCommunicate()
{
// 两个容器可以通过网络别名相互访问
// db -> redis://redis:6379
// redis -> postgres://db:5432
}
}
模式 6: 跨测试重用容器
为了更快的测试执行,在类中跨测试重用容器:
[Collection("Database collection")]
public class FastDatabaseTests
{
private readonly DatabaseFixture _fixture;
public FastDatabaseTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Test1()
{
// 使用 _fixture.Connection
// 如果需要,测试后清理数据
}
[Fact]
public async Task Test2()
{
// 重用相同的容器
}
}
// 共享夹具
public class DatabaseFixture : IAsyncLifetime
{
private readonly TestcontainersContainer _container;
public IDbConnection Connection { get; private set; }
public DatabaseFixture()
{
_container = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithEnvironment("ACCEPT_EULA", "Y")
.WithEnvironment("SA_PASSWORD", "Your_password123")
.WithPortBinding(1433, true)
.Build();
}
public async Task InitializeAsync()
{
await _container.StartAsync();
// 设置连接
}
public async Task DisposeAsync()
{
await Connection.DisposeAsync();
await _container.DisposeAsync();
}
}
[CollectionDefinition("Database collection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
模式 7: 使用真实数据库测试迁移
public class MigrationTests : IAsyncLifetime
{
private readonly TestcontainersContainer _container;
private string _connectionString;
public async Task InitializeAsync()
{
_container = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithEnvironment("ACCEPT_EULA", "Y")
.WithEnvironment("SA_PASSWORD", "Your_password123")
.WithPortBinding(1433, true)
.Build();
await _container.StartAsync();
var port = _container.GetMappedPublicPort(1433);
_connectionString = $"Server=localhost,{port};Database=TestDb;User Id=sa;Password=Your_password123;TrustServerCertificate=true";
}
[Fact]
public async Task Migrations_ShouldRunSuccessfully()
{
// 运行 Entity Framework 迁移
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseSqlServer(_connectionString);
using var context = new AppDbContext(optionsBuilder.Options);
// 应用迁移
await context.Database.MigrateAsync();
// 验证模式
var canConnect = await context.Database.CanConnectAsync();
Assert.True(canConnect);
// 验证表存在
var tables = await context.Database.SqlQueryRaw<string>(
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES").ToListAsync();
Assert.Contains("Orders", tables);
Assert.Contains("Customers", tables);
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
最佳实践
- 始终使用 IAsyncLifetime - 正确的异步设置和拆卸
- 等待端口可用性 - 使用
WaitStrategy确保容器就绪 - 使用随机端口 - 让 TestContainers 自动分配端口
- 在测试之间清理数据 - 使用新容器或截断表
- 尽可能重用容器 - 比为每个测试创建新容器更快
- 测试真实查询 - 不要只测试模拟对象;验证实际的 SQL 行为
- 验证约束 - 测试外键、唯一约束、索引
- 测试事务 - 验证回滚和提交行为
- 使用真实数据 - 使用类似生产的数据量测试
- 处理清理 - 始终在
DisposeAsync中处置容器
常见问题及解决方案
问题 1: 容器启动超时
问题: 容器启动时间过长
解决方案:
_container = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("postgres:latest")
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilPortIsAvailable(5432)
.WithTimeout(TimeSpan.FromMinutes(2)))
.Build();
问题 2: 端口已在使用
问题: 测试失败,因为端口已绑定
解决方案: 始终使用随机端口映射:
.WithPortBinding(5432, true) // true = 分配随机公共端口
问题 3: 容器未清理
问题: 测试后容器仍在运行
解决方案: 确保正确处理:
public async Task DisposeAsync()
{
await _connection?.DisposeAsync();
await _container?.DisposeAsync();
}
问题 4: 在 CI 中测试失败但在本地通过
问题: CI 环境没有 Docker
解决方案: 确保 CI 有 Docker 支持:
# GitHub Actions
runs-on: ubuntu-latest # 预装 Docker
services:
docker:
image: docker:dind
CI/CD 集成
GitHub Actions
name: 集成测试
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest # 预装 Docker
steps:
- uses: actions/checkout@v3
- name: 设置 .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: 运行集成测试
run: |
dotnet test tests/YourApp.IntegrationTests \
--filter Category=Integration \
--logger trx
- name: 清理容器
if: always()
run: docker container prune -f
模式 8: 使用 Respawn 重置数据库
当跨测试重用容器时,使用 Respawn 重置数据库状态,而不是重新创建容器:
<PackageReference Include="Respawn" Version="*" />
基本 Respawn 设置
using Respawn;
public class DatabaseFixture : IAsyncLifetime
{
private readonly TestcontainersContainer _container;
private Respawner _respawner = null!;
public NpgsqlConnection Connection { get; private set; } = null!;
public string ConnectionString { get; private set; } = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
var port = _container.GetMappedPublicPort(5432);
ConnectionString = $"Host=localhost;Port={port};Database=testdb;Username=postgres;Password=postgres";
Connection = new NpgsqlConnection(ConnectionString);
await Connection.OpenAsync();
// 首先运行迁移
await RunMigrationsAsync();
// 在模式存在后创建 respawner
_respawner = await Respawner.CreateAsync(ConnectionString, new RespawnerOptions
{
TablesToIgnore = new Table[]
{
"__EFMigrationsHistory", // EF Core 迁移表
"AspNetRoles", // Identity 角色(种子数据)
"schema_version" // DbUp/Flyway 版本表
},
DbAdapter = DbAdapter.Postgres
});
}
/// <summary>
/// 重置数据库到清洁状态。在测试设置或测试之间调用此方法。
/// </summary>
public async Task ResetDatabaseAsync()
{
await _respawner.ResetAsync(ConnectionString);
}
public async Task DisposeAsync()
{
await Connection.DisposeAsync();
await _container.DisposeAsync();
}
}
在测试中使用 Respawn
[Collection("Database collection")]
public class OrderTests : IAsyncLifetime
{
private readonly DatabaseFixture _fixture;
public OrderTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
public async Task InitializeAsync()
{
// 在每个测试前重置数据库
await _fixture.ResetDatabaseAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateOrder_ShouldPersist()
{
// 数据库是清洁的 - 没有其他测试的残留数据
await _fixture.Connection.ExecuteAsync(
"INSERT INTO orders (customer_id, total) VALUES (@CustomerId, @Total)",
new { CustomerId = "CUST1", Total = 100.00m });
var count = await _fixture.Connection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM orders");
Assert.Equal(1, count);
}
[Fact]
public async Task AnotherTest_StartsWithCleanDatabase()
{
// 这个测试也以空表开始
var count = await _fixture.Connection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM orders");
Assert.Equal(0, count); // 清洁状态!
}
}
Respawn 选项
var respawner = await Respawner.CreateAsync(connectionString, new RespawnerOptions
{
// 要保留的表(参考数据、迁移历史)
TablesToIgnore = new Table[]
{
"__EFMigrationsHistory",
new Table("public", "lookup_data"), // 模式限定
},
// 要清理的模式(默认:所有模式)
SchemasToInclude = new[] { "public", "app" },
// 或排除特定模式
SchemasToExclude = new[] { "audit", "logging" },
// 数据库适配器
DbAdapter = DbAdapter.Postgres, // 或 SqlServer, MySql
// 处理循环外键
WithReseed = true // 重置标识列(SQL Server)
});
为什么使用 Respawn 而不是容器重新创建
| 方法 | 优点 | 缺点 |
|---|---|---|
| 每个测试新容器 | 完全隔离 | 慢(每个容器 10-30 秒) |
| Respawn | 快(约 50 毫秒),保留模式/迁移 | 需要小心排除表 |
| 事务回滚 | 最快 | 不能测试提交行为 |
使用 Respawn 当:
- 测试通过 xUnit 集合夹具共享容器
- 您需要测试实际提交(不仅是回滚)
- 容器启动时间是瓶颈
性能提示
- 重用容器 - 在集合中跨测试共享夹具
- 使用 Respawn - 重置数据而不重新创建容器
- 并行执行 - TestContainers 自动处理端口冲突
- 使用轻量级镜像 - Alpine 版本更小更快
- 缓存镜像 - Docker 会在本地缓存拉取的镜像
- 限制容器资源 - 如果需要,设置 CPU/内存限制:
.WithResourceMapping(new CpuCount(2))
.WithResourceMapping(new MemoryLimit(512 * 1024 * 1024)) // 512MB