Desenvolvimento

19 set, 2017

TDD: Como criar unit tests em Node.js com Tape

Publicidade

Ok,  testar suas aplicações antes de enviá-las para produção é importante e todo mundo já sabe. Mas será que você sabe como testar eficientemente sua aplicação escrita em Node.js?

Neste artigo veremos como utilizar o módulo Tape para criar testes de unidade (unit tests) em uma abordagem de desenvolvimento orientado à testes, o TDD (Test Driven Development). O mais legal é que não nos restringiremos apenas a testar funções isoladas (unit test), também vamos testar APIs completas em Node.js usando o módulo Supertest para forjar requisições HTTP (integration test).

Veremos neste artigo:

  • Introdução ao TDD
  • Introdução a Testes de Unidade
  • Criando unit tests com Tape
  • Introdução a Testes de Integração
  • Testando requisições com Supertest

 

Vamos lá!

Introdução ao TDD

O Test Driven Development/TDD ou Desenvolvimento Orientado à Testes é uma técnica criada por Kent Beck, um famoso programador e autor de livros que atualmente trabalha no Facebook. Também são de autoria de Beck a metodologia ágil Extreme Programming (XP) que inclui técnicas como o TDD e o Pair Programming, que já falei anteriormente.

A ideia do TDD é que você deve escrever primeiro os testes da sua aplicação, para depois implementar o código que fará com que eles funcionem. Isso pode soar um tanto estranho mas é uma ideia ousada que possui vários benefícios, tais como:

  • diminuição do número de bugs, uma vez que não existem features sem testes;
  • foco nas features que importam para o projeto, pois escrevemos os testes com os requisitos em mãos;
  • permite testes de regressão, para ver se um sistema continua funcionando mesmo após várias mudanças e/ou muito tempo desde a última release;
  • aumento da confiança do time no código programado;

 

O TDD é executado em um ciclo chamado de Red/Green/Refactor e prega que cada incremento pequeno de software (baby step) deve ser testado, para que bugs sejam corrigidos rapidamente assim que surgem. Ele prega que primeiro você deve escrever o teste, antes mesmo da feature ser implementada. Quando executar esse teste, ele irá falhar (Red), aí você codifica a feature apenas o suficiente para ela passar no teste (Green) e então você melhora o código para que ele não apenas seja eficaz, mas eficiente (Refactor).

Recomenda-se rodar novamente os testes e se após a refatoração a feature parar de funcionar, roda-se o ciclo novamente.

O coração do TDD são os unit tests.

Introdução aos Testes de Unidade

Existe uma pequena discussão sobre a melhor tradução de “unit test” ser “teste unitário” ou “teste de unidade”. Acredito que seja uma discussão mais conceitual do que prática (como muitas discussões “técnicas” que existem por aí), então não entrarei nela aqui e apenas chamarei de “teste de unidade”.

Um unit test é um teste, geralmente automatizado, que testa uma única unidade da sua aplicação, geralmente uma única função, em um único contexto.

Cheio de “únicos” nesse parágrafo, não?

Mesmo com essa ênfase ainda existem muitas dúvidas e discussões acerca do que pode ser considerado um unit test ou não. Me aterei mais à prática e menos aos conceitos e resumirei como: se o seu teste testa apenas uma coisa (como uma função), chamarei ele de teste de unidade aqui, mesmo essa coisa internamente seja composta de outras coisas (como outras funções internas) afinal, parto do pressuposto que não temos como garantir que algumas funções do Node.js também não chamam diversas outras internamente.

Os unit testes são o coração do TDD pois é com eles que começamos a aplicar TDD. Mesmo que você não seja um “purista” e vá aplicar TDD 100% como manda os livros, tente ao menos passar a utilizar testes de unidade na sua aplicação, pois eles realmente valem a pena.

Um exemplo prático de aplicação de testes de unidade com TDD seria a criação de um método que aplica um desconto, em R$, ao valor de um produto, também em R$. Vamos começar escrevendo a função de teste dessa funcionalidade em um arquivo index.js, comparando o retorno da função aplicarDesconto com o valor esperado, o que chamamos de asserção ou assert (em Inglês):

function aplicarDescontoTest(){
 return aplicarDesconto(10,2) === 8
 }
console.log('A aplicação de desconto está funcionando? ')
 console.log(aplicarDescontoTest())

Se aplicarmos um desconto de R$2 sobre um produto de R$10, o valor esperado como retorno é R$8, certo? Mas o que acontece se executarmos este bloco de código com o comando “node index” no terminal?

