Migrando más de 500 tests de Mocha a Node.js

Por
Emanuele Stoppa
Bjorn Lu

Hace más de un mes, discutimos una posible migración al Node.js test runner. Aunque estábamos suficientemente felices con Mocha, siempre estamos buscando hacer nuestros jobs de CI más rápidos.

Depender de un test runner integrado en nuestro runtime tenía algunas ventajas para nuestro monorepo principal:

  • Dos dependencias menos para instalar y mantener en nuestro monorepo: mocha y chai.
  • Mantenibilidad: hay más personas involucradas en el proyecto de Node.js para mantener el Node.js test runner.
  • Beneficios futuros: creemos que el test runner mejorará con el tiempo, y eventualmente ahorrará algo de tiempo en nuestros workflows de CI.

De una idea a un PoC

El monorepo de Astro tiene más de 500 suites de testing: entre tests de integración y tests unitarios, tenemos 664 suites, con un total de 1603 tests. La mayoría de estos tests son tests de integración.

Un test de integración, en nuestro monorepo, significa crear un pequeño proyecto de Astro, construir este proyecto con un entorno específico (desarrollo, generación estática (SSG) o generación dinámica (SSR)), y luego ejecutar aserciones sobre las páginas construidas. Así es, cada test de integración requiere que vite construya y empaquete el proyecto.

Antes de decidir emprender esta migración, queríamos asegurar que alejarnos de Mocha no fuera un error. A pesar de sus peculiaridades, ¡Mocha es un test runner muy capaz! Ha estado presente durante mucho tiempo y está probado en batalla. Si usas Mocha, estás en buenas manos.

La idea del PoC era entender:

  • La flexibilidad de los argumentos CLI de Node.js y qué tan personalizables podían ser los test reporters.
  • La velocidad de ejecución de las suites de testing.
  • La experiencia general del desarrollador.

Cómo empezamos

Empezamos migrando solo uno de nuestros paquetes que ya no usaba la suite de integración de astro: create-astro. Esta fue una buena oportunidad para jugar con la librería de aserciones integrada node:assert, aprender sobre las opciones que ofrecía y evaluar su rendimiento comparado con Mocha.

Dado que create-astro solo tenía un puñado de tests, fue relativamente fácil migrar los archivos de test para usar node:test y node:assert en lugar de mocha y chai. Después de eso, lo único que quedaba era actualizar el comando de mocha a node --test para ejecutar los tests. Sin embargo, rápidamente nos encontramos con problemas usando el comando node --test, incluyendo:

  • Tenía problemas para analizar la sintaxis glob al pasar múltiples argumentos (ej: node --test "test/*.test.js" --test-name-pattern="foo").
  • No era posible pasar el flag --test-concurrency (solo disponible en Node.js 21 y superior), pero se pudo solucionar usando la API programática con la opción concurrency.
  • Puntilloso, pero los nombres de los argumentos eran verbosos: --test-name-pattern en lugar de los argumentos --match, -m; --test-timeout en lugar de los argumentos --timeout, -t, etc.

Por lo tanto, para resolver estos problemas, creamos un script personalizado que puede ser llamado con el comando astro-scripts test. Esta decisión también resultó útil para habilitar más soluciones alternativas como verás más adelante.

Abriendo la caja de Pandora

Después de migrar exitosamente el primer paquete, luego intentamos migrar las suites de testing del paquete @astrojs/node. Esta integración es una de nuestras integraciones más descargadas, así que tenemos muchos tests. Además, los tests de este paquete todos son tests de integración, así que fue una buena oportunidad para comprobar el rendimiento del test runner.

Una vez que el PR estuvo listo, notamos que el Node.js test runner era significativamente más lento que Mocha. Investigamos, y descubrimos que Node.js lanza un nuevo proceso para cada archivo de test para asegurar que cada suite de testing se ejecute en aislamiento. Ejecutar una suite de testing en aislamiento es, generalmente, una buena práctica porque asegura que los tests se ejecuten en un entorno no contaminado.

Sin embargo, nuestras suites de testing ya estaban aisladas. De hecho, podíamos ejecutar nuestras suites de testing con Mocha usando el hilo principal sin encontrarnos con ninguno de los problemas típicos: efectos secundarios, entornos contaminados, etc. Lamentablemente, Node.js no proporcionaba una opción para ejecutar todos los tests en el mismo hilo. Así que tuvimos que idear una solución para las ejecuciones lentas de tests. (¿Al final no somos ingenieros? ¡Resolvemos problemas!)

Usando nuestro comando interno astro-scripts test, pudimos solucionar esto creando un archivo temporal que importa todas las suites de testing, y dejamos que Node.js teste ese único archivo. De esta manera, solo se lanza un proceso para el archivo y alcanzamos el mismo nivel de rendimiento que si estuviéramos usando el proceso principal.

Sin embargo, esto vino con su propia desventaja: si había un fallo de test o un timeout, no podíamos decir qué test era la causa. Esta fue la principal peculiaridad que encontramos, y aunque no todos los equipos podrían haber tomado esta decisión, aceptamos este trade-off para darnos los beneficios mencionados anteriormente. ¡Al fin y al cabo, previamente habíamos aceptado las peculiaridades de Mocha!

Node.js assert y chai

