An uncaught error was detected outside of a test.
Cannot read properties of undefined (reading 'prototype')
¿Alguna vez has abierto un informe y te has encontrado un archivo de spec cuyos tests no fallaban, simplemente no estaban? ¡A todos nos ha pasado!
El mío llevaba un año en verde, en una rama donde nadie lo había tocado. No se ejecutó ninguno de sus tests, y tampoco falló ninguno: el archivo ni siquiera llegó a registrarlos, así que el informe no tenía nada de ellos salvo ese único fallo generado bajo el título genérico.
La causa era un ciclo de imports en un grafo de page objects. Y cerrarlo del todo me llevó tres intentos distintos a lo largo de ocho meses (sí, ocho): primero arreglos puntuales, luego una limpieza de 49 ciclos en una sola área, después una limpieza del resto y, por último, salvaguardas para que ese tipo de bug no pudiera volver. Este post es la versión que me habría gustado leer en el primer intento.
Por qué los repositorios de tests no pueden hacer la vista gorda con los ciclos
La mayoría de los desarrolladores de JavaScript han publicado alguna vez un import circular sin darse cuenta, porque los módulos ES los toleran sorprendentemente bien. Los bindings se elevan (hoisting); si el módulo A solo usa algo de B dentro de una función que se ejecuta más tarde, todo está resuelto para cuando se llama.
Los repositorios de tests rompen esa tolerancia, por dos motivos.
Una capa de page objects es, por diseño, un grafo denso de dependencias mutuas. Una API fluida implica que el padre devuelve hijos y los hijos vuelven al padre: orders.openFilters().selectRegion('EMEA').apply(). OrdersContext tiene que conocer FiltersContext, y FiltersContext tiene que devolver un OrdersContext. Eso es un ciclo en el grafo de módulos, como consecuencia directa de la ergonomía que has elegido. No es descuido. Y por eso "simplemente no escribas ciclos" no es un consejo.
Ahora bien, aquí es donde la trama se complica: los decoradores se ejecutan en el momento de evaluar el módulo. Esta es la parte que convierte un ciclo latente en uno fatal. Pongamos que tus page objects están anotados para logging:
export class OrdersContext {
@step('Open the filters panel')
openFilters(): FiltersContext<OrdersContext> { /* ... */ }
}
Entonces step tiene que ser una función real en el momento en que se evalúa el cuerpo de la clase, durante el import, no cuando un test llama al método. Lo mismo ocurre con cualquier otra cosa que se evalúe de forma anticipada: una clase base en extends, el inicializador de un campo estático, un new a nivel de módulo. En un ciclo, uno de los dos módulos siempre está a mitad de evaluarse cuando el otro pide sus exports, así que uno de esos bindings es undefined. Y un decorador que es undefined lanza un error de inmediato.
Como lanza el error durante el import, lo lanza fuera de cualquier test, que es exactamente lo que te está diciendo el mensaje de error de Cypress:
import → class body evaluates → decorator is undefined → throws outside any test
→ no test to attribute it to → the file's tests are discarded
→ reports one generated failure in their place
Por eso el radio de impacto es un archivo de spec entero y no una aserción, y por eso tus propios tests desaparecen del informe en lugar de ponerse en rojo.
Tres herramientas, tres funciones distintas
Acabé usando dos, y no son intercambiables, así que aquí va para qué sirve cada una.
dpdm: análisis estático, el gate de CI. Analiza el grafo de TypeScript sin hacer ningún build y termina con un código distinto de cero si encuentra ciclos:
{
"scripts": {
"check-circular": "dpdm -T --circular --no-warning --no-tree --exit-code circular:1 \"app/**/*.ts\" \"cypress/**/*.ts\""
}
}
Cuatro flags se ganan su sitio:
--no-treey--no-warning→ suprimen el enorme volcado de dependencias y la lista de warnings, así que la salida son solo los ciclos. Eso es lo que mantiene legibles los logs de CI.-T(--transform) → compila cada archivo antes de analizarlo, de modo que los imports que solo aportan tipos desaparecen del grafo. Sin él,dpdmtambién reporta como ciclos las aristas que son solo de tipos,import typeincluido.--exit-code circular:1→ el que importa. Sin él,dpdmreporta los ciclos y sale con0, y una comprobación que no puede romper el build es un informe que nadie lee.
Fíjate en los dos globs. Un ciclo que pasa por un archivo de spec también cuenta, y encontré varios así.
circular-dependency-plugin: en tiempo de build, detecta lo que el bundler resuelve de verdad. La herramienta estática y el bundler no siempre coinciden; los alias, los mapeos de paths y la configuración del preprocesador pueden producir aristas que a uno de los dos se le escapan. Ejecutar el plugin de webpack de forma incondicional, con la caché desactivada, fue lo que cerró ese hueco:
new CircularDependencyPlugin({
exclude: /node_modules/,
failOnError: true,
allowAsyncCycles: false,
cwd: process.cwd(),
})
Antes estaba detrás de una variable de entorno, con caché en el sistema de archivos. Lo que significaba que estaba apagado en todas las ejecuciones que importaban y, cuando sí estaba encendido, una caché caliente podía saltarse justo los archivos que habían cambiado. Una salvaguarda condicional y con caché es una salvaguarda apagada.
import/no-cycle (eslint-plugin-import): feedback en el editor. La mejor relación entre señal y esfuerzo si quieres el subrayado ondulado mientras escribes. No me apoyé en ella, porque es lenta en un grafo grande y quería que el gate viviera en un sitio que el desarrollador no pudiera saltarse.
Los cuatro ciclos con los que me topaba una y otra vez
1. La utilidad hoja que vive en un módulo central
Todos importan el módulo central por un decorador → el módulo central los importa a todos de vuelta.
El decorador step estaba definido en el archivo de soporte, el mismo archivo que importa page objects para registrar comandos personalizados. Así que cada page object hacía esto:
import { step } from '../../cypress/support/commands';
y commands.ts hacía esto:
import { OrdersContext } from '@/orders/orders-context';
import { NotificationsContext } from '@/utilities';
app/* → commands.ts → app/*. Un ciclo por page object, unos setenta en total, todos por el mismo error cometido una sola vez.
La solución fue un archivo y una línea por consumidor: mover el decorador a su propio módulo con cero imports y luego reexportarlo desde el módulo central por compatibilidad hacia atrás.
export function step(description: string) { /* ... */ }
import { step } from './step-decorator';
export { step };
Como puedes ver, es casi vergonzoso de lo pequeño que es, y aun así es la refactorización más valiosa de este post: mecánica, una línea por archivo, y elimina una familia entera de ciclos de golpe. Una utilidad que importan todos no debe importar nada. Si vive en un archivo que además configura tu framework, vive en el archivo equivocado.
2. instanceof en un par interdependiente
Una comprobación de tipo que necesita la clase en tiempo de ejecución → un import que el compilador no puede borrar.
El primer caso fatal con el que me topé. Un contexto de grid guardaba la última acción que había creado y ramificaba según su tipo:
private lastActionContext: RowActionBaseContext | null = null;
if (this.lastActionContext instanceof EditableRowActionBaseContext) {
this.lastActionContext.openEditor().save();
}
En la revisión se ve bien. Explota al importar. instanceof necesita la clase como valor en tiempo de ejecución, así que, a diferencia de una anotación de tipo, ese import sobrevive a la compilación. Las clases de acción importan el contexto de grid que las crea, de modo que el ciclo es real en tiempo de ejecución. Mientras los módulos se evalúan, uno de los lados sigue siendo undefined cuando se ejecutan los decoradores y las cláusulas extends del otro, y de ahí salen errores como Cannot read properties of undefined.
La solución fue dejar de preguntar "¿qué clase es esta?" y empezar a preguntar "¿puede hacer lo que necesito?":
if (this.lastActionContext?.openEditor) {
this.lastActionContext.openEditor().save();
}
El duck typing elimina por completo el import en tiempo de ejecución; declara la capacidad como un método opcional en el tipo base y sigue pasando el type-check. Sinceramente, además es una comprobación mejor: a la condición le importa una capacidad, no una posición en la jerarquía de herencia.
Donde de verdad no se podía evitar una comprobación de identidad de clase, recurrí a comparar el nombre:
if (this.parentContext?.constructor?.name === 'ConfirmDialogContext') { /* ... */ }
Funciona, y no voy a fingir que sea bonito. Es un string, así que un renombrado cambia el comportamiento en silencio, y se rompe con cualquier minificación que altere los nombres de clase. Aceptable en un repositorio de tests que se ejecuta sin minificar y donde un renombrado está a un grep de distancia. No lleves ese patrón a código de producción (en serio).
3. El import solo de tipos, el que de verdad importa
Se borra en tiempo de compilación (normalmente) → sigue siendo una arista para tus reglas de lint.
Esta es la idea que convirtió la limpieza de un suplicio en una pasada mecánica, y acabó siendo la mayor parte del trabajo:
Los tipos no pueden causar un ciclo en tiempo de ejecución. Solo los valores pueden.
El compilador borra las anotaciones de tipo. Si OrdersContext importa FiltersContext solo para nombrar un tipo de retorno, esa arista normalmente desaparece en el JavaScript emitido, pero una sentencia import normal no lo dice. Las reglas de lint como import/no-cycle la siguen contando y, con verbatimModuleSyntax activado, el compilador también la conserva.
Así que di lo que quieres decir, y la arista desaparece gratis:
import type { FiltersContext } from './filters-context';
import type { OrdersPropertiesContext } from './orders-properties-context';
¿Y cuando también necesitas el constructor en tiempo de ejecución? Separa las dos necesidades: import type arriba para la posición de tipo, y un require local en el punto de uso para el valor.
@step('Open the filters panel')
openFilters(): FiltersContext<OrdersContext> {
this.toolbar.clickFilters();
const { FiltersContext } = require('./filters-context');
return new FiltersContext(this);
}
El require diferido traslada la resolución del momento de evaluar el módulo al momento de la llamada, cuando ambos módulos ya están completamente inicializados. Dos advertencias, ambas reales: es CommonJS, así que necesita un bundler o una configuración de ts-node que lo permita, y los analizadores estáticos siguen viendo la arista. O sea, arregla el crash pero puede que no limpie el informe. Que es exactamente por lo que existe el siguiente patrón.
4. Padres genéricos en lugar de concretos
El hijo deja de nombrar a su padre → no queda nada que importar.
La solución estructural:
export class FiltersContext<TParent> {
constructor(private readonly parentContext: TParent) {}
@step('Apply and return')
apply(): TParent {
this.button.click();
return this.parentContext;
}
}
Ahora el hijo está parametrizado por quien lo creó. La cadena fluida sigue pasando el type-check de punta a punta, el tipo de retorno del padre pasa a ser FiltersContext<OrdersContext>, y el módulo hijo no importa nada del padre. Ni import de tipo, ni require, ni arista en el grafo de ninguna herramienta.
Esta es la forma de la que partiría si construyera la capa hoy. Adaptarla a una suite existente es un cambio mayor que los otros tres, y por eso partes de mi repositorio todavía llevan la versión barata de la solución: un parámetro tipado como any para cortar la arista.
reopenSettings(sidePanel: any): this { /* ... */ }
Quiero contarlo con honestidad, porque los posts como este suelen terminar en la solución limpia. any rompe el ciclo en una línea y te cuesta la seguridad de tipos en esa frontera. Los que introduje siguen ahí, en el informe de lint, como deuda conocida: registrada, no olvidada, y solo eliminable del todo con la refactorización a padres genéricos de arriba. ¿Volvería a aceptar la misma concesión, sabiendo que la alternativa eran archivos de spec que en silencio no producían ningún resultado? Sí. Pero es una concesión, no una solución.
Cuatro capas para que no vuelva
Las limpiezas no fueron la victoria. Los ciclos volvieron dos veces después de eliminarlos, porque nada los frenaba. Lo que acabó con ellos fue hacer que el repositorio no pudiera aceptar ni uno:
- Hook de pre-commit.
check-circularse ejecuta antes de los tests unitarios y lo detecta en la máquina que lo creó, en segundos. - Un script de npm en el preflight de CI, junto a las demás comprobaciones estructurales del repositorio, para que un pull request en verde signifique cero ciclos.
- El plugin del bundler, siempre activo y sin caché, para detectar aristas que el análisis estático resuelve de otra forma.
- Un escaneo en el propio setup del test runner: el runner analiza el grafo en
setupNodeEventsy aborta antes de que se ejecute un solo test:
async setupNodeEvents(on, config) {
const { parseDependencyTree, parseCircular } = require('dpdm');
const tree = await parseDependencyTree('app/**/*.ts', {
cwd: config.projectRoot,
extensions: ['.ts'],
transform: true,
});
const circular = parseCircular(tree);
if (circular.length) {
throw new Error(`Circular dependencies detected:\n${circular.map(c => c.join(' → ')).join('\n')}`);
}
// ...
}
Básicamente, la ruta del ciclo se convierte en el mensaje de error, con flechas y todo.
La capa cuatro es la que más defendería, y es la que más resistencia genera. Te estarás preguntando "¿no es excesivo? ¡El gate ya existe en CI!" Ya existe. Pero el modo de fallo que evita es una ejecución de cuatro horas que reporta resultados parciales, en la que un ciclo anula en silencio un puñado de archivos de spec y el resumen parece verosímil. Fallar en diez segundos con la ruta del ciclo impresa es estrictamente mejor que pasar 900 tests y saltarse 40 sin avisar. Cambia un fallo silencioso y tardío por uno ruidoso y temprano, siempre.
El balance de la limpieza, tres intentos y ocho meses después: 49 ciclos eliminados en un área, luego el resto, los parches con any aparcados en el informe de lint donde puedo verlos, y cuatro capas entre cualquier ciclo nuevo y un archivo de spec. La próxima vez que un spec que nadie ha tocado te lance ese error, mira el grafo, no el spec.
¿Y tú? ¿Alguna vez un ciclo se ha comido uno de tus archivos de spec, o tus herramientas lo detectaron antes? Déjame un comentario, ¡me encantaría comparar experiencias!
