Un test pasa solo y falla cuando se ejecuta después de otro del mismo archivo, con este mensaje de Testing Library:
TestingLibraryElementError: Found multiple elements with the text: uno (favorito)
Here are the matching elements:
[...]
(If this is intentional, then use the `*AllBy*` variant of the query (like
`queryAllByText`, `getAllByText`, or `findAllByText`)).
En proyectos con Vitest, jsdom, Testing Library y Radix UI esa misma familia de síntomas tiene tres causas distintas que se confunden con facilidad: estado que se filtra entre tests (localStorage), un DropdownMenu que no abre con fireEvent.click y un getByRole('dialog') que encuentra más de un elemento. Cada una tiene su arreglo.
Por qué pasa
1. localStorage sobrevive entre tests del mismo archivo
Con el entorno jsdom, Vitest crea el entorno por archivo (la opción isolate está activada por defecto), pero los tests de un mismo archivo comparten el mismo window. El cleanup de Testing Library solo desmonta los árboles de React; no toca el almacenamiento. Si un hook guarda algo en window.localStorage, el siguiente test arranca con ese dato.
export function useFavoritos() {
const [favoritos, setFavoritos] = useState<string[]>(() =>
JSON.parse(window.localStorage.getItem('favoritos') ?? '[]'),
);
const agregar = (item: string) => {
const siguiente = [...favoritos, item];
window.localStorage.setItem('favoritos', JSON.stringify(siguiente));
setFavoritos(siguiente);
};
return { favoritos, agregar };
}
Si dos tests agregan 'uno', el segundo parte de ['uno'], agrega otro y renderiza dos <li> iguales. Ejecutado aislado, pasa; ejecutado después del primero, falla.
2. El DropdownMenu.Trigger de Radix no abre con click
El código del trigger en @radix-ui/react-dropdown-menu abre el menú en onPointerDown (botón principal, sin Ctrl) y en onKeyDown con Enter, espacio o flecha abajo. La documentación de Radix describe las mismas teclas. fireEvent.click solo despacha un evento click, así que el menú no se abre y las consultas por menuitem no encuentran nada.
3. Popover.Content también tiene role="dialog"
El contenido del Popover de Radix se renderiza con role="dialog" (así aparece en el DOM y en el código del paquete). Si en la misma pantalla hay un diálogo y un popover abiertos, getByRole('dialog') encuentra dos elementos y lanza el error de múltiples coincidencias.
Solución
-
Limpia el almacenamiento antes de cada test. Lo más cómodo es un archivo de preparación común:
// vitest.setup.ts import { afterEach, beforeEach } from 'vitest'; import { cleanup } from '@testing-library/react'; beforeEach(() => { window.localStorage.clear(); window.sessionStorage.clear(); }); afterEach(() => { cleanup(); }); // vitest.config.ts // test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'] }Si solo un archivo lo necesita, basta
beforeEach(() => window.localStorage.clear())dentro de ese archivo. -
Encuentra otras dependencias de orden. La documentación de Vitest indica que
--sequence.shuffleejecuta archivos y tests en orden aleatorio y sirve para detectar tests que dependen de otro ejecutado antes. La semilla permite repetir un orden concreto:npx vitest run --sequence.shuffle npx vitest run --sequence.shuffle --sequence.seed=3En el ejemplo anterior, cambiar la semilla cambiaba cuál de los dos tests fallaba: siempre el que se ejecutaba segundo.
-
Abre el menú como lo haría una persona. Con
@testing-library/user-event, que simula la interacción completa (varios eventos, incluidos los de puntero), o con una tecla:import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; it('abre el menú con user-event', async () => { const user = userEvent.setup(); render(<Menu />); await user.click(screen.getByRole('button', { name: 'Acciones' })); expect(await screen.findByRole('menuitem', { name: 'Editar' })).toBeTruthy(); }); it('abre el menú con el teclado', async () => { render(<Menu />); fireEvent.keyDown(screen.getByRole('button', { name: 'Acciones' }), { key: 'ArrowDown', }); expect(await screen.findByRole('menuitem', { name: 'Editar' })).toBeTruthy(); });La documentación de Testing Library recomienda
user-eventen la mayoría de los casos yuserEvent.setup()antes de renderizar. UsafindBy, que espera a que el elemento aparezca, porque el menú se renderiza en un portal después del evento. -
Consulta el diálogo correcto. Filtra por nombre accesible. Si no puedes, usa un
data-testiden el contenido, que la documentación de Testing Library deja como último recurso:screen.getByRole('dialog', { name: 'Editar pedido' }); // o, para el popover const popover = screen.getByTestId('filtros-popover'); within(popover).getByText('Contenido de filtros');
Cómo verificar que quedó resuelto
npx vitest run
npx vitest run --sequence.shuffle --sequence.seed=1
npx vitest run --sequence.shuffle --sequence.seed=2
npx vitest run -t "nombre del test"
La suite debe pasar con varias semillas, y el test que fallaba debe pasar tanto solo (-t) como dentro del archivo completo.
Variantes y errores relacionados
Sin globals: true no hay limpieza automática
La documentación de Testing Library indica que el cleanup se ejecuta solo si el framework ofrece un afterEach global. En Vitest, ese afterEach global existe con globals: true. En una prueba con globals desactivado y sin cleanup(), el segundo test falló con Found multiple elements with the role "button" porque seguía montado el árbol del primero; con globals: true pasó. Importa cleanup y llámalo en afterEach, como en el archivo de preparación anterior.
ResizeObserver is not defined
En la versión de jsdom de la prueba, ResizeObserver no existe. Si un componente (de Radix o de otra librería) lo usa y el test falla con ese error, un stub mínimo en el archivo de preparación suele bastar:
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
Aislamiento desactivado
La opción isolate está activada por defecto. Si la desactivas (isolate: false o --no-isolate), se pierde el aislamiento entre archivos y el estado también puede filtrarse de un archivo a otro.
Otro estado global
El mismo patrón aplica a cookies, sessionStorage, variables de módulo y cualquier singleton: si un test lo modifica, el siguiente lo hereda. Restablécelos en beforeEach.
Resumen
- Los tests de un mismo archivo comparten
window: limpialocalStorageysessionStorageenbeforeEach. - Detecta dependencias de orden con
--sequence.shuffley reprodúcelas con--sequence.seed. - El trigger de Radix abre con puntero o teclado, no con
click: usauser-eventofireEvent.keyDown. Popover.Contenttienerole="dialog": consulta por nombre accesible o, como último recurso, pordata-testid.- Sin
globals: true, llama acleanup()tú mismo enafterEach.
Comentarios
Publicar un comentario