Fake scheduler
actions.debounce(800).play?.() is a tiny convenience in production and a small nightmare in tests. Real wall clocks make tests slow and flaky; vi.useFakeTimers() works inside Vitest but doesn’t help in Jest-with-ESM or node:test; and timers scheduled from inside a microtask handler tend to land just outside whatever window you set up.
createFakeScheduler from @triggery/testing is a thin, test-runner-agnostic replacement for globalThis.setTimeout / globalThis.clearTimeout. You install it, fire your events, advance the clock by the exact number of milliseconds you care about, then assert. It works the same in Vitest, Jest, bun:test and node:test.
The shape of a debounce test
Section titled “The shape of a debounce test”No await new Promise((r) => setTimeout(r, 800)). No “wait, the CI ran it in 803 ms and the test passed by luck”. The clock only advances when you advance it.
What createFakeScheduler does
Section titled “What createFakeScheduler does”Under the hood it swaps the two global timer functions for a virtual clock:
That’s it. The scheduler doesn’t touch setInterval, requestAnimationFrame, process.nextTick, microtasks, or Date.now. Only setTimeout and clearTimeout — which is exactly what Triggery’s debounce / throttle / defer wrappers use internally.
The implementation is in @triggery/testing — zero runtime deps, ~60 lines.
install() / uninstall()
Section titled “install() / uninstall()”Swap globals on install, restore them on uninstall. Both are idempotent — calling install() twice is a no-op, same for uninstall(). The virtual clock and pending-timer map reset on uninstall.
The standard pattern is the beforeEach/afterEach pair above. Don’t share a scheduler instance across an entire file — installing and uninstalling per-test gives you deterministic isolation.
If you forget uninstall, the next test starts with a fake setTimeout and may hang forever waiting for a timer that no one will ever advance.
advance(ms)
Section titled “advance(ms)”Move the virtual clock forward by ms and run every timer due in that window:
advance returns a promise that resolves after microtasks are drained — so callers can await fs.advance(N) and then assert immediately, without worrying about a queued microtask still pending. (Two Promise.resolve() rounds is the internal trick — same as flushMicrotasks.)
Ordering rules inside one advance:
- Timers are run in scheduled-time order (
runAtascending). - Same
runAt→ FIFO by registration order. - A timer scheduled during a callback that happens to fall within
target(the new clock value after the advance) runs in the same call — handy for cascades. - A timer scheduled with
runAt > targetwaits for a later advance.
Negative ms throws — await expect(fs.advance(-1)).rejects.toThrow(/ms must be/).
flushAll()
Section titled “flushAll()”Run every pending timer, regardless of scheduled time, in scheduled-time order. The virtual clock jumps to the max runAt of any timer that ran.
Use flushAll for “I don’t care about the exact timing, just see what eventually happens” assertions. It’s the right tool for asserting “the queue is empty” at the end of a test:
pending()
Section titled “pending()”Returns the number of timers currently in the queue. Useful as a leak detector:
A test that ends with pending() > 0 usually means a debounced action that no test ever advanced past. Either advance the clock to drain it, or await fs.flushAll() in afterEach.
Returns the virtual clock value in ms since install. Resets to 0 on uninstall.
Worked example — throttle
Section titled “Worked example — throttle”Worked example — defer
Section titled “Worked example — defer”Combining with vi.useFakeTimers()
Section titled “Combining with vi.useFakeTimers()”Don’t. The two systems both replace setTimeout and will fight for ownership. createFakeScheduler is enough on its own — and unlike vi.useFakeTimers() it works identically in Jest and node:test, so you don’t end up with a test-runner-specific test suite.
The only valid reason to mix them is if another library you use (not Triggery) relies on Date.now or process.hrtime and you want to freeze those too. In that case:
- Install Vitest’s fake timers with
vi.useFakeTimers({ toFake: ['Date', 'performance'] })— restrict it to time sources, notsetTimeout. - Then install Triggery’s fake scheduler.
The two now operate on disjoint surfaces. But this is exotic — most projects don’t need it.
Test-runner agnostic — works in Vitest, Jest, Bun, node:test
Section titled “Test-runner agnostic — works in Vitest, Jest, Bun, node:test”The scheduler has zero dependencies on Vitest globals or vi. You can use it from any runner:
Patterns and pitfalls
Section titled “Patterns and pitfalls”Drain microtasks before assertions
Section titled “Drain microtasks before assertions”advance returns a promise specifically because handlers may queue microtasks. Always await it.
Don’t share a scheduler across test files
Section titled “Don’t share a scheduler across test files”If you import fs from a helper module, every file that imports it shares the same instance. That’s fine as long as install/uninstall is per-test and never overlaps — but a single forgotten uninstall poisons the next test. Safer: create one per describe or per file.
Time-sensitive teardown
Section titled “Time-sensitive teardown”If a test ends with timers in the queue (e.g. you fired but never advanced), the next test’s install will see them. Either drain them in afterEach (await fs.flushAll()), or accept that fs.uninstall() clears the queue too — both are valid.
Combining with async handlers
Section titled “Combining with async handlers”A handler that does await fetch(…) schedules its post-fetch work as a microtask, not a setTimeout. The fake scheduler doesn’t touch microtasks — await rt.flushMicrotasks() is what you want there. Use advance only for time-based action wrappers (debounce/throttle/defer).
Don’t advance into the future blindly
Section titled “Don’t advance into the future blindly”await fs.advance(1_000_000) works but is wasteful — the scheduler runs every timer due in that window, sorted by runAt. If you only need to drain “everything that’s queued”, await fs.flushAll() is more honest.
A drop-in test helper
Section titled “A drop-in test helper”If you don’t want the beforeEach/afterEach ceremony in every file:
Import getScheduler wherever you need to advance or assert on pending. Every test gets a fresh instance, every test cleans up.