单元测试负责以最低成本穷举业务分支。它应在没有数据库、消息代理、Redis、网络和真实系统时钟的情况下运行,并通过公开行为表达规则。BitzOrcas 把纯基础/领域测试放在 BitzOrcas.Unit.Tests,把 Handler、授权策略和 Mediator pipeline 等应用控制流放在 BitzOrcas.Application.Tests。
边界判断
需要确认 SQL 翻译、索引冲突、事务、CAP、序列化协议或中间件顺序时,就已经越过单元边界。不要用 EF InMemory 或一个 List<T> 仓储冒充 provider 合同。
优先测试的行为
| 类型 | 高价值断言 | 低价值替代 |
|---|---|---|
| 聚合 | 合法状态转换、拒绝分支、领域事件 | 属性 getter |
| 值对象 | 规范化、相等性、边界和错误 | 逐行复制实现算法 |
Result<T> | 稳定错误码、错误类型、是否短路 | 只断言 IsFailure |
| 请求规则 | tenant-specific 规则、取消、组合顺序 | mock 私有方法 |
| Handler | port 调用后的可观察结果与失败映射 | 每个 mock 调用次数 |
| Pipeline | 授权/校验/事务等是否按合同短路 | 测框架 DI 本身 |
| 纯算法 | 配额、时间窗口、掩码、分页 | 用真实时钟和随机数 |
测试名应包含场景和结果,例如 Should_ShortCircuit_With_Failure_And_Skip_Handler_When_Rule_Fails。失败日志不打开源码也能看懂预期合同。
聚合相等性与领域事件
仓库的 EntityEqualityTests 不是只测 Equals 的 happy path,还区分相同类型/ID、不同 ID、不同实体类型、null,并验证领域事件收集和清空。
[Fact]public void SameIdAndType_ShouldBeEqual_ButDifferentTypeShouldNot(){ // Arrange:固定同一 ID,隔离“标识相同”和“运行时类型相同”两个条件。 var id = Guid.CreateVersion7(); var first = new SampleEntity(id); var sameType = new SampleEntity(id); var otherType = new OtherEntity(id);
// Assert:相等运算符、Equals 与 hash code 必须保持同一合同。 first.Equals(sameType).ShouldBeTrue(); (first == sameType).ShouldBeTrue(); first.GetHashCode().ShouldBe(sameType.GetHashCode());
// 同 ID 不足以跨实体类型判等,否则集合和仓储 identity map 会混淆。 first.Equals(otherType).ShouldBeFalse();}当聚合行为产生事件时,应断言事件类型和关键业务字段,而不是只断言 Count == 1。如果合同要求失败不产生事件,也要覆盖拒绝路径。
[Fact]public void RaiseAndClear_ShouldMaintainDomainEventCollection(){ // 新聚合开始时不应携带任何待分发事件。 var entity = new SampleEntity(Guid.CreateVersion7());
entity.DomainEvents.ShouldBeEmpty(); entity.Raise(new SampleEvent()); entity.DomainEvents.ShouldHaveSingleItem();
// Clear 用于持久化/分发后的生命周期;重复清空仍应安全。 entity.ClearDomainEvents(); entity.ClearDomainEvents(); entity.DomainEvents.ShouldBeEmpty();}应用 Pipeline 的短路合同
ValidationPipelineBehaviorTests 使用真实 pipeline 类和小型 IRequestRule<T>,只替代当前租户与策略端口。关键断言不是“规则被调用一次”,而是失败时 handler 不运行、错误码稳定,成功时 handler 才运行。
[Fact]public async Task FailingRule_ShouldSkipHandler_AndReturnStableError(){ // Arrange:策略返回一个确定失败的规则,不启动 Host 或数据库。 var pipeline = CreatePipeline(new FailingRule()); var handlerCalled = false; MessageHandlerDelegate<CreateGreetingCommand, Result<Greeting>> next = (_, _) => { handlerCalled = true; return ValueTask.FromResult(Result<Greeting>.Success(CreateGreeting())); };
// Act:从公开 Handle 合同驱动完整校验控制流。 var result = await pipeline.Handle( new CreateGreetingCommand("name"), next, CancellationToken.None);
// Assert:既要验证类型化失败,也要证明副作用边界未被越过。 result.Error.Code.ShouldBe("Greeting.Invalid"); handlerCalled.ShouldBeFalse();}租户特定规则要至少覆盖:目标租户获得附加规则、其他租户只用基础规则、无租户时的 fail-closed/默认行为(按具体合同)。不要用一个全局静态 tenant 在测试间共享。
替身使用原则
替代真正的外部端口,而不是被测对象的内部方法。常见合理替身包括 ICurrentTenant、固定时钟、ID 生成器、授权策略、通知/仓储 port。仓储 fake 必须只用于 Handler 控制流;若要证明 provider 的并发、过滤或事务,转到集成测试。
// 当前租户是安全相关输入,使用精确值而不是任意参数匹配。var currentTenant = Substitute.For<ICurrentTenant>();currentTenant.Tenant.Returns(new CurrentTenant("TENANT_A"));
// 策略只返回当前场景需要的一条通过规则。var strategy = Substitute.For<IValidationStrategy>();strategy.GetRules<CreateGreetingCommand>(currentTenant) .Returns(new IRequestRule<CreateGreetingCommand>[] { new PassingRule() });避免 ReturnsForAll、任意参数匹配和几十个无意义 setup;它们会让错误调用也通过。对有安全意义的 tenant、resource、error code 使用精确值。
时间、随机与并发
业务规则依赖当前时间时注入固定时钟,断言边界前、边界点和边界后。依赖 ID 时测试只关心唯一性就用可预测 generator;关心 UUID v7 排序则显式构造场景。不要用 Task.Delay 证明超时,使用可控信号和取消令牌。
并发正确性通常依赖真实数据库版本列、唯一索引或锁,属于集成测试。单元层可以验证冲突 Result 的映射逻辑,但不能声称已经证明两个事务竞争。
参数化边界
[Theory][InlineData(0, false)][InlineData(1, true)][InlineData(100, true)][InlineData(101, false)]public void BatchSize_ShouldRespectInclusiveRange(int value, bool expected){ // Theory 把边界表变成可读合同,并为每个输入生成独立失败结果。 // 断言直接比较布尔合同,失败日志会同时显示输入与期望。 BatchPolicy.IsAllowed(value).ShouldBe(expected);}不要为了减少行数把互不相关的业务场景塞进一个 Theory。每行数据应共享同一规则与失败解释。
运行与诊断
# 两个层级分别运行,便于定位归属。dotnet test tests/BitzOrcas.Unit.Tests --configuration Releasedotnet test tests/BitzOrcas.Application.Tests --configuration Release
# 修改 Validation pipeline 时先缩小到一个类。dotnet test tests/BitzOrcas.Application.Tests \ --configuration Release \ --filter 'FullyQualifiedName~ValidationPipelineBehaviorTests'出现偶发失败时先寻找真实时间、随机值、静态状态、共享集合和执行顺序依赖。不要通过重试或放宽断言把非确定性永久化。
评审清单
- 测试名表达业务场景与可观察结果;
- Arrange 只创建当前合同需要的状态;
- 成功、拒绝、边界和取消路径均按风险覆盖;
- 错误断言包含稳定 code/type,不依赖易变消息全文;
- 替身位于外部 port,未暴露私有实现;
- 测试不依赖网络、真实时钟、固定端口和执行顺序;
- provider/事务/消息语义没有被 fake 夸大;
- 修复前测试能红,修复后才绿。