La ejecución asíncrona en JavaScript opera bajo el modelo de bucle de eventos monohilo (Event Loop). Comprender con precisión la jerarquía de colas resulta imprescindible para construir pruebas confiables y libres de condiciones de carrera:
Promise.then, catch, finally), queueMicrotask() y process.nextTick(). La cola de microtareas se vacía de forma exhaustiva antes de ceder el control a cualquier macrotarea o ciclo de renderizado.setTimeout, setInterval, setImmediate), operaciones de I/O y eventos de interfaz.Esperar el transcurso del tiempo real durante las pruebas penaliza gravemente la velocidad en CI (por ejemplo, esperar 5 segundos para comprobar la expiración de una sesión demoraría los pipelines). Vitest suprime esta latencia mediante Fake Timers (Temporizadores simulados).
+-------------------------------------------------------------+
| JavaScript Event Loop |
+-------------------------------------------------------------+
|
+----------------------------+----------------------------+
| |
v v
+-------------------------------+ +-------------------------------+
| Microtask Queue | | Macrotask Queue |
| - Promise.then / await | | - setTimeout / setInterval |
| - queueMicrotask | | - setImmediate / I/O |
| (Se vacía por completo | | (Interceptado por Vitest |
| antes de cada macrotarea) | | Fake Timers Engine) |
+-------------------------------+ +-------------------------------+
|
v
+-------------------------------+
| vi.advanceTimersByTime(ms) |
| Avance instantáneo del reloj|
| virtual sin pausa física |
+-------------------------------+AbortError / TimeoutError empleando AbortController y AbortSignal.timeout().vi.setSystemTime() para comprobar la expiración de tokens JWT, cupones de descuento o devengos de suscripción.setInterval huérfanos que retienen componentes en memoria tras su desmontaje.vi.advanceTimersByTimeAsync#Analicemos una implementación tipada de debounce. Dado que el callback puede retornar una promesa o desencadenar tareas asíncronas, empleamos 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 con temporizadores virtuales', () => {
beforeEach(() => {
// Interceptamos temporizadores nativos antes de cada prueba
vi.useFakeTimers();
});
afterEach(() => {
// Restauración incondicional de los temporizadores nativos
vi.useRealTimers();
});
it('invoca la función objetivo exactamente una vez tras expirar la demora', async () => {
const targetFn = vi.fn();
const debouncedFn = debounce(targetFn, 300);
// Tres invocaciones sucesivas e inmediatas
debouncedFn('alpha');
debouncedFn('beta');
debouncedFn('gamma');
// A los 299 ms la función aún no debe haberse ejecutado
await vi.advanceTimersByTimeAsync(299);
expect(targetFn).not.toHaveBeenCalled();
// Avanzamos 1 ms adicional (300 ms en total)
await vi.advanceTimersByTimeAsync(1);
expect(targetFn).toHaveBeenCalledTimes(1);
expect(targetFn).toHaveBeenCalledWith('gamma');
});
it('reinicia el temporizador si se reciben nuevos argumentos antes de expirar el lapso', async () => {
const targetFn = vi.fn();
const debouncedFn = debounce(targetFn, 500);
debouncedFn('initial');
await vi.advanceTimersByTimeAsync(300); // Han transcurrido 300 ms
// Una nueva llamada reinicia la ventana de espera
debouncedFn('updated');
await vi.advanceTimersByTimeAsync(300); // 300 ms desde la segunda llamada (600 ms en total)
expect(targetFn).not.toHaveBeenCalled();
// Completamos los 200 ms restantes
await vi.advanceTimersByTimeAsync(200);
expect(targetFn).toHaveBeenCalledTimes(1);
expect(targetFn).toHaveBeenCalledWith('updated');
});
});AbortController#Las arquitecturas web contemporáneas aprovechan la API AbortController para abortar peticiones zombis ante transiciones de ruta o caídas por timeout.
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('Petición cancelada por tiempo límite', '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 {
// Saneamiento obligatorio para erradicar temporizadores colgados
clearTimeout(timeoutId);
}
}
describe('fetchWithTimeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('aborta peticiones de red estancadas al expirar el límite estipulado', async () => {
// Simulación de red que permanece abierta indefinidamente hasta recibir la señal de aborto
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);
// Adelantamos el reloj virtual 2000 ms
await vi.advanceTimersByTimeAsync(2000);
await expect(fetchPromise).rejects.toThrow('Petición cancelada por tiempo límite');
});
it('retorna la información y desarma el temporizador si la respuesta llega a tiempo', 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' });
// Verificamos que no queden temporizadores activos huérfanos en la cola
expect(vi.getTimerCount()).toBe(0);
});
});vi.setSystemTime()#Validar planes de suscripción, periodos de prueba o claves de licencia requiere fijar el reloj en un punto determinista.
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 con fijación del reloj del sistema', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('confirma el estado activo de la suscripción antes del instante de expiración', () => {
// Congelamos la hora del sistema: 1 de junio de 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', // Expira en 1 segundo
};
expect(isSubscriptionValid(sub)).toBe(true);
// Adelantamos el tiempo virtual 2 segundos
vi.advanceTimersByTime(2000);
expect(isSubscriptionValid(sub)).toBe(false);
});
});Cuando el código evaluado programa un intervalo recurrente o un setTimeout autorreferencial (como en sondeos continuos), invocar vi.runAllTimers() desencadena un bucle infinito que bloquea el worker hasta agotar el timeout global.
// ❌ Error: bucle infinito en el planificador de pruebas
setInterval(pollServer, 1000);
vi.runAllTimers(); // Bloqueo: el intervalo continúa reprogramándose indefinidamente
// ✅ Solución recomendada: emplear vi.runOnlyPendingTimers()
// o avanzar el reloj mediante saltos discretos
vi.runOnlyPendingTimers(); // Ejecuta exclusivamente las tareas encoladas al invocar
// o
await vi.advanceTimersByTimeAsync(1000); // Avanza un periodo controladoNo restaurar los relojes nativos contamina los siguientes archivos ejecutados en el mismo worker: cualquier prueba con esperas reales de red o utilidades como waitFor de React Testing Library quedará suspendida indefinidamente.
// ❌ Error: propagación de temporizadores simulados a otras pruebas
it('prueba con temporizador', () => {
vi.useFakeTimers();
// ... omisión de limpieza
});
// ✅ Solución recomendada: saneamiento estricto en afterEach
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});Si el temporizador desencadena una función async/await o una cadena de promesas, el método sincrónico vi.advanceTimersByTime finaliza antes de que el Event Loop procese la cola de microtareas, arrojando aserciones fallidas erróneas.
// ❌ Error: la microtarea interna aún no ha culminado
setTimeout(async () => {
await fetchUserProfile();
state.isLoaded = true;
}, 500);
vi.advanceTimersByTime(500);
expect(state.isLoaded).toBe(true); // ¡FALLA! La microtarea de la promesa sigue en cola
// ✅ Solución recomendada: avance asincrónico que vacía las microtareas
await vi.advanceTimersByTimeAsync(500);
expect(state.isLoaded).toBe(true); // ¡PASA!Intervalos desatendidos dentro de hooks useEffect retienen referencias y clausuras en memoria, degradando el rendimiento general de la suite y ocasionando fallos intermitentes.
// ✅ Comprobación de limpieza de temporizadores al desmontar el componente
it('desarma el intervalo al desmontarse', () => {
const { unmount } = render(<AutoRefreshWidget />);
expect(vi.getTimerCount()).toBe(1);
unmount();
expect(vi.getTimerCount()).toBe(0); // El temporizador se canceló adecuadamente en el cleanup
});Invocar vi.useFakeTimers() especificando configuraciones toFake si se requiere interceptar selectivamente primitivas.
Disparar la operación que programa trabajo asíncrono (clic de usuario, despacho de debounce o inicio de sondeo).
Adelantar el tiempo virtual con await vi.advanceTimersByTimeAsync(ms), permitiendo la resolución de microtareas.
Comprobar invariantes, asegurar la ausencia de temporizadores con vi.getTimerCount() y restituir vi.useRealTimers().