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-domo 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:
nodepor 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 | sí |
Element.scrollIntoView |
no existe | sí |
document.elementFromPoint |
no existe | sí |
dialog.showModal() |
no existe | sí |
CSS.supports |
no existe | sí |
navigator.clipboard |
no existe | sí |
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 | sí | sí |
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 39;vitest/config39;
export default defineConfig({
test: {
projects: [
{
test: {
name: 39;unit39;,
environment: 39;node39;,
include: [39;src/**/*.spec.ts39;],
exclude: [39;src/**/*.component.spec.ts39;],
},
},
{
test: {
name: 39;dom39;,
environment: 39;happy-dom39;,
include: [39;src/**/*.component.spec.ts39;],
},
},
],
},
})
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 39;vitest39;
it(39;corre en jsdom aunque el proyecto use happy-dom39;, () => {
expect(window.navigator.userAgent).toContain(39;jsdom39;)
})
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 39;vitest39;
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(39;ResizeObserver39;, ResizeObserverMock)
vi.stubGlobal(39;IntersectionObserver39;, 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.
nodepor defecto. Si el test no tocadocument, no cargues DOM. Ahí está el 5x, no en la comparativa de emuladores.- 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.
- Nunca uses guardas del tipo
if (typeof window.X === 'function')en el setup. Sobrescribe siempre los observers con mocks que disparen. - jsdom fichero a fichero, no suite entera. ¿Un test necesita
canvasreal o un comportamiento de spec que happy-dom aproxima mal?// @vitest-environment jsdomen la línea 1 y sigues. - 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.
