Tag: Vitest

  • Tests unitarios lentos: el número de Vitest que casi nadie mira

    Tests unitarios lentos: el número de Vitest que casi nadie mira

    Nuestro job de tests en CI tardaba doce minutos clavados. Setecientos siete ficheros, cuatro mil cuatrocientos tests. Nadie lo cuestionaba: una suite grande tarda, y punto.

    Hoy lo hemos dejado en seis minutos y quince segundos. Sin borrar un solo test, sin runners más caros, sin paralelizar nada. Solo cambiando qué entorno arranca cada fichero.

    Y lo interesante no es el 48 % que nos ahorramos. Es que llevábamos meses con tests unitarios lentos mirando el número equivocado.

    El número equivocado es el total. El total te dice que tienes un problema, pero no te dice dónde se va el tiempo. Y sin el dónde, optimizar es tirar cosas a la pared: cambias el entorno, subes los threads, añades runners, y a veces sale bien y a veces sale peor y nunca sabes por qué.

    Vitest te da el dónde al final de cada ejecución. Lo tienes impreso en tu terminal ahora mismo.


    Resumen rápido

    • El total del Duration te dice que tienes tests unitarios lentos, no dónde se va el tiempo. El desglose sí.
    • environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. Compáralo contra tests, nunca contra Duration.
    • En nuestra suite: 803,6 s de environment contra 156 s de tests. Cinco veces más en montar el escenario que en ejecutarlo.
    • La causa: environment: 'jsdom' global para 707 ficheros, de los que solo 208 tocan el DOM.
    • El fix: dos proyectos de Vitest, la extensión decide el entorno. .test.ts a node, .test.tsx a DOM. 12m → 6m 15s en CI.
    • Shardear no arregla esto: algo más de la mitad del tiempo es coste fijo de arranque, y cada shard lo vuelve a pagar entero.

    Tests unitarios lentos: el desglose que Vitest imprime y nadie lee

    Debajo del Duration hay un paréntesis con seis campos: transform, setup, collect, tests, environment y prepare.

    En nuestro caso, los dos que importan salían así:

    Duration  106.31s (transform …, setup …, collect …, tests 156.00s, environment 803.60s, prepare …)
    

    Recorto los campos que no vienen al caso. Fíjate en la contradicción aparente: el run entero duró 106 segundos, pero dice que gastó 803 en environment.

    No es un bug. environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. La suite corre en dieciséis workers y cada uno monta su propio entorno por fichero. La cifra que ves es la suma de todos ellos, así que puede ser más de siete veces mayor que el reloj de pared.

    La regla de lectura del desglose de Vitest es comparar acumulado contra acumulado: environment contra tests, nunca contra Duration. Duration es wall-clock; los otros dos son tiempos sumados entre ficheros y workers.

    Y ahí el número deja de ser abstracto: 803,6 segundos montando el escenario contra 156 ejecutando los tests. Cinco veces más en preparar que en actuar.

    Cuando environment multiplica varias veces a tests, el problema no son los tests lentos: es el arranque del entorno.


    707 ficheros arrancando un navegador, 208 usándolo

    El origen estaba en una línea del config: environment: 'jsdom', global, para los 707 ficheros.

    Conté los que tocaban el DOM de verdad. Eran 208. Los 499 restantes —parsers, cálculo de precios, mapeo de rutas de API, validadores— montaban un navegador falso entero para no usarlo jamás.

    Ese es el gasto que estábamos pagando cinco veces sobre el trabajo real. No era un problema de rendimiento del emulador. Era que la mitad larga de la suite no necesitaba emulador ninguno.

    La solución fue partir la suite en dos proyectos de Vitest con una regla que cabe en una frase: la extensión decide el entorno.

    El config, con Vitest 4.1.10 (julio de 2026). La clave test.projects sustituyó a workspace en Vitest 3.2, así que en versiones anteriores esto no aplica:

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    import react from '@vitejs/plugin-react'
    
    export default defineConfig({
      test: {
        projects: [
          {
            // Lógica pura: node pelado. Sin plugins, sin setup, sin DOM.
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.test.ts'],
            },
          },
          {
            // Componentes: DOM emulado + testing-library.
            plugins: [react()],
            test: {
              name: 'dom',
              environment: 'jsdom',
              include: ['src/**/*.test.tsx'],
              setupFiles: ['./src/test/setup-dom.ts'],
            },
          },
        ],
      },
    })
    

    Elegir la extensión como criterio no es cosmético. Con globs por carpeta o por sufijo (*.spec.ts y *.component.spec.ts) te toca mantener un exclude, porque el primer patrón se traga los ficheros del segundo y esos tests se ejecutan dos veces, una de ellas en el entorno equivocado y fallando por un motivo que parece un bug de tu código.

    .test.ts y .test.tsx no se solapan nunca. Cero exclude, cero ambigüedad, y una regla que un compañero nuevo entiende sin preguntar: si tu test importa JSX, es .tsx y tiene DOM.

    Si trabajas en Angular la idea es idéntica desde que Vitest es el runner por defecto. Cambian los globs, no el razonamiento.

    Después dimos el segundo paso: cambiar una palabra en el proyecto dom, de jsdom a happy-dom. En un A/B aislado sobre esos 208 ficheros, 48,9 s → 34,9 s. Unos catorce segundos. Útil, pero un orden de magnitud por debajo de lo que dio separar los entornos.

    La comparativa completa entre los dos emuladores, con benchmark y las APIs que le faltan a cada uno, la tengo aparte en el post sobre happy-dom o jsdom.

    El resultado de las dos cosas juntas:

    Ámbito Antes Después Δ
    Job Test en CI 12m 00s 6m 15s −48 %
    Suite local (16 cores) 106,3 s 46,0 s −57 %
    environment (acumulado entre ficheros) 803,6 s 137 s −83 %
    Ficheros / tests 707 / 4.400 707 / 4.400 sin cambios

    Mismos tests. Mismas aserciones. Misma cobertura.


    El efecto secundario: dos tests que llevaban meses mintiendo

    Al cambiar el entorno, dos tests empezaron a fallar.

    Y tenían razón.

    El patrón era este, y lo he visto en todos los proyectos en los que he entrado:

    vi.spyOn(global, 'fetch')
      .mockResolvedValueOnce(ok(productos))
      .mockResolvedValueOnce(ok(stock))
    

    Parece un mock. No lo es.

    vi.spyOn(global, 'fetch') sin implementación envuelve la función original y sigue llamándola. Lo único que intercepta son las respuestas que has encolado con mockResolvedValueOnce. Y esa cola se agota: la primera llamada recibe productos, la segunda stock, y la tercera sale a la red de verdad.

    Nuestro componente hacía tres llamadas.

    Llevaba meses pidiendo datos a localhost:3000 desde el runner de CI. jsdom se lo tragaba en silencio y el test seguía en verde. Con happy-dom la petición real quedó a la vista, y ahí aparecieron los 401.

    El arreglo son cuatro líneas, y es la clase de cosa que debería estar en el setup de cualquier suite:

    // setup-dom.ts
    import { beforeEach, vi } from 'vitest'
    
    beforeEach(() => {
      vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear'))
    })
    

    Un default que revienta. Si un test necesita una respuesta, la encola encima; si se le olvida una llamada, el test falla con un mensaje que dice exactamente qué pasó, en vez de irse a internet a buscar suerte.

    Añade también restoreMocks: true en el config: mockRejectedValue en un beforeEach no vacía la cola de ...Once que haya dejado el test anterior, y esa cola sobrante es una fuga entre tests igual de silenciosa que la que acabas de tapar.

    Yo esto ya no lo discuto: un test que llega a la red no es un test unitario, es una apuesta. Es lento, es flaky, y depende del firewall del runner. Diseñar los mocks para que el hueco falle ruidosamente en vez de degradar en silencio es la mitad del trabajo de testear bien, y es la parte que más tiempo dedico a explicar en el curso de Testing en Angular.

    Nadie planea encontrar estos bugs. Aparecen cuando tocas los cimientos.


    ¿Merece la pena shardear los tests unitarios? Los números dijeron que no

    Nos dio un 27 % a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que en unitarios la mayor parte del tiempo es arranque compartido, y repartirlo no lo divide: lo multiplica. Así llegamos ahí.

    Semanas antes habíamos partido la suite de E2E en shards concurrentes y el wall-clock se había desplomado. Fue de esas victorias que te dejan con ganas de repetir.

    Así que la pregunta era obvia: si funcionó con E2E, ¿por qué no con los unitarios?

    Los números decían que sí. De los 375 segundos del job, solo 31 eran setup —checkout, pnpm install, build de las librerías internas—, un 8 %. Con un overhead fijo tan bajo, repartir en tres debería habernos dejado en torno a los dos minutos y medio. Una mejora del orden del 60 %.

    Abrimos el PR, lo lanzamos, y esto es lo que salió:

    Job Tiempo
    Test (1) 4m 19s
    Test (2) 4m 32s ← wall-clock
    Test (3) 3m 57s
    Test (otros paquetes) 1m 38s

    375 s → 272 s. Un 27 %, a cambio de cuatro runners en vez de uno.

    Volvimos al desglose, que es lo que había que haber hecho antes de escribir el PR:

    Ámbito Ficheros Tiempo de tests
    Job completo 707 333 s
    Un shard 236 226 s

    Léelo despacio. Un tercio de los ficheros tarda el 68 % de lo que tardan todos.

    Si ajustas una recta T(n) = F + n·v con esos dos puntos, sale un coste fijo F de unos 172 segundos y una pendiente de 0,23 segundos por fichero. Traducido: de los 333 segundos, algo más de la mitad es peaje que pagas antes de ejecutar un solo test. Solo unos 160 dependen de cuántos ficheros tengas.

    Y aquí toca ser honesto con el método: dos puntos y dos incógnitas significa que la recta pasa por ambos por construcción. Es aritmética, no un perfilado. Te da el orden de magnitud del reparto entre lo fijo y lo variable, que es justo lo que necesitas para decidir, pero no lo cites como si fuera una medida.

    Ese coste fijo es transform más importación del grafo de dependencias, en frío, sin caché de Vite en el runner. Y cada shard lo paga entero, otra vez, desde cero.

    Con 3 shards pagas ese peaje tres veces. Con 6, seis. No hace falta el modelo para verlo: el shard más rápido de los tres, con 236 ficheros en vez de 707, todavía tardó 3m 57s. Por muchos runners que enchufes, el suelo se queda en unos cuatro minutos.

    La lección de E2E no transfería, y visto desde aquí es evidente. En Playwright cada test es trabajo independiente de navegador: repartir divide de verdad. En unitarios, la mayor parte del tiempo es arranque compartido, y repartir trabajo compartido no lo divide, lo multiplica.

    El sharding no era la palanca. La palanca era eliminar los ~172 segundos de coste fijo que cada shard vuelve a pagar entero.


    El PR sigue abierto, y creo que así está bien

    El PR #36 no está mergeado ni cerrado. Un 27 % por 4x runners es un trade flojo, y las tres opciones siguen sobre la mesa:

    • Cerrarlo. Los cien segundos no compensan cuadruplicar el consumo de CI ni la complejidad de un job matricial.
    • Bajarlo a 2 shards. Menos ganancia, la mitad de coste, y sospecho que el punto donde la curva todavía compensa.
    • Aparcarlo y atacar el coste fijo. Cachear node_modules/.vite entre runs, si esa caché existe en tu setup, porque hoy se reconstruye en frío cada vez. Si funciona, mejora los tres escenarios a la vez, incluido el de un solo runner.

    La tercera es la que tiene mejor pinta, y precisamente por eso no quiero decidirla con la misma prisa con la que abrimos el PR. Primero medir el arranque en caliente, después decidir.


    Qué hacer hoy si tienes tests unitarios lentos

    1. Lanza tus tests y mira el paréntesis del final. Solo eso.
    2. Compara environment con tests. Los dos son sumas acumuladas entre ficheros, así que la división tiene sentido. Si environment es el doble de tests, ya sabes dónde está tu problema, y no es donde llevas semanas buscándolo.
    3. Cuenta cuántos ficheros importan JSX o tocan document de verdad. En nuestro caso eran 208 de 707. En el tuyo probablemente sea una proporción parecida, porque un catálogo, un carrito o un dashboard tienen mucha más lógica que pintura.
    4. Separa por extensión. .test.ts a node, .test.tsx a DOM. Veinte minutos de trabajo, cero riesgo, ningún test tocado.

    La tesis de todo esto no es "usa happy-dom" ni "shardea tus tests". Es que medir el coste correcto —no el tiempo total, sino en qué se va— convierte una optimización a ciegas en un cambio de config de veinte líneas. El mismo desglose que nos quitó seis minutos nos evitó después tirar cuatro runners a un problema que no era de paralelismo.

    Y que de vez en cuando, al levantar los cimientos, encuentras un test que llevaba meses saliendo a internet sin que nadie se enterara.

    Si quieres ver este tipo de decisiones tomadas sobre proyectos reales, con los runs y los números delante en vez de con opiniones, es lo que hacemos en Dominicode Labs.


    Preguntas frecuentes sobre tests unitarios lentos

    ¿Por qué mis tests unitarios son lentos si cada test tarda milisegundos?

    Casi siempre porque el tiempo no se va en ejecutar los tests, sino en preparar el entorno de cada fichero. En nuestra suite, el desglose de Vitest daba 803,6 segundos acumulados en environment frente a 156 en tests: cinco veces más en montar el escenario que en actuar. Mientras esa proporción esté desequilibrada, optimizar aserciones o subir el número de threads no te va a dar nada: estarías acelerando la parte pequeña.

    ¿Qué significa environment en el resumen de Vitest?

    Es el tiempo dedicado a instanciar el entorno de test (jsdom, happy-dom o node) para cada fichero, sumado entre todos los workers. Es tiempo acumulado entre ficheros y workers, no wall-clock, y por eso puede ser mucho mayor que el Duration total: nosotros teníamos 803,6 segundos de environment en un run de 106,3 segundos corriendo sobre dieciséis workers. La comparación que tiene sentido es environment contra tests, porque ambas cifras están acumuladas de la misma forma.

    ¿Cómo separo los tests que necesitan DOM de los que no en Vitest?

    Con test.projects en vitest.config.ts: un proyecto con environment: 'node' para la lógica pura y otro con DOM emulado, plugin del framework y setupFiles para los tests de componente. Lo que mejor nos ha funcionado es decidir por extensión, .test.ts contra .test.tsx, porque son globs que no se solapan y no necesitas exclude. Si separas por carpeta o por sufijo compuesto, un mismo fichero puede caer en los dos proyectos y ejecutarse dos veces, una de ellas en el entorno equivocado.

    ¿Merece la pena shardear los tests unitarios en CI?

    Depende de qué proporción de tu tiempo sea coste fijo de arranque, y hay que medirlo antes de abrir el PR. En nuestro caso, tres shards dieron un 27 % de mejora a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que cada shard vuelve a pagar entero el transform y la importación del grafo de dependencias, así que ese coste no se reparte, se multiplica. Con E2E la historia es distinta porque cada test es trabajo independiente de navegador y repartir sí divide.

    ¿Por qué vi.spyOn(global, 'fetch') no mockea mis llamadas?

    Porque spyOn sin implementación envuelve la función original y sigue llamándola. Solo intercepta las respuestas que hayas encolado con mockResolvedValueOnce, y esa cola se agota: en cuanto tu código hace una llamada más de las que encolaste, esa petición sale a la red de verdad. El arreglo es poner siempre un default que falle, con vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear')) en el setup, y encolar las respuestas concretas encima en cada test.

    ¿Esto aplica igual en Angular?

    Sí, y desde Angular 21 y 22 aún más, porque Vitest pasó a ser el runner por defecto y el entorno DOM dejó de ser una decisión implícita del builder de Karma. Cambian los globs, que serán .spec.ts con algún criterio propio para distinguir tests de componente de tests de servicio, pero el diagnóstico es idéntico: mira el desglose de environment, cuenta cuántos specs necesitan document y manda el resto a node.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • happy-dom o jsdom: qué entorno DOM elegir en tests unitarios

    happy-dom o jsdom: qué entorno DOM elegir en tests unitarios

    Cambié a happy-dom la mitad de la suite que toca el DOM, un martes por la mañana. Era la última pieza de un cambio que dejó el job de CI en 6m 15s, desde los doce minutos que tardaba esa misma mañana. Me sentí muy listo.

    Dos semanas después, un componente de lazy loading llegó roto a producción. El test seguía en verde. Lo ejecuté cincuenta veces y cincuenta veces me dijo que todo estaba bien.

    El problema no era el test. Era el suelo sobre el que corría. Elegir entre happy-dom o jsdom no es una micro-optimización de CI: es decidir qué mentiras está autorizada a contarte tu suite.

    Con jsdom, ResizeObserver no existe. El test revienta con un error escandaloso, instalas un mock, el mock dispara el callback y el test comprueba algo de verdad. Con happy-dom, ResizeObserver sí existe: es una clase que se instancia sin quejarse y cuyos tres métodos están vacíos por dentro. El callback no se llama jamás.

    Mi setup tenía una guarda del tipo if (typeof window.ResizeObserver === 'undefined') para instalar el mock. Con happy-dom esa condición no se cumplía nunca. El mock no se instalaba. El test verificaba el vacío.


    Resumen rápido

    • En tests unitarios, el runner (Vitest, Jest) no es el entorno. El entorno es la librería que emula el navegador debajo: jsdom, happy-dom o ninguna.
    • Vitest arranca por defecto en node, sin DOM. Jest también. El DOM siempre lo pides tú.
    • En mi benchmark, happy-dom resultó 1,77x más rápido que jsdom (mediana de 5 pares A/B, rango 1,45x–1,96x), no las 5x-10x que circulan por ahí.
    • Ninguno de los dos tiene motor de layout. getBoundingClientRect() devuelve ceros en ambos. Cambiar de entorno no arregla eso.
    • happy-dom cubre más superficie de API moderna que jsdom (matchMedia, showModal, scrollIntoView), pero incluye stubs mudos que fingen existir.
    • Regla: node por defecto, happy-dom para tests de componente, jsdom fichero a fichero cuando algo se rompa. Se mezclan en el mismo proyecto.

    El runner no es el entorno

    Esta confusión cuesta tardes enteras.

    Cuando escribes environment: 'jsdom', Vitest instancia un window completo por cada fichero de test y lo inyecta en el contexto global antes de importar tu código. El runner orquesta. El entorno es quien finge ser un navegador.

    Y por defecto no hay ninguno: Vitest arranca en node por defecto, sin document ni window. Jest hace lo mismo desde la versión 27, y desde la 28 ni siquiera trae jsdom — hay que instalar jest-environment-jsdom a mano.

    Angular es el caso más traicionero. Desde que Vitest se convirtió en el test runner por defecto en Angular 21, el builder @angular/build:unit-test detecta qué tienes instalado: si encuentra happy-dom lo usa, y si no, cae a jsdom. Basta con que alguien añada happy-dom al package.json por cualquier motivo para que toda tu suite cambie de suelo sin que nadie toque un fichero de configuración.

    Si vienes de Karma, ese cambio de suelo es la parte que menos se cuenta y más duele. Lo desarrollé en Vitest en Angular 22: por qué Karma ya no es el default.


    Qué es jsdom y qué es happy-dom, sin marketing

    jsdom es la implementación de referencia. Se publicó por primera vez en noviembre de 2011: casi quince años de historia y la base sobre la que se ha testeado medio ecosistema JavaScript. Su norma es la fidelidad a la especificación: si algo está implementado, se comporta como en el navegador; si no puede implementarlo bien, prefiere no implementarlo. Su documentación deja layout y navegación explícitamente fuera de alcance. Versión actual: 29.1.1, del 30 de abril de 2026. Nada nuevo desde entonces.

    happy-dom es un emulador con otra prioridad: arrancar rápido y cubrir lo que los frameworks modernos usan de verdad. Versión 20.11.1, del 22 de julio de 2026, con nueve versiones publicadas entre el 3 de junio y esa fecha.

    En disco: jsdom instala 25 MB y 21 dependencias directas; happy-dom, 19 MB y 7. La diferencia real es mucho menor de lo que sugieren las comparativas que verás por ahí.


    Benchmark happy-dom vs jsdom: lo medí en vez de citarlo

    Circulan cifras de "5x-10x más rápido" que nadie respalda. Monté la prueba, y publico los datos crudos para que puedas comprobar cada división.

    Metodología — benchmark ejecutado por Bezael Pérez (Dominicode) el 24 de julio de 2026:

    Carga 50 ficheros × 3 tests con DOM real: listas de 100 nodos, eventos con dispatchEvent, 200 mutaciones de clases y atributos
    Protocolo 5 pares de ejecuciones alternando A/B (jsdom, happy-dom, jsdom, happy-dom…) para anular la deriva de carga de la máquina
    CPU Intel i7-11700K, 16 hilos, 32 GB RAM, Windows 11
    Runtime Node 24.16.0
    Runner Vitest 4.1.10
    Entornos jsdom 29.1.1 · happy-dom 20.11.1

    Datos crudos. Tiempo total de suite, par a par:

    Par jsdom happy-dom Ventaja
    1 26,47 s 14,93 s 1,77x
    2 21,94 s 15,11 s 1,45x
    3 26,90 s 15,77 s 1,71x
    4 15,70 s 8,56 s 1,83x
    5 16,27 s 8,31 s 1,96x

    La ventaja de happy-dom es 1,77x, la mediana de esos cinco ratios.

    Ojo con un detalle que despista, porque yo mismo tropecé con él: la mediana de los tiempos de jsdom (21,94 s) dividida entre la mediana de los de happy-dom (14,93 s) da 1,47x. Pero esas dos medianas salen de pares distintos —la primera del par 2, la segunda del par 1— y dividirlas mezcla ejecuciones que no compartieron condiciones de máquina. En un diseño A/B emparejado, el estimador correcto es el ratio dentro de cada par, y su mediana es 1,77x.

    Con ese mismo criterio, el resto de métricas:

    Métrica jsdom (mediana) happy-dom (mediana) Ventaja por par
    Tiempo total de suite 21,94 s 14,93 s 1,77x
    Ejecución pura de los tests 4,29 s 1,74 s 2,5x
    Arranque del entorno (acumulado) 235,6 s 119,0 s 2,2x

    Y ahora el dato que de verdad cambia decisiones. Segunda suite, 50 ficheros con un único expect(1 + 1).toBe(2), que aísla el coste de levantar el entorno:

    Entorno Tiempo total Arranque por fichero
    node 1,55 s ~0,2 ms
    happy-dom 4,20 s ~0,75 s
    jsdom 7,71 s ~1,55 s

    Las dos tablas no miden lo mismo y no debes cruzarlas: el arranque acumulado de la primera incluye montar y desmontar un documento con cientos de nodos por fichero, con los 16 hilos saturados; la segunda mide levantar un DOM vacío. Compara cada tabla consigo misma.

    Dicho eso, léelo dos veces. Pasar de jsdom a happy-dom te da 1,8x. Pasar de jsdom a ningún DOM te da 5x.

    La optimización más rentable de tu suite no es cambiar de emulador. Es dejar de cargar un emulador en los tests que no tocan el DOM: reducers, servicios, validadores, utilidades puras. Esos no necesitan window, y probablemente son el 70% de tu suite.


    Dónde te rompe cada uno: qué APIs faltan en jsdom y en happy-dom

    Ejecuté el mismo fichero de sondeo en los dos entornos. Esto es lo que devolvió, no lo que dice la documentación:

    API jsdom 29.1.1 happy-dom 20.11.1
    getBoundingClientRect() todo a 0 todo a 0
    offsetWidth / offsetTop 0 0
    getComputedStyle() con estilos inline correcto correcto
    window.matchMedia no existe
    Element.scrollIntoView no existe
    document.elementFromPoint no existe
    dialog.showModal() no existe
    CSS.supports no existe
    navigator.clipboard no existe
    ResizeObserver no existe stub que nunca dispara
    IntersectionObserver no existe stub que nunca dispara
    canvas.getContext('2d') null, o real con el paquete canvas null, sin alternativa
    Element.animate (WAAPI) no existe no existe
    Custom elements y Shadow DOM

    Un matiz sobre dialog: jsdom sí define el constructor HTMLDialogElement, pero showModal, show y close no están en el prototipo. No es que lancen una excepción propia: es que 'showModal' in dialog devuelve false.

    Tres conclusiones incómodas.

    Una: el relato de "jsdom es más completo" es falso tal y como se cuenta. En superficie de API moderna gana happy-dom. jsdom sigue sin matchMedia en 2026, probablemente el mock más copiado y pegado de la historia del frontend.

    Dos: ninguno tiene layout. Si tu test necesita que getBoundingClientRect() devuelva algo distinto de cero, cambiar de entorno no te salva.

    Tres, la que me costó el susto: happy-dom prefiere un stub silencioso a un fallo ruidoso. Este es su ResizeObserver real, tal cual está en el repositorio:

    export default class ResizeObserver {
      public observe(): void {
        // TODO: Not implemented
      }
      public unobserve(): void {
        // TODO: Not implemented
      }
      public disconnect(): void {
        // TODO: Not implemented
      }
    }
    

    IntersectionObserver sigue el mismo patrón: guarda el callback en el constructor y expone un takeRecords() que devuelve siempre un array vacío, pero observe() tiene el cuerpo igual de hueco. Tu código lo instancia, llama a observe(), no pasa nada y el test sigue adelante. Un fallo ruidoso cuesta diez minutos. Uno silencioso cuesta un incidente.

    En lo fundamental son gemelos: probé validación de formularios, sanitización de input[type=number], ciclo de vida de custom elements, <template>, orden de propagación capture/bubble, resolución de URLs relativas y parseo de HTML mal formado. Resultado idéntico en ambos. Para el 95% de los tests de componente da exactamente igual cuál uses.


    Cómo se configuran, y cómo se mezclan

    Lo que casi nadie cuenta: no tienes que elegir uno para todo el proyecto. Con Vitest 4 defines proyectos por glob.

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    
    export default defineConfig({
      test: {
        projects: [
          {
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.spec.ts'],
              exclude: ['src/**/*.component.spec.ts'],
            },
          },
          {
            test: {
              name: 'dom',
              environment: 'happy-dom',
              include: ['src/**/*.component.spec.ts'],
            },
          },
        ],
      },
    })
    

    Ese exclude no es decorativo. Sin él, src/**/*.spec.ts también captura los *.component.spec.ts, cada test de componente se ejecuta dos veces —una en node y otra en happy-dom— y la ejecución en node falla con un expected 'undefined' to be 'object' que parece un bug de tu componente y no lo es.

    Y cuando un fichero suelto necesite jsdom, lo declaras en la primera línea. Ese comentario gana a la configuración del proyecto:

    // @vitest-environment jsdom
    import { it, expect } from 'vitest'
    
    it('corre en jsdom aunque el proyecto use happy-dom', () => {
      expect(window.navigator.userAgent).toContain('jsdom')
    })
    

    Lo he verificado ejecutándolo: ese fichero arranca en jsdom mientras el resto de la suite sigue en happy-dom. Un solo test lento no justifica frenar los otros mil.

    En Angular la palanca es distinta, porque el builder elige por ti según lo que esté instalado. Lo robusto es no depender de esa autodetección: apunta la opción runnerConfig del builder a un vitest.config.ts con environment fijado explícitamente, y así da igual lo que aparezca en el package.json. Si prefieres la vía rápida, deja instalado solo uno de los dos:

    # alternativa: fuerza jsdom eliminando la otra opción
    npm uninstall happy-dom && npm install -D jsdom
    

    Y añade esto a tu fichero de setup para que los observers dejen de mentirte:

    // test-setup.ts
    import { vi, beforeEach } from 'vitest'
    
    class ResizeObserverMock {
      constructor(private cb: (entries: unknown[], obs: unknown) => void) {}
      observe = vi.fn((target: Element) =>
        this.cb([{ target, contentRect: target.getBoundingClientRect() }], this))
      unobserve = vi.fn()
      disconnect = vi.fn()
    }
    
    class IntersectionObserverMock {
      constructor(private cb: (entries: unknown[], obs: unknown) => void) {}
      observe = vi.fn((target: Element) => this.cb([{ target, isIntersecting: true }], this))
      unobserve = vi.fn()
      disconnect = vi.fn()
      takeRecords = vi.fn(() => [])
    }
    
    beforeEach(() => {
      vi.stubGlobal('ResizeObserver', ResizeObserverMock)
      vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
    })
    

    Dos clases, no una. Un mock compartido que emite { isIntersecting: true } para ambos revienta en cuanto un componente responsive lee entries[0].contentRect.width, porque esa propiedad no existe en la entry: TypeError: Cannot read properties of undefined. Cada observer tiene su forma de entry y hay que respetarla.

    Y fíjate en el otro detalle: asigno siempre, sin comprobar antes si existe. Esa comprobación es exactamente lo que me llevó a producción con un test verde y un componente roto.


    La regla para elegir entre happy-dom o jsdom

    Cinco pasos, en este orden. Los aplico tal cual.

    1. node por defecto. Si el test no toca document, no cargues DOM. Ahí está el 5x, no en la comparativa de emuladores.
    2. happy-dom para tests de componente. Casi el doble de rápido y con más API moderna cubierta. Es la elección por defecto en 2026.
    3. Nunca uses guardas del tipo if (typeof window.X === 'function') en el setup. Sobrescribe siempre los observers con mocks que disparen.
    4. jsdom fichero a fichero, no suite entera. ¿Un test necesita canvas real o un comportamiento de spec que happy-dom aproxima mal? // @vitest-environment jsdom en la línea 1 y sigues.
    5. Si necesitas layout de verdad, ningún emulador sirve. Posiciones reales, scroll real, capturas visuales: eso es Browser Mode de Vitest, estable desde la 4.0, con Playwright debajo. Más lento, y el único sitio donde esos tests significan algo.

    La excepción que invierte los pasos 2 y 4: si mantienes una librería de componentes que consumen otros, empieza en jsdom. Ahí prefieres un fallo ruidoso a una aproximación cómoda, porque el coste de un falso verde no lo pagas tú.

    Razonar sobre el entorno antes que sobre el aserto es la columna vertebral del curso de Testing en Angular, donde monto la suite desde cero decidiendo qué corre en node, qué en DOM emulado y qué en navegador real. Y si lo que te falta es la base del framework antes de entrar a testearlo, esa parte la cubro en el curso de Angular Moderno.


    Lo que puedes hacer hoy

    Abre tu vitest.config.ts y mira qué environment tienes a nivel global para tus tests unitarios.

    Si es jsdom o happy-dom para toda la suite, acabas de encontrar tu mayor ganancia de tiempo del trimestre: sepáralo en dos proyectos y manda a node todo lo que no toque document. Diez minutos de trabajo.

    Después añade el mock de los observers al setup. Porque el test que más te va a costar en tu carrera no es el que falla: es el que pasa por el motivo equivocado.

    Si quieres seguir tirando del hilo, tengo publicado Testing en Angular con IA: tests que protegen de verdad, donde ataco el mismo problema desde el otro lado. Y si prefieres verlo montado sobre un proyecto real y con gente a la que preguntar, te espero en Dominicode Labs.


    Preguntas frecuentes sobre happy-dom y jsdom

    ¿Qué es más rápido, happy-dom o jsdom?

    happy-dom. En el benchmark que ejecuté en Dominicode en julio de 2026 con Vitest 4.1.10, sobre 50 ficheros con manipulación real de DOM y cinco pares de ejecuciones alternadas, happy-dom resultó 1,77x más rápido que jsdom en mediana, con un rango de 1,45x a 1,96x. En ejecución pura de operaciones DOM la ventaja sube a 2,5x y en arranque del entorno es de 2,2x. Las cifras de "5x o 10x" que circulan no se corresponden con lo que mide una suite real. La ganancia grande está en no cargar ningún DOM: el entorno node fue 5 veces más rápido que jsdom en la misma máquina.

    ¿Merece la pena migrar de jsdom a happy-dom?

    Depende de dónde esté tu cuello de botella, y casi nunca está donde crees. Si tu suite tarda diez minutos, migrar a happy-dom te deja en unos seis: real, pero no transformador. Antes de eso, mira cuántos de tus tests cargan un DOM sin necesitarlo, porque mover esos a environment: 'node' da una mejora del orden de 5x en esa parte de la suite y no tiene ningún riesgo de compatibilidad. Mi recomendación es hacerlo en ese orden: primero separa node de DOM, después cambia el emulador y, si algún fichero se rompe, pásalo a jsdom con el comentario // @vitest-environment jsdom en lugar de revertir la migración entera.

    ¿Cuál usa Vitest por defecto?

    Ninguno de los dos. El valor por defecto de test.environment en Vitest es node, sin window ni document. Para tener DOM debes instalar jsdom o happy-dom y declararlo en vitest.config.ts o con el comentario // @vitest-environment en la cabecera del fichero. Jest se comporta igual: su entorno por defecto es node y desde Jest 28 hay que instalar jest-environment-jsdom como paquete aparte.

    ¿Qué entorno DOM usa Angular con Vitest?

    El builder @angular/build:unit-test detecta automáticamente qué tienes instalado: prefiere happy-dom si está presente y cae a jsdom si no. Conviene saberlo porque implica que añadir happy-dom al package.json por cualquier motivo cambia el entorno de toda la suite sin que nadie modifique la configuración. Si quieres un comportamiento predecible, fija environment de forma explícita en el fichero de configuración al que apunta la opción runnerConfig del builder, en vez de confiar en la autodetección.

    ¿Por qué mi test falla con "ResizeObserver is not defined"?

    Porque estás en jsdom, que no implementa ResizeObserver ni IntersectionObserver: la propiedad no existe en window. La solución es añadir un mock en el fichero de setup, con una clase distinta para cada uno, porque sus entries tienen forma diferente: contentRect en el de resize e isIntersecting en el de intersection. Ojo con el matiz: en happy-dom esas clases sí existen, pero sus métodos están vacíos y el callback no se ejecuta nunca. Si tu mock está protegido por una comprobación de existencia, en happy-dom no se instalará y tu test pasará sin comprobar nada.

    ¿Puedo usar happy-dom y jsdom en el mismo proyecto?

    Sí, y es la mejor estrategia. Con Vitest 4 defines varios proyectos en test.projects, cada uno con su environment y su glob de ficheros. Cuida los globs: si un proyecto incluye src/**/*.spec.ts y otro src/**/*.component.spec.ts, los ficheros de componente caen en los dos y se ejecutan por duplicado, así que necesitas un exclude en el primero. Además, el comentario // @vitest-environment jsdom en la primera línea de un fichero tiene prioridad sobre la configuración del proyecto, así que puedes mantener toda la suite en happy-dom y mover a jsdom solo los ficheros que lo necesiten. No hay que migrar en bloque.

    ¿Con cuál funciona getBoundingClientRect?

    Con ninguno. Ni jsdom ni happy-dom incorporan motor de layout, así que getBoundingClientRect(), offsetWidth y offsetTop devuelven cero en los dos. La documentación de jsdom lo declara explícitamente fuera de alcance. Si tu test depende de posiciones o tamaños reales, la única salida es un navegador de verdad: Browser Mode de Vitest, estable desde la 4.0, con Playwright por debajo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Cómo Vitest se convierte en el test runner por defecto en Angular 21

    Cómo Vitest se convierte en el test runner por defecto en Angular 21

    Vitest como test runner por defecto en Angular 21

    Tiempo estimado de lectura: 3 min

    • Ideas clave:
    • Angular 21 define a Vitest como runner de tests por defecto, unificando pipeline con Vite/esbuild.
    • Ventajas técnicas: pipeline unificado, ESM-first, feedback más rápido en –watch, menos dependencias puente.
    • Riesgos a validar: compatibilidad con TestBed, zone.js, elección de entorno DOM y migración incremental en monorepos.
    • Estrategia recomendada: probar en branch, alinear vite.config.ts, migrar por iteraciones y mantener Karma/Jest hasta completar la transición.

    Vitest como test runner por defecto cambia algo más que un paquete en package.json: alinea el pipeline de pruebas con el mismo motor que compila y sirve tu aplicación (Vite/esbuild). En las primeras líneas: esto reduce discrepancias entre entorno de desarrollo y entorno de tests, acelera el feedback en modo watch y simplifica la gestión de módulos ESM. Angular 21 fija Vitest por defecto; Karma y Jest siguen soportados, pero la dirección es clara.

    Resumen rápido (lectores con prisa)

    Vitest es un runner sobre Vite optimizado para ESM y esbuild.

    Significa un pipeline unificado entre dev y tests, menos transformaciones duplicadas y feedback más rápido en –watch.

    Valida TestBed, zone.js y entornos DOM (jsdom vs happy-dom) antes de migrar.

    Migración recomendada por fases; mantener Karma/Jest hasta completar la cobertura en Vitest.

    Vitest como test runner por defecto: qué significa para tu arquitectura

    Vitest es un runner construido sobre Vite y optimizado para flujos ESM y esbuild. Al adoptar Vitest por defecto, Angular deja de tener dos pipelines distintos (uno para build/serve y otro para tests), lo que elimina una fuente frecuente de errores: transforms distintos, alias no replicados o compatibilidades ESM que “se rompen” solo en CI.

    Ventajas técnicas (no marketing)

    Pipeline unificado

    Same toolchain para dev y tests. Menos config duplicada = menos fallos de integración.

    ESM-first

    Vitest trata paquetes ESM nativos sin hacks; adiós a transformIgnorePatterns interminables.

    Feedback más rápido en –watch

    Vite conoce el grafo de módulos y re-ejecuta solo lo necesario, reduciendo tiempos de feedback en desarrollo.

    Menos “glue” y dependencias puente

    Menos presets y adaptadores como los que Jest requería en proyectos Angular.

    Cuando la herramienta de testing comparte transformaciones exactas con tu dev server, tus tests reflejan la realidad del runtime.

    Ejemplo mínimo de integración y scripts

    Un ejemplo práctico de lo que cambias en un proyecto Angular:

    package.json (scripts)

    {
      "scripts": {
        "test": "vitest",
        "test:watch": "vitest --watch",
        "test:ci": "vitest --run"
      }
    }

    vite.config.ts (fragmento)

    import { defineConfig } from 'vite';
    import { angular } from '@analogjs/vite-plugin-angular'; // ejemplo de integración si aplica
    
    export default defineConfig({
      test: {
        environment: 'happy-dom', // o 'jsdom' según tus necesidades
        globals: true
      }
    });

    Estos archivos muestran la convergencia: tu vite.config.ts puede centralizar transformaciones y alias que antes había que duplicar para Jest o Karma.

    Riesgos y puntos a validar antes de migrar

    • TestBed y APIs específicas de Angular: sigue funcionando, pero revisa utilidades que dependan de polyfills o zone.js. Algunas configuraciones de test legacy pueden requerir ajustes.
    • Entornos DOM: Vitest soporta jsdom y happy-dom; valida cuál reproduce mejor tu suite (render tests, componentes con animaciones, etc.).
    • Monorepos y grandes bases de tests: migrar toda la suite puede ser costoso de golpe; planifica migraciones por paquetes o por dominios funcionales.
    • Cold starts en CI: aunque Vitest suele ser rápido, en suites masivas el rendimiento en cold start depende mucho de la configuración y del entorno del runner (compara con tus números reales).

    Estrategia práctica de migración (tech-lead friendly)

    1. Probar en un branch: instala Vitest y configura un ejemplo mínimo (un módulo o paquete).
    2. Alinear vite.config.ts: centraliza las transformaciones que usas en dev (alias, plugins).
    3. Comparar modos: ejecutar la suite en modo watch y en CI (run) para comparar tiempos y fallos.
    4. Migración por iteraciones: migrar grupos de tests por feature o carpeta, ajustando mocks y environment según sea necesario.
    5. Mantener Karma/Jest activos: mantenerlos durante la transición; eliminar cuando el 100% de tests pasen en Vitest y la monitorización confirme estabilidad.

    Si quieres una herramienta rápida para estimar el impacto, mide:

    • Tiempo total de test en CI antes/después.
    • Tasa de falsos negativos/positivos detectados por cambio de runner.
    • Tiempo medio de feedback en dev (–watch).

    CI/CD: ganancias reales

    En CI obtendrás beneficios prácticos:

    • Menor complejidad en runners (menos paquetes, menos instalación).
    • Startup más rápido y menor consumo de memoria en contenedores.
    • Pipeline más predecible al usar el mismo empaquetador que en dev.

    Ejemplo breve (GitHub Actions step):

    - name: Run tests
      run: npm ci && npm run test:ci

    Sin Webpack en la ecuación, la instalación y ejecución suelen ser más ágiles.

    Cuándo no migrar aún

    • Si tienes una suite de tests con integración muy fina en Jest (configuraciones Nx o monorepos con dependencias internas) y el coste de migración supera el beneficio inmediato.
    • Si dependes de plugins de Jest que no tienen equivalentes en Vitest y que son críticos para tu flujo.

    En esos casos, planifica la migración como iniciativa de medio plazo: arranca con nuevos proyectos y módulos para ganar experiencia.

    Conclusión

    Vitest como test runner por defecto en Angular 21 no es solo una nueva opción: es la consolidación de una decisión de coherencia técnica. Para proyectos nuevos, adopta Vitest. Para proyectos con Karma, planifica la migración cuanto antes. Para entornos Jest maduros, valora ROI y migra por fases. La apuesta central aquí es menos complejidad operativa, menos errores por discrepancias de herramientas y tests que realmente reflejan el comportamiento en producción.

    Lecturas y recursos:

    FAQ

    Respuesta: Vitest es un test runner construido sobre Vite, optimizado para flujos ESM y esbuild. Comparte transformaciones con el dev server de Vite, lo que reduce discrepancias entre desarrollo y tests.

    Respuesta: Angular 21 fija Vitest por defecto para alinear pipelines, reducir duplicación de configuración y mejorar la coherencia entre runtime de desarrollo y entorno de tests.

    Respuesta: Validar compatibilidad de TestBed y APIs específicas de Angular, dependencia en zone.js o polyfills, y elegir el entorno DOM adecuado (jsdom vs happy-dom).

    Respuesta: Depende de tu suite. jsdom puede ser más completo en APIs DOM; happy-dom suele ser más ligero. Probar ambos según componentes y animaciones es la vía correcta.

    Respuesta: Migrar por paquetes o dominios funcionales, no todo de golpe. Medir impacto en cada iteración y mantener herramientas legacy activas hasta validar cobertura completa en Vitest.

    Respuesta: Sí. Mantener Jest o Karma durante la transición es recomendable. Eliminar solo cuando todos los tests pasen en Vitest y la monitorización confirme estabilidad.

    Respuesta: Medir tiempo total de test en CI antes/después, tasa de falsos negativos/positivos por cambio de runner y tiempo medio de feedback en dev (–watch).