Escrevendo código testável em JavaScript: uma breve visão geral

Publicados: 2022-03-11

Quer estejamos usando o Node emparelhado com uma estrutura de teste como Mocha ou Jasmine, ou executando testes dependentes de DOM em um navegador headless como o PhantomJS, nossas opções para JavaScript de teste de unidade estão melhores agora do que nunca.

No entanto, isso não significa que o código que estamos testando seja tão fácil para nós quanto nossas ferramentas! Organizar e escrever código que seja facilmente testável exige algum esforço e planejamento, mas existem alguns padrões, inspirados em conceitos de programação funcional, que podemos usar para evitar problemas na hora de testar nosso código. Neste artigo, veremos algumas dicas e padrões úteis para escrever código testável em JavaScript.

Mantenha a lógica de negócios e a lógica de exibição separadas

Um dos principais trabalhos de um aplicativo de navegador baseado em JavaScript é ouvir os eventos DOM acionados pelo usuário final e, em seguida, responder a eles executando alguma lógica de negócios e exibindo os resultados na página. É tentador escrever uma função anônima que faça a maior parte do trabalho exatamente onde você está configurando seus ouvintes de eventos DOM. O problema que isso cria é que agora você precisa simular eventos DOM para testar sua função anônima. Isso pode criar sobrecarga tanto nas linhas de código quanto no tempo necessário para a execução dos testes.

Em vez disso, escreva uma função nomeada e passe-a para o manipulador de eventos. Dessa forma, você pode escrever testes para funções nomeadas diretamente e sem pular obstáculos para acionar um evento DOM falso.

Isso se aplica a mais do que o DOM. Muitas APIs, tanto no navegador quanto no Node, são projetadas para disparar e ouvir eventos ou aguardar a conclusão de outros tipos de trabalho assíncrono. Uma regra prática é que, se você estiver escrevendo muitas funções de retorno de chamada anônimas, seu código pode não ser fácil de testar.

 // hard to test $('button').on('click', () => { $.getJSON('/path/to/data') .then(data => { $('#my-list').html('results: ' + data.join(', ')); }); }); // testable; we can directly run fetchThings to see if it // makes an AJAX request without having to trigger DOM // events, and we can run showThings directly to see that it // displays data in the DOM without doing an AJAX request $('button').on('click', () => fetchThings(showThings)); function fetchThings(callback) { $.getJSON('/path/to/data').then(callback); } function showThings(data) { $('#my-list').html('results: ' + data.join(', ')); }

Use retornos de chamada ou promessas com código assíncrono

No exemplo de código acima, nossa função fetchThings refatorada executa uma solicitação AJAX, que faz a maior parte de seu trabalho de forma assíncrona. Isso significa que não podemos executar a função e testar se ela fez tudo o que esperávamos, porque não saberemos quando ela terminará de ser executada.

A maneira mais comum de resolver esse problema é passar uma função de retorno de chamada como parâmetro para a função que é executada de forma assíncrona. Em seus testes de unidade, você pode executar suas asserções no retorno de chamada que você passa.

Ilustração: Usando uma unção de retorno de chamada como parâmetro no teste de unidade

Outra maneira comum e cada vez mais popular de organizar código assíncrono é com a API Promise. Felizmente, $.ajax e a maioria das outras funções assíncronas do jQuery já retornam um objeto Promise, então muitos casos de uso comuns já estão cobertos.

 // hard to test; we don't know how long the AJAX request will run function fetchData() { $.ajax({ url: '/path/to/data' }); } // testable; we can pass a callback and run assertions inside it function fetchDataWithCallback(callback) { $.ajax({ url: '/path/to/data', success: callback, }); } // also testable; we can run assertions when the returned Promise resolves function fetchDataWithPromise() { return $.ajax({ url: '/path/to/data' }); }

Evite efeitos colaterais

Escreva funções que recebam argumentos e retornem um valor baseado apenas nesses argumentos, assim como digitar números em uma equação matemática para obter um resultado. Se sua função depende de algum estado externo (as propriedades de uma instância de classe ou o conteúdo de um arquivo, por exemplo), e você precisa configurar esse estado antes de testar sua função, você precisa fazer mais configurações em seus testes. Você terá que confiar que qualquer outro código sendo executado não está alterando esse mesmo estado.

Ilustração: Efeito em cascata causado pelo estado externo.

Da mesma forma, evite escrever funções que alteram o estado externo (como gravar em um arquivo ou salvar valores em um banco de dados) enquanto ele é executado. Isso evita efeitos colaterais que podem afetar sua capacidade de testar outro código com confiança. Em geral, é melhor manter os efeitos colaterais o mais próximo possível das bordas do seu código, com a menor “área de superfície” possível. No caso de classes e instâncias de objetos, os efeitos colaterais de um método de classe devem ser limitados ao estado da instância de classe que está sendo testada.

 // hard to test; we have to set up a globalListOfCars object and set up a // DOM with a #list-of-models node to test this code function processCarData() { const models = globalListOfCars.map(car => car.model); $('#list-of-models').html(models.join(', ')); } // easy to test; we can pass an argument and test its return value, without // setting any global values on the window or checking the DOM the result function buildModelsString(cars) { const models = cars.map(car => car.model); return models.join(','); }

