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
Durationte dice que tienes tests unitarios lentos, no dónde se va el tiempo. El desglose sí. environmentes la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. Compáralo contratests, nunca contraDuration.- En nuestra suite: 803,6 s de
environmentcontra 156 s detests. 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.tsanode,.test.tsxa 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 39;vitest/config39;
import react from 39;@vitejs/plugin-react39;
export default defineConfig({
test: {
projects: [
{
// Lógica pura: node pelado. Sin plugins, sin setup, sin DOM.
test: {
name: 39;unit39;,
environment: 39;node39;,
include: [39;src/**/*.test.ts39;],
},
},
{
// Componentes: DOM emulado + testing-library.
plugins: [react()],
test: {
name: 39;dom39;,
environment: 39;jsdom39;,
include: [39;src/**/*.test.tsx39;],
setupFiles: [39;./src/test/setup-dom.ts39;],
},
},
],
},
})
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, 39;fetch39;)
.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 39;vitest39;
beforeEach(() => {
vi.spyOn(global, 39;fetch39;).mockRejectedValue(new Error(39;fetch sin mockear39;))
})
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/.viteentre 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
- Lanza tus tests y mira el paréntesis del final. Solo eso.
- Compara
environmentcontests. Los dos son sumas acumuladas entre ficheros, así que la división tiene sentido. Sienvironmentes el doble detests, ya sabes dónde está tu problema, y no es donde llevas semanas buscándolo. - Cuenta cuántos ficheros importan JSX o tocan
documentde 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. - Separa por extensión.
.test.tsanode,.test.tsxa 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.
