javascript-unit-testing-performance
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
Jest ejecuta miles de pruebas en Facebook constantemente, ya sea mediante integración continua o invocado manualmente por ingenieros durante el desarrollo. Esto funcionó bien durante años, incluso cuando las personas que trabajaban en Jest pasaron a otros proyectos dentro de Facebook.
Sin embargo, a medida que los ingenieros agregaban más y más pruebas, notamos que el rendimiento de Jest no escalaría adecuadamente. Además, en el último año el ecosistema JavaScript ha cambiado drásticamente con la introducción de elementos como npm3 y Babel, lo cual no habíamos anticipado. Formamos un nuevo equipo de Jest para abordar todos estos problemas y compartiremos nuestro progreso y planes en este blog a partir de ahora.
Jest es un poco diferente de la mayoría de los ejecutores de pruebas. Lo diseñamos para funcionar bien en el contexto de la infraestructura de Facebook:
-
Monorepo: En Facebook tenemos un enorme monorepo que contiene todo nuestro código JavaScript. Hay muchas razones por las que este enfoque es ventajoso para nosotros, y hay una charla excelente de un ingeniero de Google que destaca todos los beneficios e inconvenientes de los monorepos.
-
Sandboxing: Otra característica de Jest importante para Facebook es cómo virtualiza el entorno de pruebas y envuelve
requirepara aislar la ejecución del código y pruebas individuales. Incluso estamos trabajando para hacer Jest más modular y así poder aprovechar esta funcionalidad en otros casos de uso no relacionados con pruebas. -
providesModule: Si has visto alguno de nuestros proyectos JavaScript de código abierto, quizás hayas notado que usamos un encabezado
@providesModulepara asignar IDs globalmente únicos a los módulos. Esto requiere algunas herramientas personalizadas, pero nos permite referenciar módulos sin rutas relativas, lo que nos ha ayudado a avanzar increíblemente rápido, ha escalado bien con el crecimiento de nuestra organización de ingeniería y ha fomentado el intercambio de código en toda la empresa. Consulta RelayContainer para ver un ejemplo práctico. Sin embargo, una desventaja de este enfoque es que nos vemos obligados a leer y analizar toda nuestra base de código JavaScript para resolver una única sentencia require. Esto obviamente sería prohibitivamente costoso sin un almacenamiento en caché extenso, especialmente para un proceso de corta duración como Jest.
Debido a estas restricciones únicas, Jest quizás nunca pueda ser tan rápido como otros ejecutores de pruebas al ejecutar toda nuestra suite de pruebas. Sin embargo, los ingenieros rara vez necesitan ejecutar Jest en toda la suite. Gracias al análisis estático del proyecto node-haste, hemos hecho que el modo predeterminado para ejecutar Jest en Facebook sea jest --onlyChanged o jest -o. En este modo construimos un gráfico de dependencias inversas para encontrar solo las pruebas afectadas que deben ejecutarse según los módulos que han cambiado.
Planificación óptima de una ejecución de pruebas
La mayoría de las veces, nuestro análisis estático determina que se deben ejecutar más de una prueba. El número de pruebas afectadas puede variar desde un par hasta miles. Para acelerar este proceso, Jest paraleliza las ejecuciones de pruebas entre workers. Esto es ideal porque la mayor parte del desarrollo de Facebook ocurre en servidores remotos con muchos núcleos de CPU.
Recientemente notamos que Jest a menudo parecía atascado con "Esperando 3 pruebas" durante hasta un minuto hacia el final de una ejecución. Resultó que teníamos algunas pruebas muy lentas en nuestra base de código que dominaban el tiempo de ejecución. Si bien pudimos acelerar estas pruebas individuales significativamente, también cambiamos cómo Jest planifica las ejecuciones. Anteriormente programábamos las ejecuciones basándonos en el recorrido del sistema de archivos, que era bastante aleatorio. Aquí hay un ejemplo de 11 pruebas en bloques grises sobre dos workers. El tamaño del bloque representa el tiempo de ejecución de la prueba:
Ejecutábamos aleatoriamente una mezcla de pruebas rápidas y lentas, y una de nuestras pruebas más lentas terminó ejecutándose cuando casi todas las demás ya habían finalizado, momento en el que el segundo worker permaneció inactivo.
Implementamos un cambio para programar las pruebas según el tamaño del archivo, que suele ser un buen indicador de cuánto tiempo tomará una prueba. Una prueba con miles de líneas de código probablemente tarde más que una con 15 líneas. Aunque esto aceleró la ejecución total en un 10%, encontramos una heurística mejor: ahora Jest almacena el tiempo de ejecución de cada prueba en una caché y en ejecuciones posteriores, programa primero las pruebas más lentas. Esto mejoró el tiempo de ejecución total en aproximadamente un 20%.
Aquí un ejemplo de la misma ejecución de pruebas con mejor programación:
Al ejecutar primero las pruebas lentas, Jest a veces parece tardar en iniciar: solo mostramos resultados después de completar la primera prueba. Para el futuro, planeamos ejecutar primero las pruebas que fallaron anteriormente, ya que dar esa información rápidamente a los desarrolladores es lo más importante.
Requerimientos en línea y mocks diferidos
Si has escrito pruebas con Jasmine antes, probablemente se vean así:
const sum = require('sum');
describe('sum', () => {
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
});
Algo especial que hacemos en Jest es reiniciar el registro completo de módulos después de cada prueba (cada llamada a it), para evitar dependencias entre pruebas. Antes de Jest, las pruebas dependían entre sí y el estado interno de los módulos se filtraba entre ellas. Al eliminar, reordenar o refactorizar pruebas, algunas empezaban a fallar sin motivo claro.
Cada prueba en Jest recibe una copia nueva de todos los módulos, incluyendo nuevas versiones de dependencias mockeadas que tardan mucho en generarse. Esto nos obligaba a llamar require manualmente antes de cada prueba, así:
let sum;
describe('sum', () => {
beforeEach(() => {
sum = require('sum');
});
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
it('works too', () => {
// This copy of sum is not the same as in the previous call to `it`.
expect(sum(2, 3)).toBe(5);
});
});
Creamos un transformador de Babel llamado inline-requires que elimina declaraciones require de nivel superior y las inserta directamente en el código. Por ejemplo, const sum = require('sum'); desaparece, pero cada uso de sum se reemplaza por require('sum'). Así escribimos pruebas como en Jasmine, y el código se transforma automáticamente:
describe('sum', () => {
it('works', () => {
expect(require('sum')(5, 4)).toBe(9);
});
});
Un gran beneficio es que solo requerimos los módulos usados realmente en la prueba, no todos los del archivo.
Lo que lleva a otra optimización: mocks diferidos. La idea es mockear módulos solo bajo demanda, lo que combinado con requerimientos en línea evita mockear muchos módulos y sus dependencias recursivas.
Actualizamos todas las pruebas con un codemod rápidamente: un "sencillo" cambio de 50,000 líneas. Los requerimientos en línea y mocks diferidos redujeron el tiempo de prueba en un 50%.
El plugin de Babel inline-require no solo sirve para Jest. Bhuwan lo implementó en facebook.com hace una semana, mejorando significativamente el tiempo de inicio.
Por ahora, si quieres usar esta transformación en Jest, debes añadirla manualmente a tu configuración de Babel. Estamos trabajando para simplificar su adopción.
Sincronización de repositorios y caché
La versión open source de Jest solía ser un fork de nuestra versión interna, y sincronizábamos solo cada varios meses. Era un proceso manual doloroso que requería arreglar muchas pruebas cada vez. Recientemente unificamos Jest para todas las plataformas (iOS, Android y web) y activamos la sincronización continua. Ahora cada cambio en el open source se prueba contra nuestros tests internos, manteniendo una única versión consistente en todas partes.
La primera función que pudimos aprovechar después de la unificación fue la caché de preprocesamiento. Si usas Babel junto con Jest, Jest debe preprocesar cada archivo JavaScript antes de ejecutarlo. Creamos una capa de caché para que cada archivo, cuando no ha cambiado, solo necesite transformarse una vez. Tras unificar Jest, pudimos corregir fácilmente la implementación de código abierto e implementarla en Facebook. Esto resultó en otra mejora de rendimiento del 50%. Como la caché solo funciona en la segunda ejecución, el tiempo de arranque en frío de Jest no se vio afectado.
También nos dimos cuenta de que realizábamos muchas operaciones de ruta al resolver módulos relativos. Como el registro de módulos se reinicia para cada prueba, existían miles de llamadas que podían memorizarse. Una gran optimización fue añadir mucha más caché, no solo alrededor de una prueba individual, sino también entre archivos de prueba. Anteriormente, generábamos metadatos de módulos para la función de automocking una vez por archivo de prueba. Sin embargo, el objeto que exporta un módulo nunca cambia, así que ahora compartimos este código entre archivos de prueba. Lamentablemente, como JavaScript y Node.js no tienen memoria compartida, debemos realizar todo este trabajo al menos una vez por proceso worker.
Cuestionarlo todo
Al intentar mejorar el rendimiento, es importante examinar también los sistemas que están por encima y por debajo del tuyo. En el caso de Jest, elementos como Node.js y los propios archivos de prueba. Una de las primeras acciones fue actualizar Node.js en Facebook desde la antigua versión 0.10 a iojs y posteriormente a Node 4. La nueva versión de V8 ayudó a mejorar el rendimiento y fue bastante fácil de actualizar.
Notamos que el módulo path de Node.js es lento al realizar miles de operaciones de ruta, lo cual se solucionó en Node 5.7. Hasta que dejemos de dar soporte a Node 4 internamente en Facebook, incluiremos nuestra propia versión del módulo fastpath.
Luego comenzamos a cuestionar el desactualizado node-haste. Como mencionamos antes, todo el proyecto debe analizarse para extraer encabezados @providesModule y construir un gráfico de dependencias. Cuando se creó originalmente este sistema, node_modules no existía y nuestro rastreador del sistema de archivos no los excluía adecuadamente.
En versiones anteriores, Jest leía cada archivo en node_modules, lo que contribuía al lento tiempo de arranque. Al retomar Jest, reemplazamos todo el proyecto con una nueva implementación basada en el empaquetador de react-native. El tiempo de arranque de Jest ahora es menor a un segundo incluso en proyectos grandes. El equipo de react-native, específicamente David, Amjad y Martin, hizo un trabajo excepcional en este proyecto.
Resumiendo las mejoras
Muchos de los cambios anteriores mejoraron el tiempo de ejecución de pruebas en un 10% o incluso 50%. Comenzamos con un tiempo de ejecución de unos 10 minutos para todas las pruebas, y sin estas mejoras probablemente estaríamos en 20 minutos ahora. Tras estas optimizaciones, ¡ahora tarda consistentemente alrededor de 1 minuto y 35 segundos ejecutar todas nuestras pruebas!
Más importante aún, añadir nuevas pruebas hace que el tiempo total de ejecución aumente muy lentamente. Los ingenieros pueden escribir y ejecutar más pruebas sin percibir los costes.
Con el lanzamiento reciente de Jest 0.9 y las mejoras de rendimiento de la integración node-haste2, el tiempo de ejecución de la suite de pruebas del framework Relay bajó de 60 segundos a unos 25, y la suite de pruebas de react-native ahora finaliza en menos de diez segundos en un MacBook Pro de 13".
Estamos muy satisfechos con los logros obtenidos hasta ahora, y seguiremos trabajando en Jest para mejorarlo. Si tienes interés en contribuir a Jest, no dudes en contactarnos en GitHub, Discord o Facebook :)