Dará um erro porque a função aplicarDesconto ainda não existe. Ou seja, sabemos o resultado esperado, mas não programamos a função ainda. Vamos fazê-lo agora, com uma simples subtração, no mesmo arquivo:

function aplicarDesconto(valor, desconto){
 return valor - desconto
 }

Agora se rodarmos o arquivo index.js no terminal novamente ele deve indicar que está funcionando, pois ao testar nossa função passando 10 e 2, ela retornará 8 e a asserção será verdadeira. Sendo assim, nosso trabalho com esta função terminou, certo?

Errado. E se o valor do desconto for superior ao produto? Você conhece alguma loja que te paga para comprar alguma coisa? Eu não!

Quando uma unidade de código (nossa função nesse caso) possui casos de uso variados, devemos testá-los sob cada contexto, em asserções separadas. Sendo assim, vamos criar um novo teste considerando que, um produto jamais possa ter um valor negativo, mesmo com descontos altos (ficando de graça nesse caso):

function aplicarDescontoGrandeTest(){
 return aplicarDesconto(1,10) === 0
 }
console.log('A aplicação de desconto grande está funcionando? ') console.log(aplicarDescontoGrandeTest())

Note que essa é uma regra que inventei para esse teste. Se o desconto for maior que o valor, o produto deve sair de graça. Se mandar rodar esse arquivo novamente, verá que esse segundo teste não irá ‘passar’, embora o teste inicial continue passando.

Vamos refatorar nossa função aplicarDesconto para que contemple este cenário que acabamos de descobrir:

function aplicarDesconto(valor, desconto){
 if(desconto > valor) return 0
 return valor - desconto
 }

Rode novamente seu arquivo de testes e verá que agora os dois testes ‘passam’!

Criar todos os testes necessários para garantir que todas as unidade do seu software realmente funcionam é o que chamamos de cobertura de código (code coverage), cuja utópica marca de 100% deve ser sempre o ideal, embora praticamente inalcançável em sistemas complexos.

E se o desconto for negativo? Precisamos de um teste pra isso, pois um desconto não pode aumentar o valor original de um produto!

E se o valor do produto for negativo? Isso é claramente um erro de integridade dos erros, pois é economicamente impossível!

O quão ‘detalhado’ os seus testes serão vai muito do grau de importância que todas essas questões possuem para o seu negócio e o quanto domina ele.

Algumas vezes, a sua nova refatoração para fazer com que a função atenda a um novo requisito (como não permitir descontos negativos, por exemplo) pode fazer com que algum teste antigo deixe de passar. E isso é bom. Sempre que asserções deixam de funcionar durante os testes é hora de reanalisar o seu algoritmo e melhorá-lo. Antes descobrir essa falha durante os testes do que em produção, não é mesmo!

Agora veremos como criar testes de unidade mais profissionais usando o módulo Tape.

Criando Unit Tests com Tape

Você até pode escrever testes de unidade sem módulo algum, apenas usando Node.js puro como fiz acima, sendo que o mínimo que deveria fazer é separar as funções de verdade das funções de teste, em módulos.

No entanto, facilita muito a vida usar bibliotecas como Tape, Mocha e Chai. Para este tutorial vamos usar o Tape, que é uma das mais populares e permite fazer asserções de uma maneira muito simples, prática e padronizada, dando agilidade ao processo de unit testing ou TDD, caso leve a metodologia realmente a sério.

Vamos começar criando uma pasta para um novo projeto, chamado exemplotdd. Crie um index.js e um index.test.js nessa pasta. O primeiro será o arquivo da nossa aplicação e o segundo os unit tests do primeiro.

Abra o terminal e navegue até a pasta em questão. Execute o seguinte comando:

npm init

Esse comando serve para criação de um package.json para o seu projeto. Apertando Enter em cada uma das perguntas que ele lhe fará no console usa a configuração padrão. Atente apenas ao fato de que quando chegar a pergunta ‘test command’, digite o seguinte comando:

node index.test

Apenas confirme as demais perguntas pressionando Enter e quando terminar, abra o arquivo package.json e verá algo parecido com isso:

{
 "name": "exemplotdd",
 "version": "1.0.0",
 "description": "",
 "main": "index.js",
 "scripts": {
 "test": "node index.test"
 },
 "author": "LuizTools",
 "license": "ISC"
 }

Note que tem uma seção scripts ali. Nesta seção, definimos atalhos para comandos padrões que poderemos quer executar via NPM, como o comando “test” que é executado no console com:

npm test

Mas não vamos fazer isso agora, afinal, não criamos nossos testes com Tape ainda. Então instale agora a dependência do Tape digitando o seguinte comando:

npm install tape

