Асинхронний код у середовищі JavaScript виконується в межах однопотокової моделі циклу подій (Event Loop). Чітке розуміння пріоритетів черг є фундаментальною передумовою для написання стабільних тестів:
Promise.then, catch, finally), виклики queueMicrotask() та process.nextTick(). Черга мікротасок спорожнюється повністю до переходу до будь-якої іншої задачі чи рендерингу UI.setTimeout, setInterval, setImmediate), системний I/O, події інтерфейсу.Реальне очікування таймерів у тестах уповільнює пайплайни (наприклад, 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) |
| Миттєвий зсув віртуального |
| годинника без реальної паузи|
+-------------------------------+AbortError / TimeoutError при перевищенні ліміту очікування з використанням 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);
});
});Якщо код запускає нескінченний інтервал або рекурсивний setTimeout (наприклад, фоновий поллінг сервера), виклик vi.runAllTimers() призведе до нескінченного циклу і падіння тесту за таймаутом.
// ❌ Помилка: нескінченний цикл планувальника
setInterval(pollServer, 1000);
vi.runAllTimers(); // Зависання тесту: інтервал нескінченно планує сам себе!
// ✅ Правильне рішення: використання vi.runOnlyPendingTimers()
// або дискретний зсув віртуального часу
vi.runOnlyPendingTimers(); // Виконує тільки вже зареєстровані в черзі задачі
// або
await vi.advanceTimersByTimeAsync(1000); // Зсув на один крокЯкщо після завершення тесту не повернути реальні системні таймери, наступні тести в тому ж воркері (наприклад, інтеграційні тести з реальними мережевими затримками або 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 продовжують утримувати замикання в пам'яті воркера, знижуючи швидкість виконання тестів та провокуючи непередбачувані падіння.
// ✅ Перевірка відсутності витоків таймерів після розмонтування компонента
it('очищає таймер при розмонтуванні', () => {
const { unmount } = render(<AutoRefreshWidget />);
expect(vi.getTimerCount()).toBe(1);
unmount();
expect(vi.getTimerCount()).toBe(0); // Таймер було коректно знято в cleanup-функції
});Викликати vi.useFakeTimers() з опцією toFake за потреби вибіркової заміни системних функцій.
Ініціювати операцію (клік користувача, запуск поллінгу, виклик debounce-функції).
Перемістити віртуальний час через await vi.advanceTimersByTimeAsync(ms), дозволяючи черзі мікротасок виконатися.
Перевірити інваріанти, упевнитися в очищенні таймерів за допомогою vi.getTimerCount() і викликати vi.useRealTimers().