Sparkdo.Messaging.Testing
传输契约测试基座:12 条可复用用例 + 一个运行器。任何传输实现跑过它才算出证——
"我实现了 ITransportPort"不是证据,"这 12 条在我身上真跑过"才是。
| 项 |
值 |
| 包 |
Sparkdo.Messaging.Testing |
| 交付框架 |
net11.0 |
| 外部 NuGet 依赖 |
无 |
| 谁引用 |
只该被测试工程引用,不进生产依赖图 |
| 公开面 |
ITransportFactory、TransportConformance、TransportConformanceRunner、ConformanceCase/Context/Result/Report/Outcome |
三步出证
internal sealed class MyTransportFactory : ITransportFactory
{
public string Name => "my-transport";
public TransportCapability Capabilities => TransportCapability.Durable; // 与传输自报一致
public ValueTask<ITransportPort> CreateAsync(CancellationToken ct)
=> ValueTask.FromResult<ITransportPort>(new MyTransport(new MyOptions { ... }));
}
var report = await TransportConformanceRunner.RunAsync(
new MyTransportFactory(), TransportConformance.Cases);
Assert.True(report.Passed, report.Describe());
传输合法地缺某项能力时(例如进程内传输没有持久化),上面那条断言本来就是 false。
这时出证要写死该跳过哪几条,而不是断言"没有失败":
Assert.Equal(["durable-survives-restart"], report.SkippedCaseIds);
Assert.True(report.FailedCaseIds.Count == 0, report.Describe());
写死而不是从能力声明派生:派生出来的期望值会让"传输悄悄少声明一项能力"自证清白。
ITransportFactory 的三个约定
| 成员 |
约定 |
Name |
进报告的标识 |
Capabilities |
要与 ITransportPort.Capabilities 一致。基座按它决定跑或跳;两边不一致就是少验或多吹 |
CreateAsync(ct) |
创建一个尚未启动的实例。运行器自己调 StartAsync,每条用例一个新实例 |
运行器的行为(写传输前要知道)
| 事 |
口径 |
| 每条用例新建一个传输实例 |
共享实例会让一条用例的残留污染下一条,而这种失败最难归因 |
只调 StartAsync,不调 InitializeAsync |
也就是说契约轮里没有 TransportContext(宿主的服务提供者)。别把"必须的依赖"全押在 InitializeAsync 上——配置校验要在 StartAsync 也能跑一遍(RabbitMQTransport / PostgresTransport 就是两处各调一次 Validate()) |
| 用例地址 |
运行器把 ConformanceContext.Address 填成 {transport.Scheme}://conformance/{caseId}。今天没有用例用它——每条用例自己按标准文法造发布与订阅地址。留这一格是给"需要一条现成唯一地址"的新用例,不是要传输认得 conformance 这一段 |
| 没声明能力就跳过,且不执行 |
能力是声明不是证据:声明了才验,验不过算失败。跳过原因串是 skipped-conformance-case:{Id}:missing-{Required} |
| 用例异常收敛成失败,不中断整轮 |
一条炸掉不该让后面没有结论——出证报告要的是全集 |
| 默认超时 30 秒 |
RunAsync(factory, cases, timeout?, ct) 的 timeout 覆盖它。超时后先取消再给 1 秒收尾窗口(unwind grace),仍未结束就判失败 |
每条用例结束 DisposeAsync 传输实例 |
传输的异步释放要真的能散——IAsyncDisposable 在这条路上被问责 |
12 条用例
| 用例 Id |
问的是什么 |
需要的能力 |
settlement-exactly-once |
同一租约第二次结算必须失败 |
— |
backpressure-blocks-when-full |
无人消费时写入最终会阻塞(只看有没有界;已故障/已取消不算通过) |
Backpressure |
stop-rejects-traffic |
停止后发送返回未受理(停止是终态) |
— |
fan-out-reaches-every-subscriber |
三个不同名订阅者都收到同一条投递 |
— |
publish-without-subscriber-accepted |
无人订阅时发布仍被受理(不等于晚起的订阅收得到) |
— |
payload-round-trips-byte-identical |
载荷逐字节往返,不得"顺手规范化" |
— |
headers-survive-the-round-trip |
消息头原样带回(含 null 值条目、键的大小写) |
— |
dead-letter-remains-readable |
结算成死信后仍可从 {scheme}://dead/{身份}/{订阅者} 读回同一条(同 MessageId、同字节) |
— |
delivery-count-advances-on-redelivery |
重投后 DeliveryCount 递增 |
— |
released-port-leaves-no-trace |
DisposeAsync 后读循环在宽限内散尽、同名重新打开取得到投递(放两次验幂等) |
— |
durable-survives-restart |
旧实例整个释放后新实例仍能取到未结算投递 |
Durable |
fault-injection-degrades-explicitly |
带已取消令牌的写入要么响亮拒绝、要么如实送达,之后一笔正常写入必须照常送达 |
— |
受能力门管的只有两条:durable-survives-restart(Durable)与
backpressure-blocks-when-full(Backpressure),都由这个运行器问责;NativeSchedule 在基座里
没有用例,这条缺席被 CapabilityCaseTests 钉着。所以基座抓的是"声明了但跑不过",
抓不到"声明了却没实现"。
报告面
report.Passed // 全部用例真跑且通过才为 true:任一条跳过即 false
report.Results // 每条一个 ConformanceResult(CaseId, Outcome, Failure?, SkipReason?)
report.PassedCaseIds / FailedCaseIds / SkippedCaseIds
report.Describe() // 给人读的全集,断言失败信息就带它
| 失效方式 |
防线 |
| 把"环境缺能力"读成"验过了" |
Passed 在有任何跳过时为 false;要合法跳过就写死 SkippedCaseIds |
| 只有 xunit 层的绿 |
xunit 的 0 跳过 ≠ 契约轮的 0 跳过:按声明跳过的那条要在传输的用例里显式断言出来 |
| 用例写错了(把"至少一次"当"恰好一次") |
才改用例,且必须在提交消息里写清理由。实现不合契约就修实现 |
加一条用例
用例类型是包内的 internal static class(Cases/ 下),注册在 TransportConformance.Cases 里,
一条一个 ConformanceCase(Id, Title, Required, Run)。加它意味着:
| 步 |
内容 |
| 1 |
想清这条问责的是端口契约的哪一句——不是某个 broker 的性质 |
| 2 |
定 Id(稳定,写进各传输的跳过断言里)与 Required |
| 3 |
三个传输都要跑得过或按声明合法跳过;跑不过先改传输 |
| 4 |
加一条能力档的自检:CapabilityCaseTests 钉着"哪些用例受哪个能力门管" |
出证记录
| 传输 |
读数 |
| InMemory |
11 过 + 1 跳(durable-survives-restart) |
| RabbitMQ |
11 过 + 1 跳(backpressure-blocks-when-full),2026-10-19 全工程 82/82 |
| Postgres |
11 过 + 1 跳(backpressure-blocks-when-full:库里没有"无人消费就堵住写入"这回事),2026-10-19 全工程 20/20 |
dotnet test src/messaging/test/Sparkdo.Messaging.Testing.Tests # 基座自身的用例
dotnet test src/messaging/test/Sparkdo.Messaging.Transport.InMemory.Tests
broker 两档要真服务(SPARKDO_MESSAGING_RABBITMQ / SPARKDO_MESSAGING_POSTGRES),没服务就跳过;
跳过不是通过,跑法与逐条读数见那两个传输工程的 README。