FNSP Web Page

O teste que lê o código-fonte: guardar uma regra em vez de uma função

Um teste normal dá uma entrada e espera uma saída. Serve para quase tudo, e tem um limite claro: só apanha aquilo que alguém se lembrou de verificar.

Há outro género, que se vê pouco escrito e que resolve um problema diferente — guardar uma regra que não vive dentro de nenhuma função. Uma decisão de arquitetura não se parte quando se mexe no que já existe; parte-se quando se acrescenta a coisa seguinte, e é precisamente aí que ninguém está a pensar nela.

1. O caso que me levou a escrever isto

Neste site, uma Server Action é um endpoint público: quem souber o identificador dela chama-a. O proxy protege as páginas de /admin e não defende as ações. Cada uma tem de verificar a sessão por si, e todas passam por um invólucro:

export const guardarArtigo = comSessao(async (dados) => {
  // ...
});

A regra é «nenhuma ação escapa ao invólucro». Um teste comum não a guarda: teria de haver um teste por ação, e escrever o teste da ação nova é exatamente aquilo de que quem se esquece do invólucro também se esquece.

O teste que guarda isto não chama código nenhum. Lê o ficheiro como texto:

const fonte = readFileSync(ficheiro, 'utf8');

test('todas as ações de administração exigem sessão', () => {
  const exportadas = [...fonte.matchAll(/^export const (\w+) = (\w+)\(/gm)]
    .map((m) => ({ nome: m[1], invólucro: m[2] }));

  assert.ok(exportadas.length > 0, 'não encontrei ações exportadas');

  const desprotegidas = exportadas.filter((a) => a.invólucro !== 'comSessao');
  assert.deepEqual(desprotegidas.map((a) => a.nome), []);
});

Acrescentar uma ação sem o invólucro faz a bateria ficar vermelha, com o nome dela na mensagem. Ninguém teve de se lembrar de nada.

2. A parte que quase toda a gente esquece

Repare naquela linha do meio:

assert.ok(exportadas.length > 0, 'não encontrei ações exportadas');

É a mais importante das três. Um teste que procura padrões num ficheiro e não encontra nenhum passa — e passa a sorrir, para sempre, sem guardar coisa nenhuma. Basta alguém mudar o estilo das declarações, renomear o ficheiro, ou partir o caminho.

Sem essa verificação, o teste deixa de proteger em silêncio no dia em que mais faz falta. É o modo de falhar próprio desta técnica, e não é opcional.

3. Fechar a porta das traseiras

O primeiro teste só olha para export const x = .... Uma ação declarada de outra maneira escapava-lhe sem levantar ruído:

test('nenhuma ação é exportada como função solta', () => {
  const soltas = [...fonte.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)/gm)]
    .map((m) => m[1]);

  assert.deepEqual(soltas, [], 'declare as ações como export const x = comSessao(...)');
});

Um teste que lê o código-fonte tem sempre uma forma de ser contornado sem má intenção nenhuma — alguém escreve noutro estilo e o teste nem dá por isso. Vale a pena perguntar, ao escrevê-lo, «e se isto for declarado de outra maneira?», e fechar essa porta também.

4. Duas listas que têm de andar a par

O outro uso, mais frequente: dois ficheiros que estão separados por uma razão e que, por estarem separados, se desencontram com facilidade.

A barra do editor deste site oferece uma lista de linguagens para os blocos de código. Essa lista vive à parte do ficheiro que regista as gramáticas do realce — porque a barra é um componente de cliente, e importar dali arrastaria a biblioteca inteira para o browser de quem escreve.

test('cada linguagem oferecida tem gramática registada', () => {
  for (const { valor } of LINGUAGENS_DISPONIVEIS) {
    assert.ok(linguagemConhecida(valor), `${valor} está na lista mas não está registada`);
  }
});

Sem isto, escolher uma linguagem sem gramática dava um bloco de código sem cor nenhuma — sem erro, sem aviso, e sem ninguém perceber porquê. Aconteceu-me duas vezes antes de o teste existir, com o dockerfile e com o nginx.

5. Quando é que vale a pena

Três condições, e convém estarem as três:

  • A regra não cabe num sítio só. Se coubesse, punha-se lá uma verificação em execução e acabava.

  • A regressão entra com o que vier a seguir, não com o que já está escrito. É o que distingue isto de um teste normal.

  • Falhar é silencioso e caro. Uma ação sem sessão não dá erro nenhum — funciona lindamente, para toda a gente.

6. O que isto custa

Ler código como texto é frágil, e não vale a pena fingir o contrário. Uma expressão regular sobre declarações parte-se com uma mudança de estilo inofensiva.

A meu ver isso é meio defeito e meio virtude: obriga a olhar para a regra outra vez, de propósito, em vez de a mudar por acidente. Mas só funciona se o teste explicar porque existe — um teste destes sem comentário é, daqui a um ano, um obstáculo sem motivo aparente, e alguém o apaga.

Para regras que se repetem em muitos sítios há ferramentas próprias: regras de ESLint escritas de raiz, ou verificadores de dependências entre pastas. Para uma regra só, um teste de vinte linhas fica mais barato do que um plugin — e lê-se melhor.

O resumo

Nem tudo o que se quer garantir é comportamento. Quando a garantia é «isto tem de ser sempre feito assim», e quando quebrá-la não dá erro nenhum, um teste que abre o ficheiro e olha para ele resolve. Desde que se lembre da linha que verifica que ele encontrou alguma coisa.

Comentários

Ainda ninguém comentou este artigo.

Voltar ao blog

Gostávamos de saber quantas pessoas visitam o site, com o Google Analytics. Sem a sua autorização não corre nada. Política de Privacidade.