Код, зависящий от времени, нельзя тестировать реальными часами: тест на debounce, интервал или таймаут начнёт зависеть от того, насколько быстра машина, и станет флаки. Vitest даёт управление временем через vi.useFakeTimers - он подменяет таймеры и системные часы на контролируемые. С этого момента время перестаёт быть внешним обстоятельством и становится входом теста, которым вы управляете явно.
Схема простая: в beforeEach включают фейковые таймеры и фиксируют момент через vi.setSystemTime, а в afterEach возвращают настоящие через vi.useRealTimers. Внутри теста время не ждут, а двигают вручную - vi.advanceTimersByTime(300) прокручивает ровно нужный интервал мгновенно. Так тест, проверяющий задержку в 300 мс, выполняется за микросекунды и всегда одинаково.
Показательный пример - debounce. Функцию вызывают дважды подряд, затем прокручивают время на длину задержки и проверяют, что реальный обработчик сработал ровно один раз и с последним аргументом. Без фейковых таймеров пришлось бы ждать настоящие 300 мс и надеяться, что планировщик успел; с ними поведение планировщика проверяется точно и за один шаг.
Важно разделять доменное время и время планировщика. Для дат в данных - created_at, дедлайн, срок действия - лучше внедрять Clock как явную зависимость и подставлять в тесте фиксированное значение, а не подменять глобальный Date. Фейковые таймеры оставляют для поведения scheduler: debounce, throttle, интервалы, backoff у повторов. Тогда каждый инструмент отвечает за свою часть, а не подменяет всё время сразу.
Рекурсивные таймеры - отдельная ловушка. Если таймер по срабатывании планирует новый таймер, vi.runAllTimers() уйдёт в бесконечный цикл, пытаясь исчерпать очередь, которая сама себя пополняет. В таких случаях двигают время шагами через advanceTimersByTime или прогоняют только уже запланированное через runOnlyPendingTimers, не пытаясь долистать очередь до конца.
Под подмену попадают setTimeout, setInterval, системные часы и, при желании, микрозадачи, process.nextTick и requestAnimationFrame. Набор можно сузить опцией toFake, передав список только тех таймеров, что нужны тесту, и оставив реальным всё остальное. Это полезно, когда подмена всех таймеров сразу ломает стороннюю библиотеку, которая полагается на настоящий планировщик: тогда подменяют только то, что относится к проверяемому поведению, а чужую механику не трогают.
Типичный провал - ждать реальную задержку через sleep вместо фейковых таймеров. Это одновременно замедляет suite и не убирает недетерминизм: на нагруженном CI задержки плывут. Второй провал - забыть vi.useRealTimers в afterEach: фейковое время протечёт в следующие тесты, и совершенно не связанный с таймерами тест вдруг зависнет или увидит замороженную дату.
Итог прост: фейковые таймеры превращают время в управляемый вход теста. Вместо того чтобы ждать, вы двигаете часы явно к той точке, поведение в которой проверяете, - к моменту после debounce, к следующему тику интервала, к сработавшему таймауту. Время становится таким же аргументом сценария, как входные данные, и перестаёт быть источником случайных падений.
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-26T10:00:00Z'))
})
afterEach(() => vi.useRealTimers())
test('шлёт поиск после 300 ms debounce', () => {
const search = vi.fn()
const debounced = debounce(search, 300)
debounced('je')
debounced('vitest')
vi.advanceTimersByTime(300)
expect(search).toHaveBeenCalledTimes(1)
expect(search).toHaveBeenCalledWith('vitest')
})