Skip to content
bitzorcas
中EN

Tutorial

编写集成测试与行为对齐:Testcontainers 容器化实战

使用 Testcontainers 启动真实 SQL Server 2022 与 Redis 容器环境,为民商事案件立案切片编写全链路集成测试,并验证 EF Core 与 SqlSugar 双 ORM 的 100% 行为等价对齐。

Last updated

在许多企业级后端工程中,经常出现一种荒谬的现象:单元测试覆盖率高达 90%,但一发布到生产环境就频繁崩溃。

造成这种现象的根源在于对“Mock 模拟对象”的过度滥用。单元测试通过内存 Mock 绕过了真实的数据库引擎,根本无法检验以下物理事实:

  • SQL 方言与索引行为:某些复杂查询在 SQLite 内存库中正常运行,但在 SQL Server 生产实例中因类型隐式转换而导致全表扫描甚至语法报错;
  • 多租户过滤器漏网之鱼:Mock 仓储无法验证持久化层全局注入的 WHERE TenantId = @TenantId 是否真实生效;
  • 事务与发件箱原子一致性:无法验证业务数据更新与 CAP 事务性发件箱记录是否在同一物理本地事务中提交。

BitzOrcas.Modern 确立“基于 Testcontainers 的真实容器集成测试”作为工程发布门禁。在自动化测试中拉起按 SHA-256 摘要严格锁定的真实 SQL Server 2022 与 Redis 容器,验证 10 级应用管道、真实事务原子性与双 ORM 行为 100% 等价。

集成测试三维验证金字塔

1. 领域纯函数单元测试 (Domain Unit Tests)
纯内存极速验证聚合根核心不变量 (无外部 IO)

2. Testcontainers 真实容器集成测试 (Integration Tests)
启动真 SQL Server 容器,穿透 HTTP -> 10 级管道 -> 事务落盘

3. 双 ORM 行为等价对齐测试 (Dual-ORM Parity Tests)
断言 SqlSugar 与 EF Core 对同一聚合根的 CRUD/软删除/租户隔离表现绝对一致

4. ArchUnitNET 架构依赖守护门禁 (Architecture Fitness Functions)
编译期与 CI 中强行阻断任何违反物理依赖分层的代码合入


第一步:构建跨平台确定性的容器测试夹具

在 Apple Silicon (macOS) 与 Linux 混合协作的团队中,若不加约束地启动容器,经常因 CPU 架构漂移而导致测试环境不一致。

在 tests/BitzOrcas.Integration.Tests/Infrastructure/ 中创建按 SHA-256 摘要与平台锁定的测试夹具:

tests/BitzOrcas.Integration.Tests/Infrastructure/SqlServerTestContainer.cs
using DotNet.Testcontainers.Images;
using Testcontainers.MsSql;
using Xunit;
using ContainerPlatform = DotNet.Testcontainers.Images.Platform;
namespace BitzOrcas.Integration.Tests.Infrastructure;
/// <summary>
/// 集成测试 SQL Server 容器工厂
/// </summary>
internal static class SqlServerTestContainer
{
/// <summary>
/// SQL Server 2022 CU 官方不可变镜像摘要。
/// 显式声明 linux/amd64 架构,杜绝 Apple Silicon (M系列芯片) 主机把平台选择误判为镜像漂移。
/// </summary>
private const string ImmutableImageDigest =
"mcr.microsoft.com/mssql/server@sha256:e07b9699a2b749969f19d86563ceeea22bd3a69f7f1db85a8d1ac4bdaf0c6f56";
internal static MsSqlContainer Create()
=> new MsSqlBuilder(new DockerImage(ImmutableImageDigest, new ContainerPlatform("linux/amd64"))).Build();
}
/// <summary>
/// 在同一个 xUnit 测试类内共享单个 SQL Server 容器生命周期
/// </summary>
public sealed class SqlServerContainerFixture : IAsyncLifetime
{
public MsSqlContainer Container { get; } = SqlServerTestContainer.Create();
public async Task InitializeAsync()
{
// 1. 启动容器并执行内部健康检查探针
await Container.StartAsync();
}
public Task DisposeAsync() => Container.DisposeAsync().AsTask();
}

第二步:编写端到端切片集成测试

利用 ASP.NET Core 的 WebApplicationFactory<Program>,将测试容器的真实连接串无缝注入 API 宿主,发起真实网络调用:

