Effect 测试模式实战指南:基于 @effect/vitest 与 Tstyche 的单元测试与类型级测试规范 Effect 测试模式实战指南基于 effect/vitest 与 Tstyche 的单元测试与类型级测试规范【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code本文以 effect-smol 仓库.repos/effect-smol的测试模式文档 .patterns/testing.md 为骨架结合仓库内effect/vitest包的源码与测试用例系统讲解基于 Effect 生态的测试编写规范如何用it.effect/it.live编写返回 Effect 的测试、为什么单元测试中禁止使用Effect.runSync、时间相关逻辑为何必须交给TestClock以及如何用 Tstyche 在类型层面捕获显示类型泄漏这类回归。读完本文你可以直接复用这套模式为任何使用 Effect 的项目编写确定性高、可维护、可被 CI 稳定执行的测试套件。一、测试框架选型Effect 测试与纯函数测试的分工1.1 用it.effect测试返回 Effect 的逻辑Effect 生态的核心测试模式是测试返回 Effect 的代码时使用effect/vitest提供的it.effect。它等价于 Vitest 的it但测试体返回的不是普通值或 Promise而是一个Effectimport { assert, describe, it } from effect/vitest import { Effect } from effect it.effect(should work with Effects, () Effect.gen(function*() { const result yield* someEffect assert.strictEqual(result, expectedValue) }))约定凡返回Effect的测试一律使用it.effect不要使用普通it配合Effect.runPromise/runSync手动运行。1.2 用普通it测试纯同步函数对于不依赖 Effect 运行时、纯粹同步的 TypeScript 函数直接使用 Vitest 的普通it即可保持最小的开销与最直观的断言方式import { assert, describe, it } from effect/vitest it(should work with pure functions, () { const result pureFunction(input) assert.strictEqual(result, expectedValue) })1.3 Scope 由框架自动托管不要手动包Effect.scoped这是本模式中极易踩坑的一条it.effect和it.live会在每个测试开始时创建并提供Scope并在测试结束时自动关闭它。因此测试体内可以直接返回作用域相关的 Effect例如包含Effect.acquireRelease的程序严禁再在测试体外面套一层Effect.scoped——重复包裹会导致 Scope 管理职责混乱并可能引发资源提前/滞后释放的问题。从实现上看effect/vitest正是通过Effect.scoped来消费框架注入的 Scope 的。在 packages/vitest/src/internal/internal.ts 中makeMethods构建effect与live两个 testerexport const makeMethods (it: V.TestAPI): Vitest.Vitest.Methods makeItProxy(it, { effect: makeTesterScope.Scope(flow(Effect.scoped, Effect.provide(TestEnv)), it), live: makeTesterScope.Scope(Effect.scoped, it), flakyTest, layer, prop })可以看到it.effect在运行测试体前会Effect.provide(TestEnv)将TestConsole与TestClock两个测试服务注入环境见TestEnv Layer.mergeAll(TestConsole.layer, TestClock.layer())两者都会在内部做一次Effect.scoped从而把框架创建的Scope应用到测试体上——这正是不要自己再包Effect.scoped的原因。1.4it.live需要真实时钟与服务时使用与it.effect相对的是it.live它同样提供并管理 Scope但不注入TestClock/TestConsole测试运行在真实的时钟与真实服务之上。适用于集成类测试、真实 I/O 或明确需要真实时间的场景。在仓库自测 packages/vitest/test/index.test.ts 中两种写法并排出现验证了 Scope 的 acquire/release 语义it.effect( effect, () Effect.acquireRelease(Effect.sync(() expect(1).toEqual(1)), () Effect.void) ) it.live( live, () Effect.acquireRelease(Effect.sync(() expect(1).toEqual(1)), () Effect.void) )二、测试规则四条硬性约定2.1 单元测试中禁止Effect.runSync规则永远不要在单元测试里使用Effect.runSync。唯一的例外是可运行文档runnable documentation并且仅限于.patterns/jsdoc.md中描述的刻意同步执行场景例如文档示例明确以同步执行为契约时见 .patterns/jsdoc.md 中关于 runner 选择的说明UseEffect.runSynconly when synchronous execution is the documented contract or materially clarifies an Effect known to be synchronous。理由runSync要求 Effect 在其内部可以同步完成任何被测试代码中隐藏的异步依赖真实时钟、I/O、调度都会在测试中暴露为非确定行为或直接抛错而框架提供的it.effect/it.live已经处理了异步运行与中断没有必要手动同步执行。2.2 断言统一使用assert不使用expect规则不要使用 Vitest 的expect统一使用effect/vitest导出的assert方法。这套assert是一组基于 Node 内置assert、Vitest 实例检查与 Effect 相等语义构建的断言工具覆盖常规值与 Effect 特有数据结构两类场景。全部定义于 packages/vitest/src/utils.ts。常用函数一览断言函数语义适用场景strictEqual(actual, expected)严格相等基础值断言deepStrictEqual(actual, expected)深严格相等对象/数组结构断言assertEquals(actual, expected)基于Equal.equals的相等Effect 数据结构如Duration、自定义Equal类型notDeepStrictEqual(actual, expected)深严格不相等结构差异断言assertTrue / assertFalse(value)布尔断言带类型收窄谓词、标志位assertSome(option, expected)/assertNone(option)Option.Some/Option.None断言Option结果assertSuccess(result, expected)/assertFailure(result, expected)Result.Success/Result.Failure断言Result结果assertExitSuccess(exit, expected)/assertExitFailure(exit, cause)Exit.Success/Exit.Failure断言Exit结果throws(thunk, error?)/throwsAsync(thunk, error?)同步/异步抛错断言错误路径测试assertInstanceOf(value, constructor)实例类型断言带类型收窄类实例检查assertInclude / assertMatch子串包含 / 正则匹配字符串断言assertDefined / assertUndefined定义性断言带类型收窄可选值fail(message)无条件失败兜底分支其中assertEquals特别值得注意它优先走Equal.equalstraitEffect 中Option、Exit、Duration、带Symbol.for(effect/Equal)的自定义类型均实现该 trait仅在不相等时才回退到deepStrictEqual展示 diff 并失败见 utils.ts。需要说明的是仓库中effect/vitest自身的自测文件如 packages/vitest/test/index.test.ts因为要验证框架对 Vitestexpect的兼容性也大量使用了expect但这属于框架自身的回归测试项目编写业务测试时应遵循文档约定——统一使用assert。2.3 时间相关操作一律使用TestClock规则Always useTestClockfor time-dependent operations.凡涉及sleep、schedule、超时、定时器、时间戳的时间相关逻辑一律在it.effect中使用TestClock手动推进时间保证测试确定性与执行速度而不是真实等待。it.effect会注入TestClock见上文TestEnv因此在测试体内可以直接使用effect/testing的TestClockAPI。仓库自测给出了一个典型用例packages/vitest/test/index.test.tslayer(Sleeper.layer)(test services, (it) { it.effect(TestClock, () Effect.gen(function*() { const sleeper yield* Sleeper const fiber yield* Effect.forkChild(sleeper.sleep(100_000)) yield* Effect.yieldNow yield* TestClock.adjust(100_000) yield* Fiber.join(fiber) })) })该测试让一个 fiber 休眠 100,000ms通过TestClock.adjust(100_000)一次性把虚拟时钟拨快fiber 立即恢复——整个测试毫秒级完成且完全确定。2.4 用describe分组相关测试规则Group related tests usingdescribe.语义相关的测试放入同一个describe块输出更清晰、便于聚焦运行pnpm vitest -t pattern也为后续使用layer(...)按组注入依赖提供了天然的组织单元见下文。三、类型级测试用 Tstyche 守护公共 API 的类型契约3.1 位置与运行方式类型级测试位于各包的packages/*/typetest/目录文件以.tst.ts结尾使用Tstyche编写。仓库的 tstyche.json 声明了测试文件匹配规则{ testFileMatch: [ packages/*/typetest/**/*.tst.*, packages/*/*/typetest/**/*.tst.* ], tsconfig: baseline }运行针对性的类型级测试按文件名过滤pnpm test-types filename该命令实际执行tstyche --target 5.9见根 package.json 的test-typesscript即针对所有不低于 TypeScript 5.9 的已配置目标版本逐一运行类型测试。这意味着你修改公共 API 后应当对每个目标 TS 版本都跑一遍相关 typetest防止类型行为仅在某个版本上回归。3.2 为什么普通 Tstyche 断言不够Tstyche 的常规断言如toBe、toMatch进行的是结构比较——比较两个类型是否可互相赋值。它无法捕获一类特殊回归公共类型在语义上正确但在编辑器 quick info 中显示为内部别名或未化简的交叉类型。例如一个应显示为{ readonly value: string }的公共类型若因实现细节泄漏被渲染为InternalAlias { ... }结构上仍能通过toBe但用户在实际使用时的 IDE 提示与类型可读性已经劣化。3.3 用受检ts-expect-error测试显示类型标准做法是故意制造一个赋值错误然后用 Tstyche 的受检ts-expect-error消息匹配渲染类型中的一个特征子串it(simplifies the displayed type, () { const value null as unknown as PublicType // ts-expect-error Type { readonly value: string; } const displayed: never value void displayed })原理把value赋给never必然产生类型错误TS 会在错误信息中写出value的真实渲染类型Tstyche 会校验该ts-expect-error注释上方的诊断消息是否包含注释中给出的子串。若PublicType泄漏了内部别名渲染结果变化匹配失败测试即红。采纳该测试前务必做一次反向验证临时把类型恢复为损坏的实现例如换回内部别名、去掉化简确认诊断消息匹配会失败——只有这样才能保证该测试确实在守护目标行为而不是永远通过的空断言。3.4 保持期望子串尽量小ts-expect-error中的期望子串应尽可能短只要足以区分期望的公共类型与泄漏的实现类型即可。原因TypeScript 诊断措辞会随版本变化格式化、别名展开策略、交叉类型展示顺序都可能变动。子串越短跨 TS 版本越稳定同时又要足够独特避免误匹配。最终应以pnpm test-types配置的每个TypeScript 版本都通过为准。四、进阶能力依赖注入、属性测试与重试源码级补充原模式文档聚焦测试纪律而effect/vitest包还提供了支撑这套纪律的高级工具。以下内容均有源码与测试佐证可作为团队扩展用法的参考。4.1layer(...)按组共享依赖注入it.effect每个测试拥有独立 Scope若多个测试需要相同服务可用layer(layerDefinition)把一组测试包裹起来共享同一个 Layer 实例默认使用Layer.makeMemoMap缓存构建结果并可选传名字以生成describe块import { assert, layer } from effect/vitest import { Effect, Layer, Context } from effect layer(Foo.layer)(layer, (it) { it.effect(adds context, () Effect.gen(function*() { const foo yield* Foo assert.strictEqual(foo, foo) })) it.layer(Bar.layer)(nested, (it) { it.effect(adds context, () Effect.gen(function*() { const foo yield* Foo const bar yield* Bar assert.strictEqual(foo, foo) assert.strictEqual(bar, bar) })) }) })关键选项见 packages/vitest/src/index.ts 的类型签名memoMap自定义记忆化映射控制 Layer 构建结果的缓存粒度timeout钩子beforeAll/afterAll超时接受Duration.InputexcludeTestServices: true不注入TestClock/TestConsole让组内测试使用真实服务仓库自测 index.test.ts 用它验证真实Clock下的休眠。嵌套it.layer会通过Layer.forkMemoMapUnsafe(memoMap)派生子映射并合并父层环境类型层面嵌套层不再接受excludeTestServices与memoMap选项见 typetest/index.tst.ts其中用type.not.toBeCallableWith明确锁定了这一约束。Scope 会在最后一个相关测试结束后统一关闭afterAll/onTestFinished计数逻辑见 internal.ts。4.2it.propSchema 驱动的属性测试prop基于effect/testing/FastCheckfast-check 的 Effect 适配支持数组或对象形式的 arbitraries且可以直接传SchemaSchema会自动转为对应 arbitrary见 internal.tsit.effect.prop( should detect the substring, { a: FastCheck.string(), b: FastCheck.string(), c: FastCheck.string() }, ({ a, b, c }) Effect.gen(function*() { yield* Effect.scope assert.include(a b c, b) }) ) it.live.prop( schema with object, { value: Schema.Int }, ({ value }) Effect.sync(() assert.isTrue(Number.isInteger(value))) )可通过{ fastCheck: { numRuns: 200 } }之类的参数控制 fast-check 的迭代次数与参数。4.3flakyTest自动重试的易碎测试flakyTest将 Effect 包裹为sandbox 化 → 按调度策略重试默认递归 10 次、总时长上限 30s见 internal.ts。适用于少数确实存在外部时序抖动、但语义上不应失败的测试timeout参数可调整总时长上限。4.4 配套测试辅助it.each/skip/skipIf/runIf/only/failsit.effect与it.live的 tester 均通过 Proxy 复用了 Vitest 的静态辅助方法internal.ts因此可以链式使用it.effect.each([1, 2, 3])(effect each %s, (n) Effect.acquireRelease(Effect.sync(() expect(n).toEqual(n)), () Effect.void)) it.effect.skipIf(true)(effect skipIf (true), () Effect.die(skipped anyway)) it.effect.runIf(true)(effect runIf (true), () Effect.sync(() expect(1).toEqual(1))) it.live.fails(interrupts on timeout, (ctx) /* ... */, 1)其中fails用于断言该测试预期失败如验证超时中断行为自测文件 index.test.ts 用它验证了超时场景下acquireRelease的 finalizer 会被正确执行。五、落地清单与常见误区将本文模式落地到新测试文件时可按以下清单自查选型返回 Effect →it.effect纯同步 →it需要真实时钟/服务 →it.live。Scope测试体直接返回 Effect绝不手动包裹Effect.scoped。禁止项单元测试不出现Effect.runSync仅可运行文档的刻意同步场景除外。断言只从effect/vitest导入assert不导入 Vitest 的expectEffect 数据结构用assertEquals。时间时间相关逻辑全部使用TestClockit.effect已注入用TestClock.adjust拨快虚拟时钟。分组相关测试用describe组织共享依赖用layer(...)嵌套依赖用it.layer。类型回归公共 API 的显示类型变化用typetest目录下的 Tstyche 受检ts-expect-error守护期望子串尽量短并对pnpm test-types覆盖的每个 TypeScript 版本运行验证。常见误区小结在it.effect里多包一层Effect.scoped——框架已注入并管理 Scope重复包裹会导致资源生命周期管理混乱用runSync把 Effect 测试拍平成同步断言——隐藏的异步依赖会让测试不稳定且违背框架设计只做结构相等的类型断言——结构正确但显示类型泄漏内部别名、未化简交叉是类型层面最隐蔽的公共 API 回归必须用显示类型测试捕获期望子串写得太长——TS 诊断措辞跨版本不稳定长子串会无谓地增加脆弱性。这套模式在仓库中同时由三层证据支撑模式文档.patterns/testing.md给出约定框架源码packages/vitest/src/internal/internal.ts 与 packages/vitest/src/utils.ts给出实现而框架自测packages/vitest/test/index.test.ts与类型测试packages/vitest/typetest/index.tst.ts本身即是这套模式的践行范例可作为团队内部一致性检查的参考样本。【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考