Skip to content
bitzorcas
中EN

Guide

编写新测试

从风险和证据出发,把回归放进正确项目,写出能先失败、可复现、可维护并进入对应门禁的新测试。

Last updated

新增测试的第一步不是复制最近的 [Fact],而是写清楚“哪一种错误实现必须被它拒绝”。相同业务规则在最低成本层详细枚举,高层只保留关键跨组件合同;这样既能获得快速反馈,也不会让同一断言散落在 Unit、API、Docker 和 Consumer 四个层级。

从风险到证据

纯规则结构Host/APISQL/message包消费

描述可能的回归

定义可观察结果

最低可信层级

Unit / Application

Architecture

Integration no Docker

Integration Docker

Consumer Contract

先红后绿

接入所属门禁并记录证据

“覆盖率提高”不是足够的测试目标。“当租户为空时查询不得返回任意租户数据”“模板消费项目不得 ProjectReference 产品仓库”才是可判定合同。

测试层选择矩阵

要证明的事实首选项目/入口为什么
聚合状态、值对象、错误码Unit.Tests无外部依赖,可穷举分支
Handler、规则、Mediator pipelineApplication.Tests使用真实应用类,只替代 port
项目引用、路径、旧模式红线Architecture.Tests解析程序集/项目/源码/manifest
API 组合、认证、Problem DetailsIntegration Category!=Docker需要 Host 管线,不需真实依赖
SQL、事务、迁移、ORM parityIntegration 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 LicenseLicensing.Tests验证签名、绑定、过期和 fail-closed

如果一个事实需要两个层级,明确各自责任。例如 Unit 穷举 error mapping,Docker 只证明真实唯一索引能触发同一个业务 error;不要在两边复制全部输入矩阵。

先写失败合同

  1. 给回归起业务化名称,包含条件与结果。
  2. Arrange 最小合法前置状态,再只改变触发条件。
  3. 运行定向测试,确认当前缺陷或临时错误实现使它失败。
  4. 检查失败原因确实是目标合同,不是 fixture 未启动或路径写错。
  5. 实现最小修复,让新测试通过。
  6. 重构测试与实现,保持失败消息仍能解释合同。
  7. 运行所属项目和相邻门禁,避免局部绿色。

没有“先红”的回归测试可能断言了既有无关行为,或根本无法捕获缺陷。无法保留缺陷版本时,可以通过最小负向 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,而非只在开发者机器运行。

Terminal window
# 定向回归通过后,运行所属完整项目与架构门禁。
dotnet test tests/BitzOrcas.Application.Tests --configuration Release
dotnet 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 记录命令、结果和未覆盖证据。

另见

100%

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