Effect 的超时和重试怎么测,才不用真等?
一段带 Effect.timeout("5 seconds") 的代码,测试里只有两个坏选择:真的等 5 秒,或者把超时调得很小。后一种测试跑得很快,但测的已经不是线上的行为。
答案是用 @effect/vitest 的 it.effect 自带的虚拟时钟 TestClock:先把被测 Effect fork 到后台,用 TestClock.adjust 把时间拨快,再 Fiber.join 取结果。 在这个环境里 Effect.sleep、Effect.timeout、Schedule 都不会真的等待,它们等的是虚拟时间被推进,由你决定时间什么时候走。
这件事能成立,是因为 Effect 里的时间是一个可替换的环境,不是写死的全局定时器。下面用一个带重试和超时的 checkout 走一遍完整写法。如果你想先了解替身 Layer 是怎么回事,见 Effect 代码怎么测试,才不用 mock 整个模块?。
超时测试怎么写?
被测代码:Gateway 是一个服务,checkout 对它的 charge 加重试和 5 秒超时。
import { Context, Data, Effect } from "effect"
class PayError extends Data.TaggedError("PayError")<{ readonly reason: string }> {}
class Gateway extends Context.Service<Gateway, {
readonly charge: (cents: number) => Effect.Effect<string, PayError>
}>()("app/Gateway") {}
const checkout = Effect.gen(function* () {
const gw = yield* Gateway
return yield* gw.charge(100).pipe(
Effect.retry({ times: 2 }),
Effect.timeout("5 seconds")
)
})测试里给 Gateway 一个睡一小时的替身,让超时必然触发:
import { assert, it } from "@effect/vitest"
import { Fiber, Layer } from "effect"
import { TestClock } from "effect/testing"
it.effect("超时按虚拟时间触发", () =>
Effect.gen(function* () {
const Slow = Layer.succeed(Gateway, {
charge: () => Effect.sleep("1 hour").pipe(Effect.as("late"))
})
const fiber = yield* checkout.pipe(
Effect.provide(Slow),
Effect.flip,
Effect.forkChild
)
yield* TestClock.adjust("5 seconds")
const err = yield* Fiber.join(fiber)
assert.strictEqual((err as { _tag: string })._tag, "TimeoutError")
}))Effect.flip 把失败换成成功值,方便直接断言,TimeoutError 的 _tag 就是断言目标。整个测试几乎瞬间完成,而且测的正是线上用的 5 秒超时。注意 TestClock 要从 effect/testing 导入,不是 effect 根模块。
为什么必须先 fork,再推进时钟?
checkout 在睡眠时会阻塞当前 fiber。如果不先把它放到后台,当前 fiber 就停在那里,没有人去推进时钟,测试卡死。
所以顺序固定是三步:用 Effect.forkChild 在后台启动,TestClock.adjust 拨快时间,Fiber.join 拿结果。fiber 是 Effect 里并发执行的单位,forkChild 的语义见 Fiber 心智模型。
测试一旦卡住,先检查有没有漏掉 fork。
重试退避怎么测?
做法相同:把带 Schedule.spaced("1 minute") 的重试 fork 出去,TestClock.adjust("2 minutes"),再断言执行了 3 次。重试间隔是虚拟时间,所以不用等两分钟。Schedule 与退避本身怎么设计,见 重试与 Schedule 以及 时间与重试。
什么时候该用真实时钟?
偶尔确实需要,比如测试与一个真实子进程的交互。这时用 it.live 代替 it.effect。把它当成例外:能用虚拟时间验证的行为,就不要用真实时间。
另一个常见错误是在 it.effect 里再调用 Effect.runPromise:测试体本身就是 Effect,直接 yield*,嵌套运行会丢掉测试环境提供的 TestClock 与 Scope。运行边界的整体原则见 运行时边界与测试。
本文代码来自我整理的一份 Effect 教程章节,这次改写没有重新运行,因此不能保证在你的版本上一定通过。重试退避那一段原章节只给了文字描述(Schedule.spaced、adjust 两分钟、断言三次),没有给完整代码,我也没有自己写代码验证。