Durante la migración, tuvimos que eliminar la librería chai por node:assert/strict. Esta tarea reveló que con chai, puedes ejecutar la misma comprobación de diferentes maneras. Por ejemplo, puedes ejecutar una comprobación de igualdad al menos de cuatro formas diferentes:

import { expect } from "chai";
expect("foo").to.eq("foo")
expect("foo").to.be.eq("foo")
expect("foo").to.equal("foo")
expect("foo").to.be.equal("foo")

Por un lado, es bueno tener este tipo de flexibilidad. Pero por el otro, el código de los tests se vuelve inconsistente. Con el módulo de aserciones de Node.js, solo hay una forma de realizar esta comprobación:

import { assert } from "node:assert/strict";
assert.equal("foo", "foo")

El módulo de aserciones de Node.js proporciona casi todas las funcionalidades que requeríamos, así que la migración desde chai no fue tan dolorosa como pensábamos que podría ser. Nuestro uso de chai era muy mínimo. Sin embargo, echamos de menos el atajo .includes de chai:

import { expect } from "chai";
expect("It's a fine day").includes("fine")

El módulo de aserciones de Node.js no proporciona tal utilidad, así que en su lugar terminamos usando la aserción de igualdad con la función String#includes:

import assert from "node:assert/strict";
assert.equal("It's a fine day".includes("fine"), true)

Aquí vienen los dragones

Como se mencionó antes, tenemos muchos archivos de test y agregamos nuevos tests casi cada día. Abrir un PR único que haga la migración de todo el monorepo es inviable. Requeriría mucho trabajo de una persona, y mantener la rama actualizada sería estresante.

Así que se nos ocurrió un plan simple:

  • Migrar primero los paquetes pequeños dentro del monorepo.
  • Migrar lentamente el paquete principal — astro — teniendo Mocha y el Node.js test runner en el mismo CI.
  • Eliminar Mocha.

Para lograr eso, pedimos ayuda a nuestra comunidad. Pensamos que esta era la perfecta oportunidad para dejar que personas que no están familiarizadas con la lógica de negocio de Astro contribuyeran al proyecto. Y podríamos hacer que el proceso de migración fuera más rápido.

Creamos y fijamos un umbrella issue para coordinar los esfuerzos. Cada contribuyente se adueñó de la migración de un paquete individual, abriendo un PR separado para cada uno. Dos nuevos contribuyentes por primera vez al proyecto incluso se unieron a los esfuerzos. Fue algo fantástico de ver. En una semana, ¡pudimos migrar todos los paquetes!

Migrar el paquete principal astro ¡fue una hazaña! Es el paquete que contiene por lejos el mayor número de tests. Para realizar esta migración lenta y cuidadosamente, tuvimos que idear una solución fuera de lo común.

Configuramos el Node.js test runner para testear solo los archivos llamados *.nodetest.js. Hacer esto nos permitió seguir testeando todos los archivos en el CI. Luego, el resto fue solo cuestión de coordinar a nuestra comunidad proporcionando (¡y documentando!) un proceso claro para seguir:

  • Usar el umbrella issue para decir a otros contribuyentes qué archivos planeas migrar;
  • Renombrar los archivos a migrar de *.test.js a *.nodetest.js;
  • Migrar los archivos;
  • Abrir un PR, esperar por una revisión, y si es exitoso, un maintainer de Astro hará merge del PR.

Con la ayuda de @log101, @mingjunlu, @VoxelMC, @alexnguyennz, @xMohamd, @shoaibkh4n, @marwan-mohamed12, @at-the-vr y @ktym4a migramos casi 300 suites de test en una semana!

Los resultados

Estamos bastante felices con los resultados. No hemos visto ninguna regresión en el rendimiento significativa en nuestros tests. El módulo de aserciones que proporciona Node.js tiene todas las utilidades que necesitamos, y el patrón describe/it es soportado, así que la migración desde Mocha fue fluida.

Sin embargo, todavía hay algunos inconvenientes respecto a la experiencia del desarrollador comparado con usar Mocha.

Por ejemplo, para ejecutar una sola suite de test en Mocha, usar it.only es suficiente. Con el Node.js test runner tienes que:

  • Ejecutar el CLI usando el argumento --test-only.
  • Agregar .only al describe que contiene el it.only que quieres ejecutar.
  • Si hay múltiples instancias de describe, todas necesitan estar marcadas con .only.

Otro ejemplo es que usar --test-name-patterns podría mejorarse. Este argumento se usa para ejecutar solo los tests que coinciden con un patrón de nombre particular. La DX no es genial porque el CLI llena la terminal con mensajes sobre tests que no coinciden (que no se ejecutan). Esto hace más difícil entender qué tests se están ejecutando realmente. Además, el comando es realmente lento solo para ejecutar tests que coinciden con algún patrón.

El Node.js test runner todavía es joven y con su desarrollo activo, tiene todas las cartas para volverse mejor. Por ejemplo, el proyecto de Node.js está actualmente evaluando ejecutar tests usando el proceso principal después de que expusimos nuestro caso de uso.

En el espíritu de la verdadera colaboración de open source, estamos complacidos de que mejorar Astro cambiando nuestros tests a Node.js, ¡a su vez, mejorará Node.js mismo!