Añades a la interfaz un componente que usa ResizeObserver (por ejemplo, una tabla con una barra de desplazamiento horizontal duplicada) y, de golpe, fallan muchos tests que no tienen relación entre sí:
FAIL src/TablaConScroll.test.tsx > TablaConScroll > muestra las filas
ReferenceError: ResizeObserver is not defined
❯ src/TablaConScroll.tsx:10:22
10| const observer = new ResizeObserver(() => setAncho(el.scrollWidth));
El componente de ejemplo mide su contenedor al montarse:
// src/TablaConScroll.tsx
import { useEffect, useRef, useState } from 'react';
export function TablaConScroll({ filas }: { filas: string[] }) {
const contenedor = useRef<HTMLDivElement>(null);
const [ancho, setAncho] = useState(0);
useEffect(() => {
const el = contenedor.current;
if (!el) return;
const observer = new ResizeObserver(() => setAncho(el.scrollWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={contenedor} data-ancho={ancho}>
<table>
<tbody>
{filas.map((f) => (
<tr key={f}><td>{f}</td></tr>
))}
</tbody>
</table>
</div>
);
}
Funciona en el navegador, y los tests que no lo renderizan pasan. Este artículo explica por qué falla solo bajo jsdom y cómo resolverlo una vez, para toda la suite, con un archivo de preparación de Vitest.
Por qué pasa
jsdom es una implementación de la plataforma web para Node.js, pero no incluye todas las APIs de un navegador. Su propio README aclara que no calcula el diseño de la página (layout) y que devuelve ceros en muchas propiedades relacionadas, como getBoundingClientRect(). Un observador de tamaño necesita justo ese cálculo, y en el repositorio de jsdom, al escribir este artículo, la solicitud para implementar ResizeObserver seguía abierta como propuesta de nueva funcionalidad.
En la comprobación con Vitest 3.2.4 y jsdom 30.0.1, tanto typeof ResizeObserver como typeof IntersectionObserver devolvieron undefined, y getBoundingClientRect().width, scrollWidth y offsetWidth valían 0.
El error se multiplica porque el componente crea el observador dentro de un useEffect, que se ejecuta al montarlo. Cualquier test que lo renderice, directamente o dentro de una página o un layout que lo incluya, falla antes de llegar a sus aserciones.
Solución
-
Crea un archivo de preparación con un mock mínimo. La clase solo necesita los métodos que invoca tu código (aquí
observeydisconnect); se incluyen los tres habituales por si otro componente usaunobserve. El operador??=asigna únicamente si el global no existe, así que no pisa la implementación real en entornos que sí la tengan:// vitest.setup.ts class ResizeObserverMock { observe() {} unobserve() {} disconnect() {} } class IntersectionObserverMock { readonly root = null; readonly rootMargin = ''; readonly thresholds: number[] = []; observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= ResizeObserverMock as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= IntersectionObserverMock as unknown as typeof IntersectionObserver; -
Regístralo en
test.setupFiles. Según la documentación de Vitest, la ruta se resuelve respecto a la raíz del proyecto y los archivos se ejecutan antes de cada archivo de tests, en el mismo proceso:// vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], }, });Si tu configuración de Vitest vive dentro de
vite.config.ts, la clavetestse escribe igual en ese archivo. -
Cuando un test necesite controlar el observador, reemplaza el global solo en ese test con
vi.stubGlobaly restáuralo después. Esta función está pensada para variables globales que no existen enjsdomonode; Vitest no las restablece por sí solo, así que llama avi.unstubAllGlobals()(o activa la opciónunstubGlobalsde la configuración):import { afterEach, expect, it, vi } from 'vitest'; import { act, cleanup, render } from '@testing-library/react'; import { TablaConScroll } from './TablaConScroll'; afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('reacciona cuando el observador notifica un cambio', () => { let notificar: () => void = () => {}; class ObservadorControlado { constructor(callback: ResizeObserverCallback) { notificar = () => callback([], this as unknown as ResizeObserver); } observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); } vi.stubGlobal('ResizeObserver', ObservadorControlado); vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(500); const { container } = render(<TablaConScroll filas={['uno']} />); act(() => notificar()); expect(container.firstElementChild?.getAttribute('data-ancho')).toBe('500'); }); -
No dependas de medidas reales. Como jsdom no hace diseño, las anchuras y alturas son 0 salvo que las simules, como en el ejemplo anterior con
scrollWidth. Los tests deben comprobar qué hace el componente cuando recibe una medida concreta, no cuánto mide en pantalla; para eso están las pruebas en un navegador real. -
Alternativa: un polyfill. Puedes instalar el paquete
resize-observer-polyfille importarlo en el archivo de preparación en lugar de escribir el mock. Ten presente que seguirá sin haber medidas reales, porque jsdom no calcula el diseño.
Cómo verificar que quedó resuelto
npx vitest run
La suite debe terminar sin ReferenceError. En la prueba, antes de añadir el archivo de preparación fallaba el test del componente (Tests 1 failed | 1 passed) y después pasaron todos los archivos. Como comprobación permanente puedes añadir un test mínimo que detecte si alguien quita el archivo de la configuración:
import { expect, it } from 'vitest';
it('el entorno de tests define los observadores', () => {
expect(typeof ResizeObserver).toBe('function');
expect(typeof IntersectionObserver).toBe('function');
});
Variantes y errores relacionados
IntersectionObserver is not defined
Es el mismo caso: jsdom tampoco lo implementa, y el archivo de preparación de la solución 1 ya lo cubre. Si un test necesita simular que un elemento entra en pantalla, usa vi.stubGlobal con una clase que guarde el callback y llámalo a mano, igual que en el ejemplo del ResizeObserver.
window.matchMedia is not a function y scrollIntoView is not a function
En jsdom 30.0.1, typeof window.matchMedia y typeof Element.prototype.scrollIntoView también fueron undefined. Se resuelven con el mismo patrón:
if (typeof window !== 'undefined') {
window.matchMedia ??= ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
Element.prototype.scrollIntoView ??= function () {};
}
La condición typeof window !== 'undefined' importa: los archivos de setupFiles se ejecutan también en los archivos de tests que usan el entorno node, donde window no existe.
El error viene de una librería, no de tu código
Componentes de terceros (gráficos, listas virtualizadas, menús desplegables) suelen crear observadores internamente. La traza apunta entonces a node_modules, pero la solución es la misma: definir el global antes de que se monte el componente.
Cuidado con ocultar un fallo real
Un mock vacío hace que el test pase aunque el componente no reaccione a los cambios de tamaño. Si esa lógica es importante, cúbrela con un test como el de la solución 3, que dispara el callback a mano y comprueba el resultado.
Resumen
- jsdom no implementa
ResizeObserverniIntersectionObserver; cualquier componente que los use al montarse rompe todos los tests que lo rendericen. - Define un mock con
observe,unobserveydisconnecten un archivo registrado entest.setupFiles, conglobalThis.ResizeObserver ??= .... - Para controlar el observador en un test concreto, usa
vi.stubGlobalyvi.unstubAllGlobals(). - En jsdom las medidas de diseño son 0: simula los valores que necesites y no escribas tests que dependan de tamaños reales.
Comentarios
Publicar un comentario