Асинхронный код в среде JavaScript исполняется в рамках модели однопоточного событийного цикла (Event Loop). Понимание приоритета очередей — фундаментальное требование для написания надежных тестов:
Promise.then, catch, finally), вызовы queueMicrotask() и process.nextTick(). Очередь микротасок дренируется полностью до перехода к любой другой задаче.setTimeout, setInterval, setImmediate), системный I/O, события UI.При реальном ожидании таймеров тесты замедляются (например, ожидание 5 секунд для проверки таймаута сессии сделает тестовый прогон неприемлемо долгим). Для решения этой проблемы Vitest предоставляет механизм Fake Timers (Фейковые таймеры).
+-------------------------------------------------------------+
| JavaScript Event Loop |
+-------------------------------------------------------------+
|
+----------------------------+----------------------------+
| |
v v
+-------------------------------+ +-------------------------------+
| Microtask Queue | | Macrotask Queue |
| - Promise.then / await | | - setTimeout / setInterval |
| - queueMicrotask | | - setImmediate / I/O |
| (Выполняется непрерывно | | (Перехвачено Vitest |
| до полного опустошения) | | Fake Timers Engine) |
+-------------------------------+ +-------------------------------+
|
v
+-------------------------------+
| vi.advanceTimersByTime(ms) |
| Мгновенный сдвиг виртуальных|
| часов без реальной паузы |
+-------------------------------+Вызвать vi.useFakeTimers() с опцией toFake: ['setTimeout', 'clearTimeout', 'Date'] при необходимости выборочной подмены.
Инициировать операцию (клик, запуск поллинга, вызов debounce-функции).
Переместить виртуальное время через await vi.advanceTimersByTimeAsync(ms), дав возможность исполниться микротаскам.
Проверить инварианты, удостовериться в сбросе таймеров и обязательно вызвать vi.useRealTimers().
Почему при тестировании асинхронных функций, вызываемых внутри setTimeout/setInterval, необходимо использовать advanceTimersByTimeAsync вместо advanceTimersByTime?
AbortError при превышении времени ожидания ответа с использованием AbortController и AbortSignal.timeout().vi.setSystemTime() для проверки истечения срока действия JWT-токенов, скидочных купонов или расчета амортизации.setInterval, удерживающих ссылки на компоненты после их размонтирования.vi.advanceTimersByTimeAsync#Рассмотрим реализацию строго типизированного debounce и его тестирование. Обратите внимание: поскольку коллбэк может возвращать промис или запускать асинхронные цепочки, мы используем метод advanceTimersByTimeAsync.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
export function debounce<TArgs extends readonly unknown[]>(
fn: (...args: TArgs) => void | Promise<void>,
delayMs: number
): (...args: TArgs) => void {
let timerId: ReturnType<typeof setTimeout> | null = null;
return (...args: TArgs) => {
if (timerId !== null) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
timerId = null;
void fn(...args);
}, delayMs);
};
}
describe('debounce с фейковыми таймерами', () => {
beforeEach(() => {
// Включаем виртуализацию таймеров перед каждым тестом
vi.useFakeTimers();
});
afterEach(() => {
// Обязательный возврат к нативным таймерам
vi.useRealTimers();
});
it('вызывает целевую функцию ровно один раз после истечения задержки', async () => {
const targetFn = vi.fn();
const debouncedFn = debounce(targetFn, 300);
// Три последовательных вызова подряд
debouncedFn('alpha');
debouncedFn('beta');
debouncedFn('gamma');
// На момент 299 мс функция не должна быть вызвана ни разу
await vi.advanceTimersByTimeAsync(299);
expect(targetFn).not.toHaveBeenCalled();
// Сдвигаем время еще на 1 мс (суммарно 300 мс)
await vi.advanceTimersByTimeAsync(1);
expect(targetFn).toHaveBeenCalledTimes(1);
expect(targetFn).toHaveBeenCalledWith('gamma');
});
it('сбрасывает таймер при повторном поступлении аргументов до истечения периода', async () => {
const targetFn = vi.fn();
const debouncedFn = debounce(targetFn, 500);
debouncedFn('initial');
await vi.advanceTimersByTimeAsync(300); // Прошло 300 мс
// Повторный вызов сбрасывает таймер
debouncedFn('updated');
await vi.advanceTimersByTimeAsync(300); // Прошло 300 мс от второго вызова (всего 600 мс)
expect(targetFn).not.toHaveBeenCalled();
// Дожидаемся оставшихся 200 мс
await vi.advanceTimersByTimeAsync(200);
expect(targetFn).toHaveBeenCalledTimes(1);
expect(targetFn).toHaveBeenCalledWith('updated');
});
});AbortController#В современных приложениях отмена запросов при смене страницы или превышении лимита времени реализуется через Web API AbortController. В тестах мы проверяем обработку ошибки прерывания.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
export async function fetchWithTimeout<T>(
url: string,
timeoutMs: number,
clientFetch: typeof fetch = fetch
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(new DOMException('Запрос прерван по таймауту', 'TimeoutError'));
}, timeoutMs);
try {
const response = await clientFetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return (await response.json()) as T;
} finally {
// Гарантированная очистка таймера во избежание утечек памяти
clearTimeout(timeoutId);
}
}
describe('fetchWithTimeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('прерывает зависший сетевой запрос по истечении лимита времени', async () => {
// Мок сетевого вызова, который никогда не резолвится до вызова abort
const mockFetch = vi.fn((_url: string, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(init.signal?.reason);
});
});
});
const fetchPromise = fetchWithTimeout('https://api.example.com/data', 2000, mockFetch as typeof fetch);
// Прокручиваем время на 2000 мс вперед
await vi.advanceTimersByTimeAsync(2000);
await expect(fetchPromise).rejects.toThrow('Запрос прерван по таймауту');
});
it('успешно возвращает данные и очищает таймер, если ответ получен вовремя', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: 'success' }),
} as Response);
const result = await fetchWithTimeout<{ status: string }>(
'https://api.example.com/data',
5000,
mockFetch as typeof fetch
);
expect(result).toEqual({ status: 'success' });
// Проверяем, что в очереди не осталось висящих активных таймеров
expect(vi.getTimerCount()).toBe(0);
});
});vi.setSystemTime()#При тестировании купонов, подписок или лицензионных ключей важно фиксировать время для детерминированного выполнения проверок.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
export interface Subscription {
expiresAt: string; // ISO 8601
status: 'active' | 'cancelled';
}
export function isSubscriptionValid(sub: Subscription): boolean {
if (sub.status !== 'active') {
return false;
}
const now = new Date();
const expiry = new Date(sub.expiresAt);
return expiry.getTime() > now.getTime();
}
describe('isSubscriptionValid с системными часами', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('подтверждает активность подписки до наступления момента экспирации', () => {
// Устанавливаем системное время: 1 июня 2026 12:00:00 UTC
vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z'));
const sub: Subscription = {
status: 'active',
expiresAt: '2026-06-01T12:00:01.000Z', // Истекает через 1 секунду
};
expect(isSubscriptionValid(sub)).toBe(true);
// Сдвигаем системное время на 2 секунды вперед
vi.advanceTimersByTime(2000);
expect(isSubscriptionValid(sub)).toBe(false);
});
});Что произойдет при вызове vi.runAllTimers() в сценарии, где компонент запускает поллинг сервера через setInterval или рекурсивный setTimeout?
Если код запускает периодический интервал или рекурсивный setTimeout (например, поллинг состояния), вызов vi.runAllTimers() приведет к бесконечному циклу и падению теста по таймауту.
// ❌ Ошибка: бесконечный цикл планировщика
setInterval(pollServer, 1000);
vi.runAllTimers(); // Зависание теста: интервал планирует сам себя бесконечно!
// ✅ Правильное решение: использование vi.runOnlyPendingTimers()
// или явный сдвиг времени на заданный интервал
vi.runOnlyPendingTimers(); // Выполняет только уже зарегистрированные в очереди задачиЕсли после завершения теста не вернуть реальные системные таймеры, последующие тесты в том же потоке (например, интеграционные тесты с реальными сетевыми задержками или React Testing Library waitFor) зависнут навсегда.
// ❌ Ошибка: утечка фейковых таймеров на другие тесты файла
it('тест с таймерами', () => {
vi.useFakeTimers();
// ... без возврата в исходное состояние
});
// ✅ Правильное решение: гарантированное восстановление в afterEach
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});Когда таймер запускает цепочку async/await или промисов, вызов синхронного vi.advanceTimersByTime завершается до того, как Event Loop успевает разрешить микротаски, что приводит к ложным падениям expect.
// ❌ Ошибка: промис внутри таймера еще не успел выполниться
setTimeout(async () => {
await fetchUserProfile();
state.isLoaded = true;
}, 500);
vi.advanceTimersByTime(500);
expect(state.isLoaded).toBe(true); // FAILED! Микротаска промиса еще не выполнена
// ✅ Правильное решение: асинхронный сдвиг с дренированием очереди микротасок
await vi.advanceTimersByTimeAsync(500);
expect(state.isLoaded).toBe(true); // PASSED!Неочищенные таймеры в хуках useEffect продолжают удерживать ссылки на функции в памяти Node.js воркера, вызывая деградацию производительности тестового раннера.
// ✅ Проверка отсутствия утечек таймеров после размонтирования компонента
it('очищает таймер при анмаунте', () => {
const { unmount } = render(<AutoRefreshWidget />);
expect(vi.getTimerCount()).toBe(1);
unmount();
expect(vi.getTimerCount()).toBe(0); // Таймер был корректно очищен в cleanup-функции
});