E depois abra o arquivo index.test.js para configurarmos nosso arquivo de testes. Comece o arquivo carregando o módulo tape e o módulo que deseja testar:

const test = require('tape')
 const index = require('./index')

Depois, escreva o primeiro teste de unidade como abaixo:

test('Aplicar desconto', (t) => {
 t.assert(index.aplicarDesconto(10,5) === 5, "Descontou corretamente")
 t.end()
 })

A função test espera o nome do teste e uma função de teste que realizará a asserção (assert). A asserção espera a expressão que indica que o teste passou e uma mensagem de sucesso, finalizando o teste em seguida (end). Essa é uma asserção bem simples e fácil de entender, não?!

Se você rodar agora com “node index.test” ou mais elegantemente com “npm test”, dará um erro como abaixo:

TypeError: index.aplicarDesconto is not a function

Isso porque essa função ainda não existe. Vamos criá-la tal qual criamos no exemplo anterior sobre testes de unidade, mas no arquivo index.js, exportando-a via module.exports:

function aplicarDesconto(valor, desconto){
 if(desconto > valor) return 0
 return valor - desconto
 }
module.exports = {aplicarDesconto}

Agora teste novamente com ‘npm test’ e verá uma saída bem interessante:

TAP version 13
 # Aplicar desconto
 ok 1 Descontou corretamente
1..1
 # tests 1
 # pass 1
# ok

Essa saída do Tape mostra os testes que foram executados, o resultado de cada um e um total de testes e testes bem sucedidos, finalizando com uma mensagem de ok ou não.

Existe um outro módulo que dá uma melhorada nessa saída, o tap-spec, que você instala com ‘npm install tap-spec’ e altera o script de ‘test’ no seu package.json para:

"test": "node index.test | tap-spec"

Para testar esse novo módulo, vamos adicionar mais um teste de unidade, tentando aplicar um desconto superior ao valor do produto em nosso index.test.js (já fizemos isso antes, lembra?):

test('Aplicar desconto grande', (t) => {
 t.assert(index.aplicarDesconto(5,10) === 0, "Descontou corretamente")
 t.end()
 })

Executando com ‘npm test’, terá como resultado:

TDD: Como criar unit tests em Node.js com Tape

Com isso, depois de construirmos testes que cubram nosso código de uma ponta a outra, temos um recurso muito poderoso para fazer testes rapidamente e ver se todas funções da nossa aplicação estão funcionando.

Juntando este conhecimento com ferramentas de Continuous Integration (CI) como CircleCI e Jenkins, podemos incluir testes de unidade automatizados em nosso build para evitar que um código suba para produção com algum bug (um dia falarei disso por aqui).

Mas e como podemos fazer testes mais amplos, como testes em APIs escritas em Node.js? Afinal, é preciso subir um servidor Express para testar uma API, certo? Caso contrário não teríamos objetos de request e responde válidos…

Introdução a Testes de Integração

Chama-se de teste de integração aqueles testes mais amplos em que um teste acaba testando internamente diversas outras funções. Por exemplo, testar se uma chamada GET a uma API está funcionando não é exatamente um teste de unidade considerando que estaremos neste caso testando a chamada/request HTTP da aplicação, o roteamento, a função em si e o retorno/resposta HTTP.

Geralmente testes de integração são mais complicados de automatizar, principalmente quando envolvem interface de usuário ou elementos complexos de infraestrutura, como bancos de dados, embora não seja impossível.

Uma técnica muito comum para abstrair questões de infraestrutura são o uso de mocking. Mocking é o ato de falsificar algum elemento externo à sua aplicação, geralmente de infraestrutura, como se ele nunca falhasse. Por exemplo, uma função que acessa o banco de dados, em um teste que não importa o acesso ao banco, poderia ser mockada para retornar sempre o valor que queremos no teste, pois o que queremos testar é o uso do valor, não o acesso ao banco.

Existem diversos módulos no Node.js que facilitam esta tarefa de mockar objetos e recursos externos, mas particularmente não gosto desta técnica. Em algumas ocasiões é a única saída (alguém aí falou de integração com bancos e financeiras?) mas geralmente prefiro ter ambientes de testes ou simulá-los de maneira mais próxima à realidade.

Da mesma forma, existem diversos módulos no Node.js que facilitam automatizar testes que necessitem de recursos mais complexos. Um deles é o módulo Supertest, que permite que você forje requisições HTTP para testar suas APIs de maneira automática usando Tape ou qualquer outro módulo de asserções.

Testando requisições com Supertest

