资讯详情

RxJS Next 测试设计深度解析:基于 @rxjs/test 的虚拟时间与 Marble 测试体系

📅 2026/9/19 9:35:04 | 华诺云谱 👁 阅读
RxJS Next 测试设计深度解析:基于 @rxjs/test 的虚拟时间与 Marble 测试体系
RxJS Next 测试设计深度解析基于 rxjs/test 的虚拟时间与 Marble 测试体系【免费下载链接】rxjsA reactive programming library for JavaScript项目地址: https://gitcode.com/gh_mirrors/rx/rxjs导读本文以 RxJS NextRxJS 9 演进线官方测试设计文档为核心系统讲解rxjs/test这一框架无关framework-neutral的虚拟时间测试 API从rxTest的执行模型、cold/hot/observable 三种源模型的语义差异到完整的 Marble 语法、虚拟宿主契约Virtual Host Contract、动画/空闲回调计划再到针对 RxJS 7 全部 2,338 条 Marble 测试证据的移植与验证流水线。读完本文你将掌握用rxTest编写确定性 Marble 测试、精确控制虚拟时钟、复用 RxJS 7 测试证据以及理解 RxJS Next 平台 Observable 生命周期与旧版 producer-per-subscription 语义之间差异的完整方法。1. 设计取向与基本用法1.1 函数优先的 API 设计RxJS Next 的测试 API 从rxjs/test包导出核心入口是rxTest。它刻意采用函数优先function-first设计rxTest自行持有调度器实例临时接管宿主调度 API排空确定性工作评估断言并在所有退出路径上恢复 realm运行环境最终返回Promisevoid。与之相对RxJS 7 的TestScheduler手动模式、可变的frameTimeFactor、maxFrames以及静态解析方法在 Next 中不再作为公开 API 暴露。这意味着测试不再需要手工管理调度器生命周期全部由rxTest封装完成。import { rxTest } from rxjs/test; await rxTest(({ cold, expectObservable }) { expectObservable(cold(--a--|)).toBe(--a--|); });从源码看rxTest的执行链是取 realm 锁 → 校验配置 → 构造VirtualTimeController与上下文实现 →controller.install()打补丁 → 运行回调直至 settle → 结算断言 → 校验无未决工作 →controller.restore()逆序恢复。恢复阶段即使失败也会与主错误合并为AggregateError抛出见 packages/test/src/rx-test.ts。1.2 安装与前置条件rxjs/test是一个独立的开发期包对匹配的rxjs预发布版本声明精确 peer 依赖。它要求运行环境先存在平台 ObservableglobalThis.Observable因为rxTest内部通过getObservableConstructor()读取活动构造函数若 realm 未提供则会抛出明确错误见 packages/test/src/platform-observable.ts 与 packages/test/README.md。npm install --save-dev rxjs/testnext rxjsnext使用前需先import rxjs或引入rxjs/observable-polyfill当运行环境未原生提供 Observable 时。包要求 Node 22.13仅发布 ESM。2. 三种源模型cold、hot 与 observable测试需要同时表达 RxJS 7 兼容行为与平台 Observable 生命周期因此rxTest上下文暴露了三种语义各异的源模型而非一个含义模糊的cold助手。这里的 hot 与 cold 仅指生产者创建相对订阅的时序cold 表示订阅创建生产者hot 表示生产者在订阅之前就已存在共享sharing、多播multicasting、重放replay与引用计数ref counting是独立于冷/热的生命周期属性由observable()模型承载。2.1cold()RxJS 7 生产者-每次-订阅模型cold()在每次订阅期间创建生产者与 RxJS 7 的 producer-per-subscription 模型一致每个观察者启动一份独立的图表副本消息相对该观察者的订阅时刻完成、出错或AbortSignal取消都会关闭其日志并发观察者之间不共享生产。实现上cold()的 fixture 是ColdObservable的具名子类ColdTestObservable见 packages/test/src/test-sources.ts而 RxJS Symbol 操作符则通过共享的[create]协议派生普通ColdObservable实例。这一点是显式的测试/兼容行为不会改变主平台 Observable。2.2hot()绝对时间线主体式生产者hot()在助手被调用时任何观察者订阅之前即创建主体式subject-like的绝对时间线生产者图表相对测试时间只调度一次观察行为不会启动、停止或重启生产者迟到观察者只能看到未来的通知^确立时间零点并允许负时间历史next、error、complete仍可手工控制通过TestHotObservable暴露的方法。hot()的 fixture 继承活动中的globalThis.Observable构造函数因此无论 realm 是保留原生实现还是回退实现都能工作其[create]协议返回活动构造函数的普通实例操作符结果不会继承 fixture 的绝对时间广播机制。2.3observable()平台生命周期模型observable()完全遵循平台生命周期第一个观察者创建并激活生产者、启动图表并发观察者共享活动生产者单个观察者可独立中止abort最后一个观察者离开时取消未决的生产者工作之后再次观察会开启一次全新的激活其subscriptions日志记录的是生产者激活窗口而非原始观察者数量。它直接由活动globalThis.Observable构造不选择、替换或导入回退构造函数。对比示例详见原文档 §11 与下文第 9 节it(makes the lifecycle explicit, () rxTest(({ cold, observable, expectObservable, expectSubscriptions }) { const legacy cold(--a--|); expectObservable(legacy).toBe(--a--|); expectObservable(legacy, ---^).toBe(-----a--|); expectSubscriptions(legacy.subscriptions).toBe([^----!, ---^----!]); const platform observable(--a--b--|); expectObservable(platform).toBe(--a--b--|); expectObservable(platform, ---^).toBe(-----b--|); expectSubscriptions(platform.subscriptions).toBe(^-------!); }));可以看到冷源两次观察产生两条独立订阅日志^----!与---^----!而平台源因共享激活只产生一条^-------!。3. 公开 API 全景权威的类型声明与文档注释位于 packages/test/src/types.ts根入口 packages/test/src/index.ts 导出rxTest与RxTestAssertionError及全部相关类型。以下为核心 surface。3.1 入口与配置export function rxTest(callback: RxTestCallback, config?: RxTestConfig): Promisevoid;RxTestConfig的完整配置项配置项类型默认值说明assertDeepEqualRxTestAssertDeepEqual内置深度严格相等自定义深等断言可委托 Jest/Vitest/Chai/Node assert可返回 PromisestartTimenumber \| string \| Date0虚拟Date使用的纪元上下文时间与performance.now()仍从零开始maxVirtualTimeTestDurationInfinity允许进入的最大虚拟时间戳maxTaskExecutionsnumber100000回调执行上限用于诊断自调度死循环idleBudgetTestDuration50默认IdleDeadline.timeRemaining()预算毫秒时长类型TestDuration number | \${number}ms | ${number}s | ${number}m。从 [packages/test/src/rx-test.ts](https://link.gitcode.com/i/bbd66835d6b3bfc7fab195ea34723348) 可以看到maxVirtualTime与idleBudget经durationToMilliseconds归一化为毫秒maxTaskExecutions必须是正整数startTime会经Date 解析并校验有限性。3.2 上下文对象RxTestContextrxTest回调收到一个完整上下文interface RxTestContext { readonly signal: AbortSignal; // 测试完成或失败时中止 coldT string(marbles, values?, error?): TestColdObservableT; hotT string(marbles, values?, error?): TestHotObservableT; observableT string(marbles, values?, error?): TestPlatformObservableT; time(marbles: string): number; // 返回时序图中唯一 | 的时间戳 now(): number; // 测试开始以来的虚拟毫秒数 schedule(work, delay?, options?): ScheduledTestTask; expectObservableT(actual, subscriptionMarbles?): ObservableExpectationT; expectSubscriptions(actual): SubscriptionExpectation; animate(plan: TestTimingPlan): void; // 声明动画帧机会仅可在时间零点调用一次 idle(plan: TestTimingPlan, options?): void; // 声明空闲机会同上 flush(): Promisevoid; // 排空有限虚拟工作并评估当前断言 advanceBy(duration: TestDuration): Promisevoid; advanceTo(time: TestDuration): Promisevoid; }expectObservable的匹配器toBe(marbles, values?, error?)将记录的通知与 Marble 图表比较toEqual(expected: ObservableT)与同一窗口内记录的另一个 Observable 比较。expectSubscriptions的toBe接受一个或多个订阅图表字符串。schedule返回ScheduledTestTask含dueTime、signal、cancel(reason?)支持AbortSignal取消。3.3 源对象与通知类型interface TestSourceT extends ObservableT { readonly kind: cold | hot | observable; readonly messages: readonly TestMessageT[]; readonly subscriptions: readonly TestSubscriptionLog[]; } type TestNotificationT | { readonly kind: N; readonly value: T } | { readonly kind: E; readonly error: unknown } | { readonly kind: C }; interface TestMessageT unknown { readonly frame: number; // 绝对虚拟时间戳 readonly notification: TestNotificationT; } interface TestSubscriptionLog { readonly subscribedFrame: number; // 每个 frame 为一个虚拟毫秒 readonly unsubscribedFrame: number; }TestHotObservable额外暴露active: boolean与next/error/complete手工控制方法。默认断言适配器是环感知cycle-aware的深度严格相等抛出RxTestAssertionError含actual、expected、assertion结构化信息见 packages/test/src/assertions.ts其deepEqual覆盖Date、RegExp、Error、ArrayBuffer视图、Map、Set与普通对象并用WeakMap记录已见对象对以避免循环引用死循环。4. Marble 语法语法表、对齐与解析实现每个普通 frame 等于 1 虚拟毫秒。语法解析由内部纯模块packages/test/src/marble-parser.ts 实现——它不依赖虚拟时钟、宿主、Observable 或断言可独立单测。该低级解析器目前不是包的公开导出是否暴露可另行决策而不耦合运行时。4.1 语法速查表语法含义空白符忽略-前进 1 毫秒a、1、、发射一个值并前进 1 毫秒(...)同步发射分组通知\|完成#错误^hot 零点或订阅点!订阅图表中的取消订阅12ms、20s、1.5m显式时间推进4.2 半开订阅窗口与帧内排序expectObservable的订阅窗口是半开的恰好位于!frame 的通知被排除且该 frame 上普通源工作执行前观察的AbortSignal即被取消。在共享虚拟时间戳处执行顺序为观察边界observation-boundary先运行 → 观察开始observation-start次之 → 普通源工作最后。因此时间零点的期望能观察到 hot 的时间零点通知而位于某通知 frame 的取消订阅仍会排除该通知。该顺序在 packages/test/src/virtual-time.ts 的任务优先级中可看到observation-boundary为-1observation-start/immediate为0timer/scheduled为1animation为2idle为3同 dueTime 下按优先级再按入队顺序执行。4.3 空白与对齐Marble 字符串内部的空白仅用于排版不推进虚拟时间。它可补偿源码布局中开引号列不一致的问题。hot 图表的关键对齐点是^因为它标记虚拟 frame 零。以下反例中较短标识符使第二个开引号左移两列但字符串内三个空格补偿过度其^偏右一列const quotePositionedRight --^---a--b--c--|; const paddedInsideString --^---x--y--z--|;正确示例恰好使用两个被忽略的字符串内空格使两个^以及 frame 零之后的时间线垂直对齐const quotePositionedRight --^---a--b--c--|; const paddedInsideString --^---x--y--z--|;引号外的空格只影响字面量在源码中的位置不会传给解析器引号内的空格会移动可见的 marble token 以对齐源码但解析器将其丢弃、不增加 frame因此内嵌填充必须与源码列偏移精确匹配。4.4 时长 token 与分组规则时长 token 在图表边界或由空白分隔时被识别a 12ms b含一个时长a12msb则全部是普通值标记。同步分组保留 RxJS 7 规则——文本宽度推进时间即便通知共享同一时间戳。time()接受空白、frame、时长 token 与恰好一个|返回该完成标记的时间戳。解析器实现细节parseMarbles通过durationPattern /^(\d(?:\.\d)?)(ms|s|m)(?\s|$)/识别时长且要求时长前一个是空白(组内的消息共享groupStartframe 而文本仍推进hot 图表只允许一个^冷图表中出现^、任何图表中出现!都会抛错未闭合分组同样抛错。订阅图表只允许^与!各一个未订阅 frame 缺省为Infinity见 packages/test/src/marble-parser.ts。5. 动画与空闲计划Animation Idle Plansanimate与idle接受绝对毫秒数组或Marble 时间线两种形式animate([4, 9, 14]); animate(------------); idle([6, 12], { budget: 5 }); idle(-----------);任何普通标记都会创建一个机会完成、错误、订阅、取消订阅与分组标记会被拒绝解析器在parseTimingPlan中对|#^!()抛出Timing plans cannot contain the reserved marker ...。数组形式要求条目为严格递增的绝对时间。语义要点动画机会已由requestAnimationFrame排队插入顺序的回调作为一批运行该批次期间新创建的请求等待下一次机会。空闲机会排队回调收到确定性IdleDeadlinetimeout先到期的回调以didTimeout true、timeRemaining() 0运行。两个助手都只能调用一次且必须在虚拟时间推进之前源码在#now ! 0时抛错没有剩余机会的未决 frame/idle 回调会使测试失败assertNoPendingWork会报告未决的 interval、动画帧与空闲回调。6. 虚拟宿主契约补丁、恢复与边界在回调与排空的完整生命周期内rxTest捕获并打补丁patch以下宿主 API见 packages/test/src/virtual-time.ts 的install()/restore()实现setTimeout/clearTimeoutsetInterval/clearIntervalrequestAnimationFrame/cancelAnimationFramerequestIdleCallback/cancelIdleCallbacksetImmediate/clearImmediate当 realm 提供时AbortSignal.timeoutDate、Date.now、performance.nowqueueMicrotask——委托原生队列并跟踪完成reportError——未处理的 Observable 错误使测试拒绝。动画、空闲与reportErrorAPI 在缺失时会被临时安装。Node 计时器句柄支持ref、unref、hasRef、refresh、数字强制转换与Symbol.dispose源码中的VirtualNodeTimerHandle类完整实现了这些方法并按process.versions.node是否存在决定返回句柄对象还是数字 id。恢复契约每个自有属性描述符与引用按打补丁的逆序恢复恢复在成功、回调失败、调度失败、断言失败或限制失败之后都会执行恢复错误与主错误用AggregateError合并。重要边界受支持的 RxJS 调度代码必须在调度工作时即时解析宿主函数lateglobalThis.*access。若在rxTest安装虚拟时间之前捕获setTimeout、requestAnimationFrame等宿主函数则会绕过测试边界属不支持用法。RxJS Next 平台层代码正是通过这种显式边界设计让同一套被补丁的 realm 函数同时管辖库工作与普通应用调度。7. 异步执行与完成语义异步测试体可以等待虚拟时间而无需手动推进await rxTest(async ({ now }) { await new Promisevoid((resolve) setTimeout(resolve, 25)); expect(now()).toBe(25); });引擎在到期虚拟任务与原生微任务检查点之间交替直至测试体 settle随后排空有限工作并评估断言。手动flush、advanceBy、advanceTo均为异步以保证 Promise 微任务按正确顺序完成advanceTo还禁止将虚拟时间倒退。rxTest返回的 Promise 在以下情况下拒绝未取消的自调度循环达到maxTaskExecutions任务超出maxVirtualTime无机会的未决 frame/idle 工作注册了期望但未提供匹配器evaluate()中抛出expectation was registered without a matcher回调、调度回调、返回的 Promise、未处理错误、断言或清理失败。限制JavaScript 无法可靠发现脱离detached的 Promise 链对测试完成重要的 Promise 工作必须 return 或 await。测试前捕获的计时器、MessageChannel、postMessage、真实 I/O 与导入的node:timers/promises函数不会被虚拟化。此外受支持 RxJS 操作符采用的每个调度原语都会成为rxjs/test的门禁——未来操作符若使用新宿主 API必须在同一变更中为其添加虚拟适配器。8. 并发与嵌套约束同一 realm 内的顶层rxTest调用按FIFO 串行化因为它们会补丁 realm 全局 API独立 worker 或 window 彼此不受影响。锁机制使用刻意共享、带版本的Symbol.for(rxjs/test/realm-lock/v1)键见 packages/test/src/rx-test.ts 的getRealmLock()。这不是Observable 扩展 Symbol共享同一性是为了防止重复的测试包副本并发补丁同一 realm。锁属性不可枚举队列清空后即被删除。嵌套rxTest调用不受支持同步嵌套会立即被诊断lock.callbackDepth 0时直接 rejectNested rxTest calls are not supported.。9. 实战示例9.1 框架无关的 Marble 测试import { rxTest } from rxjs/test; it(records the source, () rxTest(({ cold, expectObservable, expectSubscriptions }) { const source cold(--a--b--|, { a: first, b: second, }); expectObservable(source).toBe(--a--b--|, { a: first, b: second, }); expectSubscriptions(source.subscriptions).toBe(^-------!); }));9.2 原生计时器与 Observable 混用rxTest同时虚拟化普通应用计时器test(virtualizes application timers, () rxTest(({ expectObservable }) { const result new Observablestring((subscriber) { const handle setTimeout(() { subscriber.next(ready); subscriber.complete(); }, 12); subscriber.addTeardown(() clearTimeout(handle)); }); expectObservable(result).toBe(12ms (a|), { a: ready }); }));9.3 自定义断言适配器接入 Vitest/Jest 的expect并利用结构化断言信息await rxTest( ({ cold, expectObservable }) { expectObservable(cold(--a|)).toBe(--a|); }, { assertDeepEqual(actual, expected) { expect(actual).toEqual(expected); }, } );9.4 订阅窗口参数expectObservable的第二个参数可指定订阅时刻---^表示第 3 帧订阅用于验证迟到订阅看到的内容订阅日志通过expectSubscriptions(actual.subscriptions).toBe(^-------!)断言激活/订阅窗口。半开语义下位于!帧的通知不会被记录。10. 打包与验证rxjs/test是独立开发期包对匹配的rxjs版本声明精确 peer 依赖。它导入cold()所依赖的公开ColdObservable该 RxJS 入口仅在 realm 无 Observable 时条件安装回退实现并保留现有原生构造函数避免产生第二个 Observable 身份。包通过仓库常规tshy管道构建浏览器、webpack、ESM 与 CommonJS 多种方言发布产物排除源码测试。专注验证覆盖解析器直接用例对齐空白、时长、消息、分组、hot 脱字符caret、订阅、时间计划、Unicode 标记、非法语法集成用例三种源模型、ref-count 重启、toBe/toEqual、订阅日志、原生计时器、Node 句柄、时钟、微任务、动画、空闲回调、AbortSignal.timeout、取消、自定义断言、清理、串行化、嵌套调用诊断、执行/时间限制。11. 移植的 RxJS 7 测试证据与验证流水线11.1 证据仓库仓库将迁移的 RxJS 7 Marble 证据保存在packages/rxjs/test/ported位于rxjs/test包 API 之外。生成的清单manifest固定源修订版本并保留 2,201 个物理测试声明展开后的全部 2,338 条注册用例含参数化变体与源跳过证据。每个记录有唯一 case ID 与恰好一种处置disposition覆盖缺失能力与仅保护旧调度器 harness 的用例。实测清单数据sourceRef: 7.x、sourceCommit: e5351d02e225e275ac0e497c7b66eaa5f0c88791、cases: 2338、active: 2122、expectedFailure: 212、deduplicated: 4。可执行证据是 cold 与 platform 两种模式下各147 个普通、格式化的 Vitest.spec.ts文件由一次性迁移产生后作为普通源码归仓库所有。每个文件导入describe、it、rxTest及所用公开 RxJS Symbols每条测试都直接调用rxTest(...)。文件级注释记录源仓库、精确修订版本与历史 RxJS 7 路径。缺失 API 与不可用的 harness 设施会作为普通测试失败带源关联诊断而非跳过或仅表示为元数据。11.2 三种运行模式每种模式运行在独立的 Vitest 进程中使其构造函数在 Symbol 扩展加载前即处于活动状态见 packages/rxjs/vitest.ported.config.ts模式由RXJS_NEXT_TEST_MODE环境变量控制默认coldcold 模式将回退实现安装为平台基座而cold()与显式冷工厂使用ColdObservable但不替换全局polyfill 模式使用平台回退native 模式保留环境 Observable环境无 Observable 时跳过native-unavailable.spec.ts。平台测试从全局Observable构造从不导入回退构造函数专门的平台生命周期用例证明共享激活、单观察者取消、ref-count 重启与全局构造这些场景下旧的 producer-per-subscription 期望会产生误导。模式感知改写约束仅当某条精确迁移用例的订阅多重性断言在 RxJS 7 冷生命周期与共享平台生命周期之间有意不同时才可赋予内部 port 模式。入库的 cold 分支必须保留原始订阅证据平台分支只能修改受影响的生命周期期望例如两个并发观察者共享一个上游订阅。此类改写不得掩盖值、错误、完成或取消的不匹配也不得让平台实现变成 producer-per-subscription。11.3 门禁与审计命令默认 cold 与 polyfill 门禁将全部 2,338 条源用例注册为普通测试。已知的实现、能力、转换、生命周期、源跳过与重复处置不改变 Vitest 语义抛错的测试即失败该文件与命令。历史 verified-pass 基线仍可用于证据报告与 JSON 审计记录但不能隔离、跳过或反转默认结果。pnpm --filter rxjs run test:unit pnpm --filter rxjs run test:unit:native pnpm --filter rxjs run test:unit:audit pnpm --filter rxjs run test:unit:audit:polyfill pnpm --filter rxjs run test:unit:audit:check pnpm --filter rxjs run test:unit:report pnpm --filter rxjs run test:unit:parity:check正常命令原样使用 Vitest 内置defaultreporter失败打印真实入库文件名与行号编辑器和终端可直接打开。CI 审计使用 Vitest 内置 verbose reporter并通过静态迁移报告与声明顺序将结果关联到清单 case ID——机器 ID 不进入人类可读测试名同时仍验证完整、无重复的覆盖率。CI-only 的test:unit:audit:check捕获逐用例结果流并要求与已审阅的 cold/polyfill 通过 case-ID 集合精确一致收集不完整、未知/重复 ID、新失败或意外通过都会使其失败因此更新基线始终是显式的证据审查动作。CI 在审计前先构建 observable-polyfill 运行时依赖使源码导入经由贡献者本地相同的包边界解析。详细的计数、重复、缺失能力与不支持用例的理由见 docs/rxjs-next/RXJS_7_MARBLE_TEST_PORT_NOTES.md生成的符号映射与能力注册表见 docs/rxjs-next/RxJS-7-parity.md 与 packages/rxjs/test/ported/capability-registry.json。11.4 操作符导入的角色感知映射操作符导入是角色感知的。RxJS 7 管道调用如source.pipe(operator(arg1, arg2))变为对其映射导出 Symbol 的运行时调用sourcetargetSymbol精确映射保留参数统一映射显式记录适配器例如bufferCount(size) → sourcebuffer。静态创建函数在Observable[factorySymbol]上单独解析或经显式的环境平台构造。生成的地图记录 present、partial、unified、platform 与 missing 各面。11.5 可复用的迁移工具链可复用的迁移编写支持位于独立可发布的rxjs/migrate包其语义变换与测试运行器语法无关Mocha/Chai→Vitest 是首个适配器调用方可保留或替换它。配套曾包含 dry-run 优先的 CLI、可移植 Skill 资产与只读源码内容 MCP 工具在 D-046 决策下被接受的产品是确定性引擎 一个规范 Skill 薄 harness 适配器MCP 原型在 P0.M3 移除。迁移文件被接受后这些设施都不参与测试收集与执行机械 fixture 证据与 Agent 工作流证据仍是独立门禁详见 packages/migrate/README.md。12. 总结RxJS Next 的测试体系以rxTest为核心将虚拟时间、宿主调度接管、三种源模型、Marble 语法与断言统一进一个函数优先、自动恢复的 API 中同时通过packages/rxjs/test/ported中 2,338 条移植证据、147 个 Vitest 文件与严格的审计门禁把 RxJS 7 的测试资产转化为可验证、可追踪的兼容性保障。理解 cold生产者-每次-订阅与 observable平台共享激活的差异是迁移旧测试与编写新平台测试的关键而虚拟宿主契约的边界延迟解析宿主函数、await 关键 Promise、显式声明动画/空闲计划则决定了测试的确定性与可复现性。【免费下载链接】rxjsA reactive programming library for JavaScript项目地址: https://gitcode.com/gh_mirrors/rx/rxjs创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。