An uncaught error was detected outside of a test.
Cannot read properties of undefined (reading 'prototype')
Você já abriu um relatório e encontrou um arquivo de spec cujos testes não estavam falhando, eles simplesmente não estavam lá? Quem nunca!
O meu estava verde havia um ano, em uma branch onde ninguém tinha encostado nele. Nenhum dos testes rodou, e nenhum deles falhou também: o arquivo nem chegou a registrá-los, então o relatório não tinha nada sobre eles além daquela única falha gerada sob o título genérico.
A causa era um ciclo de imports em um grafo de page objects. E resolver isso de vez me custou três tentativas separadas ao longo de oito meses (sim, oito): primeiro correções pontuais, depois uma varredura de 49 ciclos em uma única área, depois uma varredura do resto e, por fim, travas para que esse tipo de bug não pudesse voltar. Este post é a versão que eu gostaria de ter lido na primeira tentativa.
Por que repositórios de testes não podem fazer vista grossa para ciclos
A maioria dos desenvolvedores JavaScript já colocou um import circular em produção sem nunca perceber, porque os ES modules toleram isso surpreendentemente bem. Os bindings passam por hoisting; se o módulo A só usa algo de B dentro de uma função que roda mais tarde, tudo já está resolvido quando ela é chamada.
Repositórios de testes quebram essa tolerância, por dois motivos.
Uma camada de page objects é, por design, um grafo denso de dependências mútuas. Uma API fluente significa que o pai retorna os filhos e os filhos retornam ao pai: orders.openFilters().selectRegion('EMEA').apply(). OrdersContext precisa conhecer FiltersContext, e FiltersContext precisa devolver um OrdersContext. Isso é um ciclo no grafo de módulos, como consequência direta da ergonomia que você escolheu. Não é desleixo. E é por isso que "simplesmente não escreva ciclos" não é conselho.
Agora é que a trama se complica: decorators rodam no momento em que o módulo é avaliado. É essa parte que transforma um ciclo latente em um ciclo fatal. Digamos que os seus page objects sejam anotados para logging:
export class OrdersContext {
@step('Open the filters panel')
openFilters(): FiltersContext<OrdersContext> { /* ... */ }
}
Então step precisa ser uma função de verdade no momento em que o corpo da classe é avaliado, durante o import, e não quando um teste chama o método. O mesmo vale para qualquer outra coisa avaliada de forma antecipada: uma classe base no extends, o inicializador de um campo estático, um new no nível do módulo. Em um ciclo, um dos dois módulos está sempre no meio da avaliação quando o outro pede os exports dele, então um desses bindings é undefined. E um decorator undefined lança erro na hora.
Como o erro acontece durante o import, ele acontece fora de qualquer teste, que é exatamente o que a mensagem de erro do Cypress está dizendo:
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 isso que o raio de impacto é um arquivo de spec inteiro, e não uma asserção, e é por isso que os seus próprios testes somem do relatório em vez de ficarem vermelhos.
Três ferramentas, três funções diferentes
Acabei usando duas, e elas não são intercambiáveis, então aqui vai para que serve cada uma.
dpdm: análise estática, o gate de CI. Faz o parse do grafo TypeScript sem fazer build de nada e sai com código diferente de zero quando encontra ciclos:
{
"scripts": {
"check-circular": "dpdm -T --circular --no-warning --no-tree --exit-code circular:1 \"app/**/*.ts\" \"cypress/**/*.ts\""
}
}
Quatro flags fazem por merecer seu lugar:
--no-treee--no-warning→ suprimem o dump enorme de dependências e a lista de warnings, então a saída traz só os ciclos. É isso que mantém os logs de CI legíveis.-T(--transform) → compila cada arquivo antes de analisá-lo, então imports que só carregam tipos saem do grafo. Sem ela, odpdmtambém reporta como ciclos as arestas que são só de tipo,import typeincluído.--exit-code circular:1→ a que importa. Sem ela, odpdmreporta os ciclos e sai com0, e uma verificação que não consegue quebrar o build é um relatório que ninguém lê.
Repare nos dois globs. Um ciclo que passa por um arquivo de spec também conta, e encontrei vários assim.
circular-dependency-plugin: em tempo de build, pega o que o bundler realmente resolve. A ferramenta estática e o bundler nem sempre concordam; aliases, mapeamentos de paths e a configuração do preprocessor podem produzir arestas que um dos dois deixa passar. Rodar o plugin do webpack incondicionalmente, com o cache desligado, foi o que fechou essa lacuna:
new CircularDependencyPlugin({
exclude: /node_modules/,
failOnError: true,
allowAsyncCycles: false,
cwd: process.cwd(),
})
Antes ele ficava atrás de uma variável de ambiente, com cache em sistema de arquivos. O que significava que estava desligado em todas as execuções que importavam e, quando estava ligado, um cache quente podia pular justamente os arquivos que tinham mudado. Uma trava condicional e com cache é uma trava desligada.
import/no-cycle (eslint-plugin-import): feedback no editor. A melhor relação entre sinal e esforço se você quer o sublinhado ondulado enquanto digita. Não me apoiei nela, porque é lenta em um grafo grande e eu queria que o gate ficasse em um lugar que o desenvolvedor não pudesse pular.
Os quatro ciclos que eu vivia encontrando
1. O utilitário-folha que mora em um módulo central
Todo mundo importa o módulo central por causa de um decorator → o módulo central importa todo mundo de volta.
O decorator step estava definido no arquivo de suporte, o mesmo arquivo que importa page objects para registrar custom commands. Então todo page object fazia isto:
import { step } from '../../cypress/support/commands';
e o commands.ts fazia isto:
import { OrdersContext } from '@/orders/orders-context';
import { NotificationsContext } from '@/utilities';
app/* → commands.ts → app/*. Um ciclo por page object, uns setenta no total, todos vindos do mesmo erro cometido uma única vez.
A correção foi um arquivo e uma linha por consumidor: mover o decorator para um módulo próprio com zero imports e depois reexportá-lo a partir do módulo central, para manter a retrocompatibilidade.
export function step(description: string) { /* ... */ }
import { step } from './step-decorator';
export { step };
Como você pode ver, é quase constrangedor de tão pequeno, e mesmo assim é a refatoração de maior valor deste post: mecânica, uma linha por arquivo, e elimina uma família inteira de ciclos de uma vez. Um utilitário que todo mundo importa não pode importar nada. Se ele mora em um arquivo que também configura o seu framework, está morando no arquivo errado.
2. instanceof em um par interdependente
Uma checagem de tipo que precisa da classe em runtime → um import que o compilador não consegue apagar.
O primeiro caso fatal que encontrei. Um contexto de grid guardava a última ação que tinha criado e decidia o caminho pelo tipo dela:
private lastActionContext: RowActionBaseContext | null = null;
if (this.lastActionContext instanceof EditableRowActionBaseContext) {
this.lastActionContext.openEditor().save();
}
Passa tranquilo no review. Explode na hora do import. instanceof precisa da classe como valor em runtime, então, ao contrário de uma anotação de tipo, esse import sobrevive à compilação. As classes de ação importam o contexto de grid que as cria, então o ciclo é real em runtime. Enquanto os módulos são avaliados, um dos lados ainda é undefined quando os decorators e as cláusulas extends do outro rodam, e é daí que vêm erros como Cannot read properties of undefined.
A correção foi parar de perguntar "que classe é esta?" e passar a perguntar "ela consegue fazer a coisa?":
if (this.lastActionContext?.openEditor) {
this.lastActionContext.openEditor().save();
}
Duck typing remove completamente o import em runtime; declare a capacidade como um método opcional no tipo base e o código continua passando no type-check. Sinceramente, também é uma verificação melhor: a condição se importa com uma capacidade, não com uma posição na hierarquia de herança.
Onde uma checagem de identidade de classe realmente não podia ser evitada, recorri a uma comparação de nome:
if (this.parentContext?.constructor?.name === 'ConfirmDialogContext') { /* ... */ }
Funciona, e não vou fingir que é bonito. É uma string, então uma renomeação muda o comportamento silenciosamente, e quebra com qualquer minificação que embaralhe nomes de classes. Aceitável em um repositório de testes que roda sem minificação e onde uma renomeação está a um grep de distância. Não leve esse padrão para código de produção (sério).
3. O import apenas de tipos, o que realmente faz diferença
Apagado em tempo de compilação (normalmente) → ainda é uma aresta para as suas regras de lint.
Este é o insight que transformou a varredura de uma maratona arrastada em uma passada mecânica, e acabou respondendo pela maior parte do trabalho:
Tipos não conseguem causar um ciclo em runtime. Só valores conseguem.
Uma anotação de tipo é apagada pelo compilador. Se OrdersContext importa FiltersContext apenas para nomear um tipo de retorno, essa aresta normalmente desaparece no JavaScript emitido, mas um import comum não diz isso. Regras de lint como import/no-cycle continuam contando essa aresta e, com verbatimModuleSyntax ativado, o compilador também a mantém.
Então deixe explícito o que você quer dizer, e a aresta some de graça:
import type { FiltersContext } from './filters-context';
import type { OrdersPropertiesContext } from './orders-properties-context';
E quando você também precisa do construtor em runtime? Separe as duas necessidades: import type no topo para a posição de tipo e um require local no ponto de uso para o valor.
@step('Open the filters panel')
openFilters(): FiltersContext<OrdersContext> {
this.toolbar.clickFilters();
const { FiltersContext } = require('./filters-context');
return new FiltersContext(this);
}
O require adiado move a resolução do momento de avaliação do módulo para o momento da chamada, quando os dois módulos já estão totalmente inicializados. Duas ressalvas, ambas reais: é CommonJS, então exige um bundler ou uma configuração de ts-node que permita isso, e os analisadores estáticos continuam vendo a aresta. Ou seja, resolve o crash, mas pode não limpar o relatório. E é exatamente por isso que o próximo padrão existe.
4. Pais genéricos em vez de concretos
O filho para de nomear o pai → não sobra nada para importar.
A correção estrutural:
export class FiltersContext<TParent> {
constructor(private readonly parentContext: TParent) {}
@step('Apply and return')
apply(): TParent {
this.button.click();
return this.parentContext;
}
}
Agora o filho é parametrizado por quem o criou. A cadeia fluente continua passando no type-check de ponta a ponta, o tipo de retorno do pai passa a ser FiltersContext<OrdersContext>, e o módulo filho não importa nada do pai. Nenhum import de tipo, nenhum require, nenhuma aresta no grafo de nenhuma ferramenta.
É por esse formato que eu começaria se estivesse construindo a camada hoje. Aplicá-lo retroativamente em uma suíte existente é uma mudança maior do que as outras três, e é por isso que partes do meu repositório ainda carregam a versão barata da correção: um parâmetro tipado como any para cortar a aresta.
reopenSettings(sidePanel: any): this { /* ... */ }
Quero falar disso com honestidade, porque posts como este costumam terminar na solução limpa. O any quebra o ciclo em uma linha e custa a segurança de tipos naquela fronteira. Os que eu introduzi continuam parados no relatório de lint como dívida conhecida: rastreada, não esquecida, e só removível de vez com a refatoração para pais genéricos descrita acima. Eu faria a mesma troca de novo, sabendo que a alternativa eram arquivos de spec que, em silêncio, não produziam resultado nenhum? Sim. Mas é uma troca, não uma correção.
Quatro camadas para que isso não volte
As varreduras não foram a vitória. Os ciclos voltaram duas vezes depois que eu os limpei, porque nada os impedia. O que resolveu de vez foi tornar o repositório incapaz de aceitar um ciclo:
- Hook de pre-commit.
check-circularroda antes dos testes unitários e pega o ciclo na máquina que o criou, em segundos. - Um script npm no preflight do CI, junto com as outras verificações estruturais do repositório, para que um pull request verde signifique nenhum ciclo.
- O plugin do bundler, sempre ligado e sem cache, para pegar arestas que a análise estática resolve de outro jeito.
- Uma verificação no próprio setup do test runner: o runner analisa o grafo em
setupNodeEventse aborta antes que um único teste seja executado:
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')}`);
}
// ...
}
Basicamente, o caminho do ciclo vira a mensagem de erro, com setas e tudo.
A camada quatro é a que eu mais defenderia, e é justamente a que gera mais resistência. Você deve estar perguntando "isso não é exagero? O gate já existe no CI!" Já existe. Mas o modo de falha que ela evita é uma execução de quatro horas que reporta resultados parciais, em que um ciclo anula silenciosamente um punhado de arquivos de spec e o resumo parece plausível. Falhar em dez segundos com o caminho do ciclo impresso é estritamente melhor do que passar 900 testes e pular 40 em silêncio. Troque uma falha silenciosa e tardia por uma barulhenta e precoce, sempre.
O saldo da varredura, três tentativas e oito meses depois: 49 ciclos eliminados em uma área, depois o resto, os paliativos com any estacionados no relatório de lint, onde eu consigo vê-los, e quatro camadas entre qualquer ciclo novo e um arquivo de spec. Da próxima vez que uma spec em que ninguém mexeu jogar esse erro na sua cara, olhe o grafo, não a spec.
E você? Algum ciclo já engoliu um dos seus arquivos de spec, ou as suas ferramentas pegaram antes? Deixe um comentário, eu adoraria comparar experiências!