Usar injeção de dependência

Um padrão comum para reduzir o uso do estado externo de uma função é a injeção de dependência - passando todas as necessidades externas de uma função como parâmetros de função.

 // depends on an external state database connector instance; hard to test function updateRow(rowId, data) { myGlobalDatabaseConnector.update(rowId, data); } // takes a database connector instance in as an argument; easy to test! function updateRow(rowId, data, databaseConnector) { databaseConnector.update(rowId, data); }

Um dos principais benefícios de usar injeção de dependência é que você pode passar objetos simulados de seus testes de unidade que não causam efeitos colaterais reais (neste caso, atualizando linhas de banco de dados) e você pode apenas afirmar que seu objeto simulado foi atuado da forma esperada.

Dê a cada função um único propósito

Divida funções longas que fazem várias coisas em uma coleção de funções curtas e de propósito único. Isso torna muito mais fácil testar se cada função faz sua parte corretamente, em vez de esperar que uma grande esteja fazendo tudo corretamente antes de retornar um valor.

Na programação funcional, o ato de juntar várias funções de propósito único é chamado de composição. Underscore.js ainda tem uma função _.compose , que pega uma lista de funções e as encadeia, pegando o valor de retorno de cada etapa e passando para a próxima função na linha.

 // hard to test function createGreeting(name, location, age) { let greeting; if (location === 'Mexico') { greeting = '!Hola'; } else { greeting = 'Hello'; } greeting += ' ' + name.toUpperCase() + '! '; greeting += 'You are ' + age + ' years old.'; return greeting; } // easy to test function getBeginning(location) { if (location === 'Mexico') { return 'Hola'; } else { return 'Hello'; } } function getMiddle(name) { return ' ' + name.toUpperCase() + '! '; } function getEnd(age) { return 'You are ' + age + ' years old.'; } function createGreeting(name, location, age) { return getBeginning(location) + getMiddle(name) + getEnd(age); }

Não Mute Parâmetros

Em JavaScript, arrays e objetos são passados ​​por referência em vez de valor, e são mutáveis. Isso significa que quando você passa um objeto ou um array como parâmetro para uma função, tanto seu código quanto a função que você passou ao objeto ou array terão a capacidade de alterar a mesma instância desse array ou objeto na memória. Isso significa que, se você estiver testando seu próprio código, precisará confiar que nenhuma das funções que seu código chama está alterando seus objetos. Toda vez que você adiciona um novo local em seu código que altera o mesmo objeto, fica cada vez mais difícil acompanhar a aparência desse objeto, dificultando o teste.

Ilustração: parâmetros mutantes podem causar problemas

Em vez disso, se você tiver uma função que recebe um objeto ou array, faça com que ela atue nesse objeto ou array como se fosse somente leitura. Crie um novo objeto ou matriz no código e adicione valores a ele com base em suas necessidades. Ou use Underscore ou Lodash para clonar o objeto ou array passado antes de operar nele. Melhor ainda, use uma ferramenta como Immutable.js que cria estruturas de dados somente leitura.

 // alters objects passed to it function upperCaseLocation(customerInfo) { customerInfo.location = customerInfo.location.toUpperCase(); return customerInfo; } // sends a new object back instead function upperCaseLocation(customerInfo) { return { name: customerInfo.name, location: customerInfo.location.toUpperCase(), age: customerInfo.age }; }

Escreva seus testes antes de seu código

O processo de escrever testes de unidade antes do código que eles estão testando é chamado de desenvolvimento orientado a testes (TDD). Muitos desenvolvedores acham o TDD muito útil.

Ao escrever seus testes primeiro, você é forçado a pensar na API que está expondo da perspectiva de um desenvolvedor consumindo-a. Isso também ajuda a garantir que você esteja apenas escrevendo código suficiente para atender ao contrato que está sendo aplicado por seus testes, em vez de criar uma solução desnecessariamente complexa.

Na prática, o TDD é uma disciplina com a qual pode ser difícil se comprometer com todas as alterações de código. Mas quando parece valer a pena tentar, é uma ótima maneira de garantir que você está mantendo todo o código testável.

Embrulhar

Todos nós sabemos que existem algumas armadilhas que são muito fáceis de cair ao escrever e testar aplicativos JavaScript complexos. Mas espero que com essas dicas e lembrando de sempre manter nosso código o mais simples e funcional possível, podemos manter nossa cobertura de teste alta e a complexidade geral do código baixa!

Relacionado:
  • Os 10 erros mais comuns que os desenvolvedores de JavaScript cometem
  • A necessidade de velocidade: uma retrospectiva do desafio de codificação JavaScript Toptal