Basicamente o Supertest é um módulo que forja requisições visando testar webservers em Node.js e verifica o retorno das mesmas para automatizar testes deste tipo de infraestrutura, principalmente web APIs.

Para usar o Supertest, devemos primeira instalar sua dependência em nossa aplicação:

npm install supertest express body-parser

Note que aproveitei e já instalei outras duas dependências que vamos precisar para criar nossa API que aplica descontos.

Como o foco do nosso tutorial não é criar APIs em Express, apenas copie o código abaixo em um arquivo app.js:

//app.js
 const index = require('./index')
 const express = require('express')
 const app = express()
 const bodyParser = require('body-parser')
 const port = 3000 //porta padrão
//configurando o body parser para interpretar requests mais tarde
 app.use(bodyParser.urlencoded({ extended: true }));
 app.use(bodyParser.json());
//definindo as rotas
 const router = express.Router();
 router.get('/', (req, res) => res.json({ message: 'Funcionando!' }));
// GET /aplicarDesconto
 router.get('/aplicarDesconto/:valor/:desconto', (req, res) => {
 const valor = parseInt(req.params.valor)
 const desconto = parseInt(req.params.desconto)
 res.json({valorDescontado: index.aplicarDesconto(valor,desconto)})
 })
app.use('/', router)
if (require.main === module){
 //inicia o servidor
 app.listen(port)
 console.log('API funcionando!')
 }
module.exports = app

Resumidamente, criamos uma web API usando Express que escuta na porta 3000 esperando por requisições GET /aplicarDesconto passando no path o valor e o desconto a ser aplicado. Internamente pega-se esses dois parâmetros passados no path, converte-os para inteiro e usa-se a função que criamos anteriormente (e cujo módulo index.js carregamos no topo deste arquivo) para calcular o valor descontado, retornando-o em um JSON na resposta.

Atenção ao if que inicia o servidor somente no caso do require.main for igual a module, pois isso evita que o servidor fique ‘pendurado’ mais tarde, durante os testes.

Se você executar esse arquivo com o comando ‘node app’ verá que ele funciona perfeitamente.

Já criamos o teste de unidade da função aplicarDesconto e garantimos que ela está funcionando, o que já garante boa parte do funcionamento da nossa API. Agora vamos criar testes de integração que garantam o funcionamento completo dessa chamada.

Para isso, vamos criar um arquivo app.test.js, que conterá todos os testes do app.js. Comece esse arquivo carregando o módulo supertest, o tape e o app:

const test = require('tape')
 const supertest = require('supertest')
 const app = require('./app')

Agora, escreva um teste usando tape, que nem já fizemos antes, mas usaremos o supertest dentro dele para forjar a requisição e ler a resposta, visando uma asserção completa, que integre todo o uso da API:

test('GET /aplicarDesconto/10/5', (t) => {
 supertest(app)
 .get('/aplicarDesconto/10/5')
 .expect('Content-Type', /json/)
 .expect(200)
 .end((err, res) =>{
 t.error(err, 'Sem erros')
 t.assert(res.body.valorDescontado === 5, "Desconto correto")
 t.end()
 })
 })

Quando chamamos a função supertest devemos passar para ela o nosso app. A função get define a requisição que faremos, enquanto que as funções expect definem características que indicam que nosso supertest foi bem sucedido, analisando por exemplo o Content-Type e o HTTP status code.

Por fim, quando a requisição é finalizada, podemos usar o objeto t do Tape para analisar um possível erro na requisição e até mesmo fazer asserções em cima do body da resposta, para ver se está retornando o que é esperado.

Mas e agora que temos dois arquivos de teste separados, como fazemos para automatizar a execução dos dois usando apenas um ‘npm test’?

Você pode guardar todos seus arquivos de teste dentro de uma pasta tests e no seu script de test no package.json colocar algo como:

"test": "node ./tests/* | tap-spec"

Apenas tome cuidado com os caminhos que referenciam os módulos app e index dentro dos seus arquivos de teste.

Ou então criar um arquivo all.test.js que é um índice de testes que apenas carrega todos os módulos de teste e ele é referenciado no package.json como sendo o arquivo a ser executado.

Em ambos os casos, o resultado é este:

TDD: Como criar unit tests em Node.js com Tape

Atenção: qualquer tipo de conexão que você efetue deve ser encerrada ao término dos testes, caso contrário os testes ficam ‘pendurados’ aguardam o encerramento de todos recursos.

O if que coloquei antes do listen no app.js garante que o servidor não fique rodando eternamente. No caso de um banco de dados, o último teste deve ser sempre o de encerramento da conexão. E assim por diante.

Espero que tenham gostado do tutorial e que apliquem em seus projetos!