Vitest: "Found multiple elements" por localStorage y Radix

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

  1. 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.

  2. Encuentra otras dependencias de orden. La documentación de Vitest indica que --sequence.shuffle ejecuta 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=3

    En el ejemplo anterior, cambiar la semilla cambiaba cuál de los dos tests fallaba: siempre el que se ejecutaba segundo.

  3. 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-event en la mayoría de los casos y userEvent.setup() antes de renderizar. Usa findBy, que espera a que el elemento aparezca, porque el menú se renderiza en un portal después del evento.

  4. Consulta el diálogo correcto. Filtra por nombre accesible. Si no puedes, usa un data-testid en 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: limpia localStorage y sessionStorage en beforeEach.
  • Detecta dependencias de orden con --sequence.shuffle y reprodúcelas con --sequence.seed.
  • El trigger de Radix abre con puntero o teclado, no con click: usa user-event o fireEvent.keyDown.
  • Popover.Content tiene role="dialog": consulta por nombre accesible o, como último recurso, por data-testid.
  • Sin globals: true, llama a cleanup() tú mismo en afterEach.

Referencias

Comentarios