Тестовый планировщик
actions.debounce(800).play?.() — крошечное удобство в продакшене и небольшой кошмар в тестах. Реальные wall-таймеры делают тесты медленными и флэйкающими; vi.useFakeTimers() работает внутри Vitest, но не помогает в Jest-with-ESM или node:test; а таймеры, запланированные изнутри обработчика микротаски, обычно попадают прямо за окном, которое ты настроил.
createFakeScheduler из @triggery/testing — тонкая, тест-раннер-агностическая замена globalThis.setTimeout / globalThis.clearTimeout. Ставишь, отправляешь события, продвигаешь таймер ровно на нужное число миллисекунд, проверяешь. Работает одинаково в Vitest, Jest, bun:test и node:test.
Форма теста debounce
Заголовок раздела «Форма теста debounce»Никаких await new Promise((r) => setTimeout(r, 800)). Никакого «ой, CI прогнал за 803 мс и тест прошёл случайно». Таймер двигается только когда ты его двигаешь.
Что делает createFakeScheduler
Заголовок раздела «Что делает createFakeScheduler»Под капотом он подменяет две глобальные таймерные функции на виртуальный таймер:
Вот и всё. Планировщик не трогает setInterval, requestAnimationFrame, process.nextTick, микротаски или Date.now. Только setTimeout и clearTimeout — ровно то, что внутри используют обёртки debounce / throttle / defer Triggery.
Реализация — в @triggery/testing, нулевые runtime-зависимости, ~60 строк.
install() / uninstall()
Заголовок раздела «install() / uninstall()»Подменяет глобалы на install, восстанавливает на uninstall. Оба идемпотентны — вызов install() дважды — no-op, то же с uninstall(). Виртуальный таймер и мапа pending-таймеров сбрасываются на uninstall.
Стандартный паттерн — пара beforeEach/afterEach выше. Не дели один инстанс планировщика на весь файл — install/uninstall пер-тест даёт детерминированную изоляцию.
Если забудешь uninstall, следующий тест начнётся с подменённым setTimeout и может зависнуть навсегда, ожидая таймер, который никто не двигает.
advance(ms)
Заголовок раздела «advance(ms)»Сдвинь виртуальный таймер вперёд на ms и запусти каждый таймер, наступающий в этом окне:
advance возвращает промис, который резолвится после слива микротасок — поэтому можно сделать await fs.advance(N) и сразу проверять, не переживая о висящей микротаске. (Два раунда Promise.resolve() — внутренний приём, тот же, что у flushMicrotasks.)
Правила порядка внутри одного advance:
- Таймеры запускаются в порядке запланированного времени (
runAtвозрастает). - Одинаковый
runAt→ FIFO по порядку регистрации. - Таймер, запланированный во время обратного вызова и попадающий в
target(новое значение таймера после advance), запускается в том же вызове — удобно для каскадов. - Таймер с
runAt > targetждёт следующего advance.
Отрицательный ms бросает исключение — await expect(fs.advance(-1)).rejects.toThrow(/ms must be/).
flushAll()
Заголовок раздела «flushAll()»Запусти все pending-таймеры, независимо от запланированного времени, в порядке запланированного времени. Виртуальный таймер прыгает к максимальному runAt любого отработавшего таймера.
Используй flushAll для проверок «мне всё равно на точное время, я хочу видеть, что в итоге произойдёт». Правильный инструмент для проверки «очередь пуста» в конце теста:
pending()
Заголовок раздела «pending()»Возвращает число таймеров в очереди. Полезно как детектор утечек:
Тест, заканчивающийся с pending() > 0, обычно означает debounce-action, до которого ни один тест не докрутил таймер. Либо подвинь таймер до его слива, либо сделай await fs.flushAll() в afterEach.
Возвращает значение виртуального таймера в мс с момента install. Сбрасывается в 0 на uninstall.
Разобранный пример — throttle
Заголовок раздела «Разобранный пример — throttle»Разобранный пример — defer
Заголовок раздела «Разобранный пример — defer»Комбинирование с vi.useFakeTimers()
Заголовок раздела «Комбинирование с vi.useFakeTimers()»Не надо. Обе системы подменяют setTimeout и будут драться за владение. createFakeScheduler самодостаточен — и в отличие от vi.useFakeTimers() работает одинаково в Jest и node:test, так что у тебя не появится тест-сьют, привязанный к одному тест-раннеру.
Единственная валидная причина смешать их — если другая используемая тобой библиотека (не Triggery) опирается на Date.now или process.hrtime, и ты хочешь заморозить и их. В этом случае:
- Поставь fake-таймеры Vitest через
vi.useFakeTimers({ toFake: ['Date', 'performance'] })— ограничь источниками времени, неsetTimeout. - Затем поставь fake-планировщик Triggery.
Эти двое работают на непересекающихся поверхностях. Но это экзотика — большинству проектов это не нужно.
Тест-раннер-агностично — работает в Vitest, Jest, Bun, node:test
Заголовок раздела «Тест-раннер-агностично — работает в Vitest, Jest, Bun, node:test»У планировщика нулевые зависимости от Vitest-глобалов или vi. Можно использовать из любого раннера:
Паттерны и ловушки
Заголовок раздела «Паттерны и ловушки»Сливай микротаски перед проверками
Заголовок раздела «Сливай микротаски перед проверками»advance возвращает промис именно потому, что обработчики могут поставить микротаски. Всегда await-ай.
Не дели планировщик между тестовыми файлами
Заголовок раздела «Не дели планировщик между тестовыми файлами»Если ты импортируешь fs из хелпер-модуля, каждый файл, импортирующий его, делит один инстанс. Это нормально, пока install/uninstall пер-тест и не пересекаются — но один забытый uninstall отравляет следующий тест. Безопаснее: создавай свой на describe или на файл.
Teardown, чувствительный ко времени
Заголовок раздела «Teardown, чувствительный ко времени»Если тест заканчивается с таймерами в очереди (например, ты отправил событие и не двинул таймер), install следующего теста их увидит. Либо сливай в afterEach (await fs.flushAll()), либо прими, что fs.uninstall() тоже очищает очередь — оба варианта валидны.
Сочетание с async-обработчиками
Заголовок раздела «Сочетание с async-обработчиками»Обработчик, делающий await fetch(…), ставит свою post-fetch-работу как микротаску, не setTimeout. Fake-планировщик не трогает микротаски — тебе нужен await rt.flushMicrotasks(). advance — только для обёрток действий по времени (debounce/throttle/defer).
Не двигай в будущее вслепую
Заголовок раздела «Не двигай в будущее вслепую»await fs.advance(1_000_000) сработает, но это расточительно — планировщик запустит каждый таймер, наступающий в этом окне, отсортированный по runAt. Если нужно слить «всё, что в очереди», await fs.flushAll() честнее.
Drop-in тестовый хелпер
Заголовок раздела «Drop-in тестовый хелпер»Если не хочешь церемонии beforeEach/afterEach в каждом файле:
Импортируй getScheduler, где нужно advance или проверка pending. Каждый тест получает свежий инстанс, каждый тест убирает за собой.