新增测试的第一步不是复制最近的 [Fact],而是写清楚“哪一种错误实现必须被它拒绝”。相同业务规则在最低成本层详细枚举,高层只保留关键跨组件合同;这样既能获得快速反馈,也不会让同一断言散落在 Unit、API、Docker 和 Consumer 四个层级。
从风险到证据
“覆盖率提高”不是足够的测试目标。“当租户为空时查询不得返回任意租户数据”“模板消费项目不得 ProjectReference 产品仓库”才是可判定合同。
测试层选择矩阵
| 要证明的事实 | 首选项目/入口 | 为什么 |
|---|---|---|
| 聚合状态、值对象、错误码 | Unit.Tests | 无外部依赖,可穷举分支 |
| Handler、规则、Mediator pipeline | Application.Tests | 使用真实应用类,只替代 port |
| 项目引用、路径、旧模式红线 | Architecture.Tests | 解析程序集/项目/源码/manifest |
| API 组合、认证、Problem Details | Integration Category!=Docker | 需要 Host 管线,不需真实依赖 |
| SQL、事务、迁移、ORM parity | Integration Category=Docker | 需要真实 provider |
| CAP/Outbox/消费重试 | Docker messaging 分片 | 需要 SQL Server + RabbitMQ |
| Generator 算法 | CodeGeneration.Tests | 快速验证生成输入输出 |
| Generator NuGet analyzer 消费 | Generator.Package.Tests | 隔离包消费边界 |
| 模板实例化与构建 | ConsumerTemplate + verify-template.sh | 验证 Profile/资产矩阵 |
| 商业 NuGet 消费 | Framework.ConsumerContract.Tests | 现场 pack + 隔离 Feed/cache |
| Runtime License | Licensing.Tests | 验证签名、绑定、过期和 fail-closed |
如果一个事实需要两个层级,明确各自责任。例如 Unit 穷举 error mapping,Docker 只证明真实唯一索引能触发同一个业务 error;不要在两边复制全部输入矩阵。
先写失败合同
- 给回归起业务化名称,包含条件与结果。
- Arrange 最小合法前置状态,再只改变触发条件。
- 运行定向测试,确认当前缺陷或临时错误实现使它失败。
- 检查失败原因确实是目标合同,不是 fixture 未启动或路径写错。
- 实现最小修复,让新测试通过。
- 重构测试与实现,保持失败消息仍能解释合同。
- 运行所属项目和相邻门禁,避免局部绿色。
没有“先红”的回归测试可能断言了既有无关行为,或根本无法捕获缺陷。无法保留缺陷版本时,可以通过最小负向 fixture 证明规则会红。
领域回归示例
[Fact]public void Create_WithoutTenant_ShouldReturnFailure(){ // Act:DocumentCategory 是租户聚合,空 TenantId 必须在进入 ORM 前被拒绝。 var result = DocumentCategory.Create( string.Empty, "kb-1", null, "分类A", null, null, null, 0);
// Assert:同时验证失败分类和调用方依赖的稳定错误码。 result.IsFailure.ShouldBeTrue(); result.Error.Code.ShouldBe("Docs.Category.TenantRequired");}这段合同来自当前 DocumentCategoryTests。它没有启动数据库,因为要证明的是聚合创建时的租户不变量;真实 ORM tenant filter 另由 parity 合同负责。
API 合同示例
[Fact]public async Task Ping_WithoutToken_ShouldReturnUnauthorized(){ // Arrange:使用仓库 API factory,但刻意不设置 Authorization。 await using var factory = new TestApiFactory(); var client = factory.CreateClient();
// Act:调用真实 Ping 路由;该端点要求认证。 var response = await client.GetAsync("/api/ping");
// Assert:未认证与无权限是不同协议,必须稳定返回 401。 response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);}这正是当前 ApiShellTests 已守护的合同。写 API 测试前先从 endpoint 源码核对实际 route、认证和响应类型;需要断言 Problem Details 时再读取真实 error catalog。不要沿用旧文档中的 /api/v1/<endpoint> 或凭想象构造状态码和错误码。
Docker parity 示例
Website 当前使用两个相邻合同分别驱动 EF Core 与 SqlSugar,并用同一个 WebsiteSlugConflictExceptionMapper 判断具名唯一冲突。下面保留 EF Core 一侧的完整关键路径;SqlSugar 一侧使用真实 ISqlSugarClient 写入相同冲突并回滚。
// Docker Trait 位于合同类上,确保该用例不会进入无容器 Job。[Fact]public async Task EfCore_Commit_Should_Expose_Named_Slug_Conflict(){ // Arrange:注册真实 EF Core adapter、初始化 schema,并插入第一个 Slug。 var services = CreateBaseServices(); services.AddBitzOrcasEfCore(options => options.ConnectionString = _database.GetConnectionString()); await using var provider = services.BuildServiceProvider(); await EnsureEfSchemaAsync(provider); await InsertEfAsync(provider, "ef-same-slug");
// Act:第二次写入相同 Slug,在真实事务 Commit 时触发具名唯一索引。 using var scope = provider.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<BitzOrcasDbContext>(); var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); await unitOfWork.BeginAsync(CancellationToken.None); context.Set<WebsiteSlugProviderFixture>() .Add(new WebsiteSlugProviderFixture { Slug = "ef-same-slug" }); var exception = await Should.ThrowAsync<DbUpdateException>( () => unitOfWork.CommitAsync(CancellationToken.None));
// Assert:adapter 能识别冲突,随后显式回滚事务。 WebsiteSlugConflictExceptionMapper.IsConflict(exception).ShouldBeTrue(); await unitOfWork.RollbackAsync(CancellationToken.None);}不要由此推导“所有异常都会映射成 Slug 冲突”:mapper 只识别目标具名索引。新增 provider 合同时还要覆盖 tenant filter、分页、并发和 transaction 等受影响语义,并确认用例进入唯一 CI 分片。
架构门禁示例
结构回归要返回所有 offender 和修复原因。下面的形态适合禁止 Application 项目直接引用 ORM 包:
[Fact]public void ApplicationProjects_ShouldNotReferenceOrmPackages(){ // 扫描所有 Application 项目并收集,而不是遇到首项后立即退出。 var offenders = DiscoverApplicationProjects() .SelectMany(ReadPackageReferences) .Where(reference => ForbiddenOrmPackages.Contains(reference.Package)) .Select(reference => $"{reference.Project}: {reference.Package}") .OrderBy(value => value) .ToArray();
// 列出全部违规项,避免开发者一次修一个后才看到下一个。 offenders.ShouldBeEmpty( "Application 必须通过 port 隔离 ORM;实现包只能位于 Infrastructure。");}扫描源码前遵守 .aiignore,路径统一处理 / 与 \,例外放在机器可读精确清单并注明退出条件。
确定性与隔离
- 时间:注入固定时钟,不使用
DateTimeOffset.UtcNow作为断言基准。 - ID:只在需要唯一性时生成;要断言排序则固定样本。
- 租户:每个测试显式创建,不依赖全局默认 tenant。
- 数据库:唯一数据库/schema/业务键,失败后也清理。
- 消息:唯一 correlation/business key,有上限轮询,不固定 sleep。
- 文件:使用临时目录并在
finally/async dispose 删除。 - 包:隔离 NuGet Feed、HTTP cache 和 global-packages。
- 端口:Testcontainers 动态绑定,不假定本机端口空闲。
测试程序集当前关闭 Integration 并行不代表可以共享脏数据;CI 分片和未来执行策略变化都会暴露这种耦合。
失败消息与断言粒度
优先断言稳定业务字段:error code、状态、tenant、业务键、事件类型、持久化结果。不要依赖完整本地化消息、provider 原始异常或 JSON 属性顺序。集合失败应输出缺少/多余项;架构失败应输出相对路径。
一个测试最好只表达一个业务合同,但可以有多个为该合同服务的断言。例如“失败关闭”同时断言状态码、error code 和没有副作用是合理的。
接入项目与 CI
新增测试项目需加入 BitzOrcas.Modern.slnx,继承统一 SDK/包版本和 Release 警告策略。Docker 测试使用 TestCategories.Docker,更新 .github/workflows/ci.yml 分片,并让 Architecture.Tests 检查 trait 与分片覆盖。模板/商业测试还需接入对应 GA workflow,而非只在开发者机器运行。
# 定向回归通过后,运行所属完整项目与架构门禁。dotnet test tests/BitzOrcas.Application.Tests --configuration Releasedotnet test tests/BitzOrcas.Architecture.Tests --configuration Release
# 全局检查遗漏旧模式;根据本次规则替换 token,预期结果为零。rg -n 'ForbiddenOldPattern' src tests --glob '*.cs'git diff --check评审清单
- 测试说明了要阻止的错误实现;
- 选择的是最低可信层级,没有重复枚举;
- 修复前能红,失败原因指向目标合同;
- 使用真实 route/type/error code,而不是文档猜测;
- 外部 port、时间、ID、tenant 与资源都可控;
- Docker 用例有 Trait、唯一分片和确定清理;
- Consumer 用例不回引产品源码且缓存隔离;
- 新项目/分类进入 solution 与 CI;
- 定向、所属全量、Architecture 和 diff sweep 已执行;
- PR 记录命令、结果和未覆盖证据。