tests/BitzOrcas.Integration.Tests/Legal/MatterIntakeIntegrationTests.cs
using System.Net;
using System.Net.Http.Json;
using BitzOrcas.Domain.Results;
using BitzOrcas.Integration.Tests.Infrastructure;
using BitzOrcas.Modules.Legal.Application.Commands.CreateMatterIntake;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Shouldly;
using Xunit;
public sealed class MatterIntakeIntegrationTests : IClassFixture<SqlServerContainerFixture>
{
private readonly SqlServerContainerFixture _fixture;
public MatterIntakeIntegrationTests(SqlServerContainerFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task CreateMatter_TraversingFullPipeline_ShouldPersistToDatabaseAndCapOutbox()
{
// 1. 动态将测试容器真实连接串覆盖至宿主配置
var appFactory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.UseSetting("ConnectionStrings:Default", _fixture.Container.GetConnectionString());
});
var client = appFactory.CreateClient();
// 2. 构造真实的涉案标的立案命令 Payload
var command = new CreateMatterIntakeCommand(
MatterTitle: "跨国跨境电商知识产权纠纷案",
ClientName: "某全球头部跨境贸易企业",
OpposingParty: "某境外侵权分销商网络",
ClaimAmount: 8_800_000.00m,
Category: MatterCategory.CrossBorder);
// 3. 发送真实 HTTP POST 请求
var response = await client.PostAsJsonAsync("/api/legal/matters", command);
// 4. 机器断言:验证 HTTP 200 OK 并成功产出立案 ID
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<Result<string>>();
result.ShouldNotBeNull();
result.IsSuccess.ShouldBeTrue();
var matterId = result.Value;
matterId.ShouldNotBeNullOrWhiteSpace();
// 5. 穿透底层物理数据库进行断言:验证实体真实落盘且租户字段正确
using var connection = new Microsoft.Data.SqlClient.SqlConnection(_fixture.Container.GetConnectionString());
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(1) FROM LegalMatterIntake WHERE Id = @Id AND IsDeleted = 0";
cmd.Parameters.AddWithValue("@Id", matterId);
var count = (int)(await cmd.ExecuteScalarAsync() ?? 0);
count.ShouldBe(1);
}
}

第三步:双 ORM 行为对齐断言(Dual-ORM Parity Tests)

BitzOrcas.Modern 支持 SqlSugar 与 EF Core 双生产级持久化 Adapter。为了确保无论客户选择哪套 ORM 底座,业务行为均 100% 等价,我们编写对齐测试(Parity Tests):

tests/BitzOrcas.Integration.Tests/Persistence/OrmParityTests.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using BitzOrcas.Domain.Abstractions;
using BitzOrcas.Infrastructure.EfCore;
using BitzOrcas.Infrastructure.SqlSugar;
using BitzOrcas.Integration.Tests.Infrastructure;
using BitzOrcas.Modules.Legal.Domain;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
public sealed class OrmParityTests : IClassFixture<SqlServerContainerFixture>
{
private readonly SqlServerContainerFixture _fixture;
public OrmParityTests(SqlServerContainerFixture fixture)
{
_fixture = fixture;
}
[Theory]
[InlineData("SqlSugar")]
[InlineData("EFCore")]
public async Task BothOrmAdapters_MustEnforceSoftDeleteAndTenantFilterIdentically(string ormProvider)
{
// 1. 根据 Provider 解析对应的命令仓储端口
var repository = ResolveRepositoryForProvider<MatterIntake>(ormProvider, _fixture.Container.GetConnectionString());
var matterId = Guid.NewGuid().ToString("N");
var matter = MatterIntake.Create(
matterId,
$"CODE-{matterId[..6]}",
"双 ORM 等价性测试案件",
"当事人 A",
"相对方 B",
100_000.00m,
tenantId: "1000001").GetValueOrThrow();
// 2. 写入数据
var saveResult = await repository.SaveAsync(matter, CancellationToken.None);
saveResult.IsSuccess.ShouldBeTrue();
// 3. 软删除该聚合根
matter.Delete(deletedBy: "auditor_user");
await repository.SaveAsync(matter, CancellationToken.None);
// 4. 断言:标准租户查询在两套 ORM 下均必须返回未找到(软删除过滤生效)
var queried = await repository.FindAsync(matterId, CancellationToken.None);
queried.IsFailure.ShouldBeTrue();
queried.Error.Code.ShouldBe("Repository.Aggregate.NotFound");
}
private static ICommandRepository<TEntity, string> ResolveRepositoryForProvider<TEntity>(string provider, string connectionString)
where TEntity : class
{
var services = new ServiceCollection();
services.AddLogging();
if (string.Equals(provider, "SqlSugar", StringComparison.OrdinalIgnoreCase))
{
services.AddBitzOrcasSqlSugar(options =>
{
options.ConnectionString = connectionString;
});
}
else
{
services.AddBitzOrcasEfCore(options =>
{
options.ConnectionString = connectionString;
});
}
var serviceProvider = services.BuildServiceProvider();
return serviceProvider.GetRequiredService<ICommandRepository<TEntity, string>>();
}
}

核心质量收益

  1. 绝对消灭环境漂移:Docker 镜像锁定具体 SHA-256 摘要,保证 CI 自动化流水线与本地开发机的物理行为 100% 绝对一致;
  2. 零成本重构底气:双 ORM 对齐测试让团队可以在无需修改一行业务代码的前提下,随意在 SqlSugar 与 EF Core 之间平滑切换;
  3. 真实可信的质量门禁:通过 Testcontainers,测试真正验证了 SQL Server 本地事务、外键约束、唯一索引与 CAP Outbox 的原子物理落盘。

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%