Sparkdo.Runtime.Testing
0.0.1-preview.3
dotnet add package Sparkdo.Runtime.Testing --version 0.0.1-preview.3
NuGet\Install-Package Sparkdo.Runtime.Testing -Version 0.0.1-preview.3
<PackageReference Include="Sparkdo.Runtime.Testing" Version="0.0.1-preview.3" />
<PackageVersion Include="Sparkdo.Runtime.Testing" Version="0.0.1-preview.3" />
<PackageReference Include="Sparkdo.Runtime.Testing" />
paket add Sparkdo.Runtime.Testing --version 0.0.1-preview.3
#r "nuget: Sparkdo.Runtime.Testing, 0.0.1-preview.3"
#:package Sparkdo.Runtime.Testing@0.0.1-preview.3
#addin nuget:?package=Sparkdo.Runtime.Testing&version=0.0.1-preview.3&prerelease
#tool nuget:?package=Sparkdo.Runtime.Testing&version=0.0.1-preview.3&prerelease
Sparkdo.Runtime.Testing
<code>Sparkdo.Runtime.Testing</code> 是 Sparkdo 统一运行时的框架无关测试与合规验证工具包。它把真实的 <code>RuntimeEngine</code> 放入可控的时间、调度、观察和生命周期环境中,帮助测试代码稳定验证以下问题:
- 重协调、停止、排空、取消和隔离等生命周期路径;
- 并发准备、待处理工作项与 Observation 队列是否保持有界;
- Scope、导出租约、版本租约和临时 Hold 的所有权是否按预期收敛;
- Native AOT、trimming 和包消费者所需的显式注册表路径;
- 已发布 Snapshot 的 Scope 与导出获取热路径是否满足固定查找不变量。
该包不是应用运行时扩展包,也不负责 Hosting、依赖注入、配置装载或生产诊断。生产应用应组合 <code>Sparkdo.Runtime.Contracts</code>、<code>Sparkdo.Runtime</code> 及所需的 Hosting 或 Configuration 包;本包仅应出现在测试、验证或合规门禁项目中。
适用场景
当测试需要真实运行时行为,但又不能依赖墙钟、线程调度偶然性或程序集扫描时,使用 <code>Sparkdo.Runtime.Testing</code>。典型场景包括:
- 为自定义 <code>IRuntimeRegistrationTable</code> 编写集成测试;
- 固定重协调到某个检查点,验证并发窗口或停止竞争;
- 推进虚拟时间,触发排空、取消确认或其他基于 <code>TimeProvider</code> 的定时逻辑;
- 验证 Observation 接收、拒绝、异常和阻塞时的运行时行为;
- 审计 Snapshot、Scope pin、消费者租约、版本租约和 Hold;
- 在不使用反射发现的前提下执行 AOT 或 trimming 冒烟测试;
- 用 Core 采集的计数器验证有界并发与热路径不变量。
它不替代业务能力实现的单元测试。业务测试仍应针对自己的 <code>ICapabilityActivator<TPlan></code>、<code>ICapabilityPreparationAdapter<TPlan></code>、<code>ICapabilityReconciler<TPlan></code> 和导出实现编写明确的断言。
安装
测试项目的目标框架应与运行时兼容;当前仓库构建目标为 <code>net10.0</code>。从 NuGet 安装时,选择与其他 Sparkdo Runtime 包一致的版本:
dotnet add package Sparkdo.Runtime.Testing --version <版本>
或在测试项目中加入引用:
<ItemGroup>
<PackageReference Include="Sparkdo.Runtime.Testing" Version="<版本>" />
</ItemGroup>
若测试项目自己实现注册表和能力适配器,也应引用其中使用到的 Sparkdo Runtime 合同包。不要把此包加入应用启动项目、Web 服务项目或生产 worker 项目。
快速开始
<code>StaticRuntimeRegistrationTable</code> 是最小、可直接使用的显式注册表。它提供一个固定静态能力、一个字符串导出,并且只接受 <code>CatalogInputs.Empty</code>。它适合验证 Runtime 创建、发布、Scope、导出租约和停止这一条完整路径,也适合 AOT、trimming 与包消费者冒烟测试。
下面是可直接放入 xUnit 测试项目的最小示例:
using System.Threading;
using Sparkdo.Runtime;
using Sparkdo.Runtime.Testing;
using Xunit;
public sealed class RuntimeSmokeTests
{
[Fact(DisplayName = "静态注册表可发布、获取导出并停止")]
public async Task StaticRegistrationCanPublishAcquireAndStopAsync()
{
var registrations = StaticRuntimeRegistrationTable.Create();
var catalog = registrations.CreateCatalog(CatalogInputs.Empty);
Assert.True(catalog.Succeeded);
Assert.NotNull(catalog.Catalog);
await using var kit = RuntimeTestKit.Create(registrations);
var published = await kit.Runtime.SubmitReconciliationAsync(
new ReconciliationRequest(catalog.Catalog!),
CancellationToken.None);
Assert.Equal(ReconciliationOutcome.Published, published.Outcome);
var opened = kit.Runtime.OpenScope();
Assert.True(opened.Succeeded);
Assert.NotNull(opened.Scope);
await using (var scope = opened.Scope!)
{
await using var lease = await scope.AcquireAsync(
registrations.CreateExport(),
CancellationToken.None);
Assert.Equal("aot-static", lease.Value);
}
var ownership = kit.CaptureOwnership();
Assert.Equal(0, ownership.RootPins);
Assert.Equal(0, ownership.ConsumerLeases);
var stopped = await kit.Runtime.StopAsync(
cancellationToken: CancellationToken.None);
Assert.Equal(RuntimeStopStatus.Stopped, stopped.Status);
}
}
<code>RuntimeTestKit</code> 持有 Runtime 并实现 <code>IAsyncDisposable</code>。始终使用 <code>await using</code>,以便测试结束时调用 Runtime 的异步释放路径。
RuntimeTestKit 与选项
<code>RuntimeTestKit.Create(IRuntimeRegistrationTable, RuntimeTestOptions)</code> 构造真实的 Core Runtime,并为该实例注入:
| 成员 | 用途 |
|---|---|
| <code>Runtime</code> | 真实的 <code>IRuntime</code>,可调用 <code>SubmitReconciliationAsync</code>、<code>OpenScope</code>、<code>StopAsync</code> 等公开运行时 API。 |
| <code>Time</code> | <code>ControlledTimeProvider</code>,从 Unix Epoch 起步,由测试显式推进。 |
| <code>Scheduler</code> | <code>ControlledScheduler</code>,用于观察并解除指定检查点的暂停。 |
| <code>Observations</code> | <code>ControlledObservationSink</code>,用于检查 Observation 或模拟接收器行为。 |
| <code>CaptureOwnership()</code> | 返回当前 Snapshot、租约、Hold 和重协调状态的测试视图。 |
<code>RuntimeTestOptions</code> 可控制以下测试条件:
| 选项 | 含义 |
|---|---|
| <code>MaximumPreparationConcurrency</code> | 传给 Runtime 的准备并发上限。默认值为 <code>1</code>。 |
| <code>CancellationConfirmationTimeout</code> | 传给 Runtime 的取消确认窗口;未设置时使用 Runtime 默认值。 |
| <code>PausedCheckpoints</code> | 指定哪些 <code>RuntimeCheckpoint</code> 会在受控执行探针处暂停。 |
| <code>FailSuccessorEntryHoldRelease</code> | 注入后继 Entry Hold 释放失败,用于负向路径测试。 |
| <code>FailSuccessorEntryHoldTransferAfter</code> | 在指定次数的后继 Entry Hold 转移时注入失败。 |
| <code>FailSuccessorEntryHoldRestoreAfterTransfer</code> | 注入已转移后恢复 Hold 的失败。 |
后三项是故障注入开关,只应用于测试预期的失败、隔离或恢复路径。它们不是生产容错配置。
<code>Create</code> 会先校验注册表和测试选项;若 Runtime 无法创建,会抛出 <code>InvalidOperationException</code>,而不是返回一个空的测试对象。对于生产式注册表,应先独立测试 <code>RuntimeFactory.Create</code> 的结构化创建结果;对于受控集成测试,再使用 <code>RuntimeTestKit</code>。
可控时间
<code>ControlledTimeProvider</code> 是 <code>TimeProvider</code> 的实现。它不读取墙钟,初始时间为 Unix Epoch,并且只能通过 <code>Advance(TimeSpan)</code> 向前推进。
var stopping = kit.Runtime.StopAsync(
new RuntimeStopOptions(TimeSpan.FromSeconds(1)),
CancellationToken.None).AsTask();
kit.Time.Advance(TimeSpan.FromSeconds(1));
var result = await stopping;
推进时间会在更新虚拟时钟后同步执行到期定时器的回调。时间不能倒退;负时长会抛出 <code>ArgumentOutOfRangeException</code>。因此,测试应只推进刚好覆盖目标分支的时长,并将超时、排空和取消确认的断言放在每一步推进之后。
不要把 <code>ControlledTimeProvider</code> 当作性能计时器或生产时间源。它的职责是消除时间相关测试的不确定性。
可控调度、检查点与轨迹
<code>RuntimeCheckpoint</code> 描述 Core 可观察的协调、发布、排空、停止和 Observation 阶段。把某个检查点加入 <code>RuntimeTestOptions.PausedCheckpoints</code> 后,运行时到达相应受控探针时会停住,直到测试调用 <code>Scheduler.Release(...)</code> 或 <code>Scheduler.RunNextAsync()</code>。
典型并发测试的顺序是:先启动异步操作,再等待它实际到达检查点,断言中间状态,最后解除检查点并等待操作完成。
using System.Collections.Immutable;
await using var kit = RuntimeTestKit.Create(
registrations,
new RuntimeTestOptions
{
PausedCheckpoints = ImmutableArray.Create(
RuntimeCheckpoint.SnapshotPublishing)
});
var reconciliation = kit.Runtime.SubmitReconciliationAsync(
new ReconciliationRequest(catalog),
CancellationToken.None).AsTask();
await kit.Scheduler.WaitForCheckpointAsync(
RuntimeCheckpoint.SnapshotPublishing,
CancellationToken.None);
Assert.False(reconciliation.IsCompleted);
kit.Scheduler.Release(RuntimeCheckpoint.SnapshotPublishing);
var result = await reconciliation;
几个容易混淆的点:
- <code>WaitForCheckpointAsync</code> 只等待“已到达”的事实,不会自动解除暂停。
- <code>Release(checkpoint)</code> 解除该检查点所有当前等待者;若尚无等待者,会保留一个释放令牌供后续等待者消费。
- <code>RunNextAsync()</code> 按全局等待顺序释放一个尚未完成的等待者,适合探索交错顺序。
- <code>HasPendingWork</code> 只反映调度器中等待释放的工作。
- <code>CaptureTrace()</code> 返回 <code>RuntimeTrace</code>,其中包含检查点和门事件;<code>ReplayAsync</code> 仅重放轨迹中 <code>GateOpened</code> 的检查点事件,不能替代完整的并发重放或线程调度模拟。
仅为 Runtime 实际经过的检查点配置暂停。等待一个不会被当前路径触达的检查点会导致测试自身无限等待,应始终传入测试框架提供的取消令牌或额外的测试超时。
受控生命周期与导出夹具
包内包含以下受控生命周期类型:
| 类型 | 表示的受控行为 |
|---|---|
| <code>ControlledGate</code> | 代表一个 <code>ControlledStage</code> 的一次性门;<code>Open()</code> 后不可再次关闭。 |
| <code>ControlledCandidate</code> | Candidate 的 Prepare、Discard、Retire 与 Dispose 生命周期。 |
| <code>ControlledVersion</code> | Version 的 <code>ReleaseAsync</code> 和对应导出提供方。 |
| <code>ControlledReload</code> | Reload 的 Prepare、Commit、Rollback 与 Release 生命周期。 |
| <code>ControlledPreparationAdapter<TPlan></code> | 将 Prepare 调用转交给 <code>ControlledCandidate</code>。 |
| <code>ControlledReconciler<TPlan></code> | 可设置 <code>ReconciliationDecision</code>,并控制 Reload Prepare 的完成。 |
| <code>ControlledExportProvider</code> | 控制导出获取门和按导出标识完成的获取结果。 |
| <code>ControlledExportAcquisition<T></code> | 控制单个导出租约的 Release 结果和 Release 门。 |
这些夹具的完成语义是“双条件”:先调用 <code>Complete...</code> 写入结果、再打开对应门,或先开门、再完成结果,都会得到相同的最终行为。测试可以据此区分“回调已进入”“结果已准备”“允许回调返回”三个状态。
<code>ControlledStage</code> 包括 Prepare、Discard、Retire、Dispose、VersionRelease、ExportAcquire、ExportRelease 以及 Reload 的 Assess、Prepare、Commit、Rollback 阶段。测试应只打开和完成当前断言所需的阶段,避免无关门被提前放行而掩盖竞态。
外部测试项目的构造边界
<code>ControlledCandidate</code>、<code>ControlledVersion</code>、<code>ControlledReload</code>、<code>ControlledPreparationAdapter<TPlan></code>、<code>ControlledReconciler<TPlan></code>、<code>ControlledExportProvider</code> 和 <code>ControlledExportAcquisition<T></code> 的构造函数是 <code>internal</code>。它们虽然作为公开类型出现在 API 表面,但包外测试项目不能直接使用 <code>new</code> 创建实例。
因此,外部项目的直接入口是 <code>RuntimeTestKit</code> 与 <code>StaticRuntimeRegistrationTable</code>。如需为自己的能力注册表制造可控生命周期,请在测试项目中实现对应 Runtime 合同,或使用项目内已有的测试注册夹具;不要假设这些类型提供了公开构造工厂。
受控 Observation
<code>RuntimeTestKit.Observations</code> 返回 <code>ControlledObservationSink</code>。它实现 <code>IObservationSink</code>,并提供以下能力:
| 成员或模式 | 行为 |
|---|---|
| <code>Items</code> | 返回已接受 Observation 的不可变快照。 |
| <code>WaitForCodeAsync(code, token)</code> | 返回首个匹配代码的 Observation;未出现时异步等待。 |
| <code>Accept</code> | 接受并记录 Observation。 |
| <code>Reject</code> | 返回 <code>false</code>,不记录 Observation。 |
| <code>Throw</code> | 写入时抛出 <code>InvalidOperationException</code>。 |
| <code>Block</code> | 阻塞写入,直到调用 <code>ReleaseBlockedWrites()</code>。 |
使用 <code>Block</code> 时必须在 <code>finally</code> 中调用 <code>ReleaseBlockedWrites()</code>,避免测试留下等待中的 Observation 写入任务:
kit.Observations.Mode = ControlledObservationMode.Block;
try
{
// 触发需要验证的运行时操作,并断言其与 Observation 投递的关系。
}
finally
{
kit.Observations.ReleaseBlockedWrites();
}
<code>WaitForCodeAsync</code> 支持取消。对等待 Observation 的测试应传入测试框架的取消令牌,防止断言目标未产生时无限挂起。
所有权快照与状态审计
<code>RuntimeTestKit.CaptureOwnership()</code> 返回 <code>RuntimeOwnershipSnapshot</code>。它是测试时刻的观测快照,不是可修改的运行时控制面。
| 字段 | 检查重点 |
|---|---|
| <code>Revision</code> | 当前活动 Snapshot 的 Catalog 修订;没有活动 Snapshot 时为 <code>null</code>。 |
| <code>Capabilities</code> | 每个能力的实例、版本、Entry 引用数、入站版本租约和出站版本租约。 |
| <code>RootPins</code> | 活动 Snapshot 的 Scope pin 数量。 |
| <code>ConsumerLeases</code> | 活动 Snapshot 中消费者保留的租约数量。 |
| <code>Holds</code> | PreparedVersion、SuccessorEntry 或 Quarantine Hold 的标识、能力、实例、版本和状态。 |
| <code>Admission</code> | 当前 Entry 准入状态。 |
| <code>Running</code> 与 <code>Pending</code> | 运行中或待处理重协调的身份与修订。 |
| <code>RunningBarrierReached</code>、<code>PendingBarrierReached</code> | 是否越过相应的协调屏障。 |
| <code>RunningCancellationRequested</code>、<code>PendingCancellationRequested</code> | 是否已请求取消。 |
<code>CapabilityOwnership.EntryReferences</code> 是 Core 对该 Entry 的测试可见引用数,包含后继 Entry Hold 的影响;不要把它误解为业务调用次数。<code>RuntimeHoldStatus</code> 可取 <code>Held</code>、<code>Transferred</code>、<code>Releasing</code>、<code>Released</code> 或 <code>Quarantined</code>,适合断言资源在成功、失败和隔离路径上的归属。
推荐在每个竞争窗口的前后各采集一次快照,并断言 pin、租约和 Hold 是否收敛。例如,释放 Scope 后应检查 <code>RootPins</code> 是否回到零;退出导出租约后应检查 <code>ConsumerLeases</code> 是否回到零。
有界性与热路径合规夹具
RuntimeBoundedReconciliationFixture
<code>RuntimeBoundedReconciliationFixture</code> 在真实重协调之后读取 Core 计数器,返回 <code>RuntimeBoundedReconciliationResult</code>。结果包含:
- 声明的准备并发上限;
- 实际最大准备 worker 数与最大活动 Prepare 数;
- 最大保留重协调工作项数;
- Observation 队列容量、最大排队数、当前排队数和丢弃数;
- 重协调结果。
<code>Satisfied</code> 是固定合规谓词:要求结果为 <code>Published</code>、准备相关最大值不超过声明上限、保留工作项不超过两个,并且 Observation 队列不超过其容量。它用于门禁断言,不是吞吐量或延迟基准。
var fixture = new RuntimeBoundedReconciliationFixture(kit);
var evidence = await fixture.ExerciseAsync(
new ReconciliationRequest(catalog),
declaredPreparationConcurrency: 2,
CancellationToken.None);
Assert.True(evidence.Satisfied);
如果测试自行编排并发与检查点,可在操作完成后调用 <code>Capture(outcome, declaredPreparationConcurrency)</code> 获取同一类证据。
RuntimeHotPathInvariantFixture
<code>RuntimeHotPathInvariantFixture</code> 只用于已发布 Snapshot。它会打开 Scope、获取一次指定 <code>Export<T></code>,并读取 Core 热路径计数器的增量。<code>RuntimeHotPathInvariantResult.Satisfied</code> 要求:
- 仅进行一次 Snapshot 定位与两次导出 Entry 定位;
- 不发生 Catalog 验证、Plan 构建、AST 解释或程序集扫描;
- 已发布的 Snapshot、Catalog 与 Plan 引用保持不变。
var hotPath = new RuntimeHotPathInvariantFixture(kit);
var evidence = await hotPath.ExerciseAsync(
registrations.CreateExport(),
CancellationToken.None);
Assert.True(evidence.Satisfied);
调用前必须先完成一次发布;没有活动 Snapshot 或无法打开 Scope 时,夹具会抛出 <code>InvalidOperationException</code>。这项检查验证固定的运行时路径不变量,不代替 BenchmarkDotNet、压力测试或生产容量评估。
静态注册表的边界
<code>StaticRuntimeRegistrationTable</code> 是固定测试夹具,不是业务注册表模板。它的行为是确定的:
- 注册一个名为 <code>aot.static</code> 的静态能力;
- 提供名为 <code>aot.static.value</code> 的 <code>string</code> 导出,获取值为 <code>"aot-static"</code>;
- 仅接受空的 <code>CatalogInputs</code>;
- 声明 AOT 与 trimming 支持,用于验证显式注册和无程序集扫描的路径。
不要在生产中复用该表,也不要根据它推导多能力拓扑、动态 Catalog 输入、业务依赖、配置绑定或失败恢复的行为。真实产品应提供自己的 <code>IRuntimeRegistrationTable</code> 和 Catalog 构建逻辑。
另外,<code>InvalidRegistrationTableFactory.Create(...)</code> 当前会抛出 <code>NotSupportedException</code>,不提供无效注册表构造行为;不要把它作为负向验证的可用工厂。负向验证应由测试项目自行构造目标输入,或使用已实现的项目内夹具。
使用边界
请遵守以下边界:
- 不将 <code>RuntimeTestKit</code>、受控时间、受控调度器或受控 Observation 接收器注入生产服务。
- 不把 <code>StaticRuntimeRegistrationTable</code> 作为应用的默认能力目录。
- 不将 <code>RuntimeOwnershipSnapshot</code> 或 <code>RuntimeTrace</code> 当作生产监控协议;它们是测试观察模型。
- 不用 <code>RuntimeBoundedReconciliationResult.Satisfied</code> 代替性能压测、可用性演练或安全审计。
- 不依赖受控夹具的内部构造函数、内部身份生成或非公开 Core 类型。
- 不在没有取消令牌、<code>finally</code> 清理或明确释放检查点的情况下编写并发测试。
本地验证
在仓库根目录执行以下命令可验证该包可还原、可构建,并运行与静态注册表和热路径夹具直接相关的规格测试:
dotnet restore src/runtime/src/Sparkdo.Runtime.Testing/Sparkdo.Runtime.Testing.csproj
dotnet build src/runtime/src/Sparkdo.Runtime.Testing/Sparkdo.Runtime.Testing.csproj --no-restore
dotnet test src/runtime/test/Sparkdo.Runtime.Specification.Tests/Sparkdo.Runtime.Specification.Tests.csproj --no-restore --filter "FullyQualifiedName~StaticRuntimeRegistrationTableTests|FullyQualifiedName~RuntimeHotPathInvariantFixtureTests"
dotnet pack src/runtime/src/Sparkdo.Runtime.Testing/Sparkdo.Runtime.Testing.csproj --configuration Release --no-restore
这些命令仅描述验证入口,不代表任何预先声明的测试结果。提交前还应运行当前变更范围要求的完整测试和包验证流程。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Sparkdo.Runtime (>= 0.0.1-preview.3)
- Sparkdo.Runtime.Contracts (>= 0.0.1-preview.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.0.1-preview.3 | 45 | 8/26/2026 |
| 0.0.1-preview.2 | 50 | 8/25/2026 |
| 0.0.1-preview.1 | 54 | 8/25/2026 |