Construindo APIs testáveis com Node.js
Construindo APIs testáveis com Node.js
Buy on Leanpub
Construindo APIs testáveis com Node.js

Sumário

Prefácio

em breve.

Introdução

Javascript é uma das linguagens atuais mais populares entre os desenvolvedores (segundo o Stack Overflow Survey de 2017). Sorte de quem pode trabalhar com Javascript ou está entrando em um projeto onde terá a oportunidade de aprender essa linguagem. Javascript é dona de uma fatia ainda única no mercado, pois é uma linguagem criada para browsers, para atualizar conteúdo dinamicamente, para ser não bloqueante, permitindo que ações sejam realizadas enquanto outras ainda estão sendo processadas. O contexto dos browsers contribuiu para que Javascript evoluísse de uma forma diferente das demais linguagens, focando em performance e em possibilitar a criação de interfaces com uma melhor experiência para o usuário.

Conforme os browsers evoluem, a linguagem Javascript também precisa evoluir. Uma prova desse processo de crescimento foi a criação do AJAX. Um dos pioneiros nesse paradigma foi a Google, com o intuito de melhorar a experiência de uso do Gmail e evitar que cada e-mail aberto gerasse uma nova atualização da página; esse tipo de cenário propiciou o começo dos trabalhos para habilitar chamadas HTTP usando Javascript e assim evitar a necessidade de atualizar a página para receber conteúdo de um servidor e mostrar na tela. Em 18 de Fevereiro de 2005 Jesse James Garreth publicou o artigo Ajax new approach web applications disseminando o termo AJAX na comunidade, termo esse que revolucionou a maneira de comunicar com servidores até então conhecida.

Com o surgimento da “nuvem”, as aplicações precisavam se tornar escaláveis. A arquitetura de software teve que se atualizar, as aplicações precisavam tirar o maior proveito de uma única máquina e utilizar o mínimo de recurso possível. Quando surge a necessidade de aumentar recursos, ao invés de fazer upgrade em uma máquina física, uma nova máquina virtual com uma nova instância da aplicação seria inicializada, permitindo a divisão da carga e dando origem a termos como micro-serviços. Nessa mesma época, Javascript chegou ao server side com o aparecimento do Node.js, novamente revolucionando a maneira de desenvolver software.

O Node.js trouxe todo o poder de Javascript para o server side, tornando-se o principal aliado de grandes empresas como Uber e Netflix, as quais lidam com milhares de requisições diariamente. A característica de trabalhar de forma assíncrona e ser guiado por eventos possibilitou a criação de aplicações que precisam de conexão em tempo real.

A comunidade Javascript é vasta e muito colaborativa, diariamente circulam centenas de novas bibliotecas e frameworks, tanto para front-end quando para back-end. Esse dinamismo confunde os desenvolvedores Node.js, pois diferente de outras linguagens consolidadas no server-side como Java, que possui frameworks como SpringMVC para desenvolvimento web e Ruby, com o framework Ruby on Rails, o Node.js possui uma gama gigantesca de frameworks web e a maioria deles não mantém uma convenção. A falta de convenções estabelecidas dificulta o caminho dos novos desenvolvedores, pois cada projeto possui uma arquitetura única o que torna complexa a escolha de um padrão e a comparação das vantagens de cada um. Além disso, Javascript e Node.js não possuem uma bateria de ferramentas de teste completa como os pacotes xUnit, comuns em outras linguagens. Cada projeto possui a sua combinação de ferramentas de testes para os mais determinados cenários, o que contribui com a confusão quando se está começando.

Esse livro tem como objetivo ajudar você a criar aplicações em Node.js utilizando os padrões mais reconhecidos da comunidade e seguindo os mais atuais padrões de qualidade para o desenvolvimento de software.

O livro guiará você para entender:

  • Qual o diferencial do Node.js quando comparado a outras linguagens
  • Como desenvolver aplicações com Node.js utilizando as últimas funcionalidades do Ecmascript
  • Como construir aplicações modularizadas e desacopladas
  • Como integrar com banco de dados NoSQL utilizando MongoDB
  • Como desenvolver guiado por testes com TDD
  • Por que testes facilitam o desenvolvimento
  • Como testar aplicações em Javascript
  • Como desenhar APIs seguindo o padrão REST
  • Como prover autenticação e segurança de APIs

Para que seja possível reproduzir cenários comuns do dia a dia do desenvolvimento, será criada no decorrer do livro, uma API que servirá como guia para a introdução dos mais diferentes tópicos. A API terá o domínio de uma loja virtual, pois é um caso no qual é possível cobrir os mais diversos cenários do desenvolvimento de aplicações com Node.js. Todo o desenvolvimento será guiado por testes, seguindo o padrão conhecido como TDD (Test Driven Development), introduzindo as mais diferentes e complexas necessidades de testes.

No final desse livro você terá desenvolvido uma aplicação resiliente, seguindo as melhores práticas do desenvolvimento de software e com cobertura de testes. Você estará pronto para desenvolver aplicações utilizando Node.js e seguindo os princípios do TDD.

Introdução ao Node.js

Node.js não é uma linguagem de programação, tampouco um framework. A definição mais apropriada seria: um ambiente de runtime para Javascript que roda em cima de uma engine conhecida como Google v8. O Node.js nasceu de uma ideia do Ryan Dahl que buscava uma solução para o problema de acompanhar o progresso de upload de arquivos sem ter a necessidade de fazer pooling no servidor. Em 2009 na JSConf EU ele apresentou o Node.js e introduziu o Javascript server-side com I/O não bloqueante, ganhando assim o interesse da comunidade que começou a contribuir com o projeto desde a versão 0.x.

A primeira versão do NPM (Node Package Manager), o gerenciador de pacotes oficial do Node.js, foi lançada em 2011 permitindo aos desenvolvedores a criação e publicação de suas próprias bibliotecas e ferramentas. O NPM é tão importante quanto o próprio Node.js e desempenha um fator chave para o sucesso do mesmo.

Nessa época não era fácil usar o Node. A frequência com que breaking changes eram incorporadas quase impossibilitava a manutenção dos projetos. O cenário se estabilizou com o lançamento da versão 0.8, que se manteve com baixo número de breaking changes. Mesmo com a frequência alta de atualizações, a comunidade se manteve ativa. Frameworks como Express e Socket.IO já estavam em desenvolvimento desde 2010 e acompanharam, lado a lado, as versões da tecnologia.

O crescimento do Node.js foi rápido e teve altos e baixos, como a saída do Ryan Dahl em 2012 e a separação dos core committers do Node.js em 2014, causada pela discordância dos mesmos com a forma como a Joyent (empresa na qual Ryan trabalhava antes de sair do projeto) administrava o projeto. Os core committers decidiram fazer um fork do projeto e chamá-lo de IO.js com a intenção de prover releases mais rápidas e acompanhar as melhorias do Google V8.

Essa separação trouxe dor de cabeça para a comunidade, que não sabia qual dos dois projetos deveria usar. Então, Joyent e outras grandes empresas como IBM, Paypal e Microsoft se uniram para ajudar a comunidade Node.js, criando a Node.js Foundation. A [Node.js Foundation] tem como missão a administração transparente e o encorajamento da participação da comunidade. Com isso, os projetos Node.js e IO.js se fundiram e foi lançada a primeira versão estável do Node.js, a versão 4.0.

O Google V8

O V8 é uma engine criada pela Google para ser usada no browser chrome. Em 2008 a Google tornou o V8 open source e passou a chamá-lo de Chromium project. Essa mudança possibilitou que a comunidade entendesse a engine em sí, além de compreender como o javascript é interpretado e compilado pela mesma.

O javascript é uma linguagem interpretada, o que o coloca em desvantagem quando comparado com linguagens compiladas, pois cada linha de código precisa ser interpretada enquanto o código é executado. O V8 compila o código para linguagem de máquina, além de otimizar drasticamente a execução usando heurísticas, permitindo que a execução seja feita em cima do código compilado e não interpretado.

Entendendo o Node.js single thread

A primeira vista o modelo single thread parece não fazer sentido, qual seria a vantagem de limitar a execução da aplicação em somente uma thread? Linguagens como Java, PHP e Ruby seguem um modelo onde cada nova requisição roda em uma thread separada do sistema operacional. Esse modelo é eficiente mas tem um custo de recursos muito alto, nem sempre é necessário todo o recurso computacional aplicado para executar uma nova thread. O Node.js foi criado para solucionar esse problema, usar programação assíncrona e recursos compartilhados para tirar maior proveito de uma thread.

O cenário mais comum é um servidor web que recebe milhões de requisições por segundo; Se o servidor iniciar uma nova thread para cada requisição vai gerar um alto custo de recursos e cada vez mais será necessário adicionar novos servidores para suportar a demanda. O modelo assíncrono single thread consegue processar mais requisições concorrentes do que o exemplo anterior, com um número bem menor de recursos.

Ser single thread não significa que o Node.js não usa threads internamente, para entender mais sobre essa parte devemos primeiro entender o conceito de I/O assíncrono não bloqueante.

I/O assíncrono não bloqueante

Trabalhar de forma não bloqueante facilita a execução paralela e o aproveitamento de recursos, essa provavelmente é a característica mais poderosa do Node.js, . Para entender melhor vamos pensar em um exemplo comum do dia a dia. Imagine que temos uma função que realiza várias ações, entre elas uma operação matemática, a leitura de um arquivo de disco e em seguida transforma o resultado em uma String. Em linguagens bloqueantes, como PHP e Ruby, cada ação será executada apenas depois que a ação anterior for encerrada. No exemplo citado a ação de transformar a String precisa esperar uma ação de ler um arquivo de disco, que pode ser uma operação pesada, certo? Vamos ver um exemplo de forma síncrona, ou seja, bloqueante:

 1 const fs = require('fs');
 2 let fileContent;
 3 const someMath = 1+1;
 4 
 5 try {
 6   fileContent = fs.readFileSync('big-file.txt', 'utf-8');
 7   console.log('file has been read');
 8 } catch (err) {
 9   console.log(err);
10 }
11 
12 const text = `The sum is ${ someMath }`;
13 
14 console.log(text);

Nesse exemplo, a última linha de código com o console.log precisa esperar a função readFileSync do módulo de file system executar, mesmo não possuindo ligação alguma com o resultado da leitura do arquivo.

Esse é o problema que o Node.js se propôs a resolver, possibilitar que ações não dependentes entre sí sejam desbloqueadas. Para solucionar esse problema o Node.js depende de uma funcionalidade chamada high order functions. As high order functions possibilitam passar uma função por parâmetro para outra função, as funções passadas como parâmetro serão executadas posteriormente, como no exemplo a seguir:

 1 const fs = require('fs');
 2 
 3 const someMath = 1+1;
 4 
 5 fs.readFile('big-file.txt', 'utf-8', function (err, content) {
 6     if (err) {
 7       return console.log(err)
 8     }
 9     console.log(content)
10 });
11 
12 const text = `The response is ${ someMath }`;
13 
14 console.log(text);

No exemplo acima usamos a função readFile do módulo file system, assíncrona por padrão. Para que seja possível executar alguma ação quando a função terminar de ler o arquivo é necessário passar uma função por parâmetro, essa função será chamada automaticamente quando a função readFile finalizar a leitura. Funções passadas por parâmetro para serem chamadas quando a ação é finalizada são chamadas de callbacks. No exemplo acima o callback recebe dois parâmetros injetados automaticamente pelo readFile: err, que em caso de erro na execução irá possibilitar o tratamento do erro dentro do callback, e content que é a resposta da leitura do arquivo.

Para entender como o Node.js faz para ter sucesso com o modelo assíncrono é necessário entender também o Event Loop.

Event Loop

O Node.js é uma linguagem guiada por eventos. O conceito de Event Driven é bastante aplicado em interfaces para o usuário, o javascript possui diversas APIs baseadas em eventos para interações com o DOM como por exemplo eventos como click, scroll, change são muito comuns no contexto do front-end com javascript.

Event driven é um fluxo de controle determinado por eventos ou alterações de estado, a maioria das implementações possuem um core (núcleo) que escuta todos os eventos e chama seus respectivos callbacks quando eles são lançados (ou tem seu estado alterado), esse é o resumo do Event Loop do Node.js.

Separadamente, a responsabilidade do Event Loop parece simples mas quando nos aprofundamos no funcionamento do Node.js notamos que o Event Loop é a peça chave para o sucesso do modelo event driven. Nos próximos tópicos vamos entender cada um dos componentes que formam o ambiente do Node.js, como funcionam e como se conectam.

Call Stack

A stack (pilha) é um conceito bem comum no mundo das linguagens de programação, frequentemente se ouve algo do tipo: “Estourou a pilha!”. No Node.js, e no javascript em geral, esse conceito não se difere muito de outras linguagens, sempre que uma função é executada ela entra na stack, que executa somente uma coisa por vez, ou seja, o código posterior ao que está rodando precisa esperar a função atual terminar de executar para seguir adiante. Vamos ver um exemplo:

 1 function generateBornDateFromAge(age) {
 2   return 2016 - age;
 3 }
 4 
 5 function generateUserDescription(name, surName, age) {
 6   const fullName = `${name} ${surName}`;
 7   const bornDate = generateBornDateFromAge(age);
 8 
 9   return `${fullName} is ${age} old and was born in ${bornDate}`;
10 }
11 
12 generateUserDescription("Waldemar", "Neto", 26);

Para quem já é familiarizado com javascript não há nada especial acontecendo aqui. A função generateUserDescription é chamada recebendo nome, sobrenome e idade de um usuário e retorna uma sentença com as informações colhidas. A função generateUserDescription depende da função generateBornDateFromAge para calcular o ano que o usuário nasceu. Essa dependência será perfeita para entendermos como a stack funciona.

No momento em que a função generateUserInformation é invocada ela vai depender da função generateBornDateFromAge para descobrir o ano em que o usuário nasceu com base no parâmetro age. Quando a função generateBornDateFromAge for invocada pela função generateUserInformation ela será adicionada a stack como no exemplo a seguir:

Conforme a função generateUserInformation vai sendo interpretada os valores vão sendo atribuídos às respectivas variáveis dentro de seu escopo, como no exemplo anterior. Para atribuir o valor a variável bornDate foi necessário invocar a função generateBornDateFromAge que quando invocada é imediatamente adicionada a stack até que a execução termine e a resposta seja retornada. Após o retorno a stack ficará assim:

O último passo da função será concatenar as variáveis e criar uma frase, isso não irá adicionar mais nada a stack. Quando a função generateUserInformation terminar, as demais linhas serão interpretadas. No nosso exemplo o console.log será executado e vai imprimir o valor da variável userInformation.

Como a stack só executa uma tarefa por vez foi necessário esperar que a função anterior executasse e finalizasse, para que o console.log pudesse ser adicionado a stack. Entendendo o funcionamento da stack podemos concluir que funções que precisam de muito tempo para execução irão ocupar mais tempo na stack e assim impedir a chamada das próximas linhas.

Multithreading

Mas o Node.js não é single thread? Essa é a pergunta que os desenvolvedores Node.js provavelmente mais escutam. Na verdade quem é single thread é o V8. A stack que vimos no capitulo anterior faz parte do V8, ou seja, ela é single thread. Para que seja possível executar tarefas assíncronas o Node.js conta com diversas outras APIs, algumas delas providas pelos próprios sistemas operacionais, como é o caso de eventos de disco, sockets TCP e UDP. Quem toma conta dessa parte de I/O assíncrono, de administrar múltiplas threads e enviar notificações é a libuv.

A libuv é uma biblioteca open source multiplataforma escrita em C, criada inicialmente para o Node.js e hoje usada por diversos outros projetos como Julia e Luvit.

O exemplo a seguir mostra uma função assíncrona sendo executada:

Nesse exemplo a função readFile do módulo de file system do Node.js é executada na stack e jogada para uma thread, a stack segue executando as próximas funções enquanto a função readFile está sendo administrada pela libuv em outra thread. Quando ela terminar, o callback sera adicionado a uma fila chamada Task Queue para ser executado pela stack assim que ela estiver livre.

Task Queue

Como vimos no capítulo anterior, algumas ações como I/O são enviadas para serem executadas em outra thread permitindo que o V8 siga trabalhando e a stack siga executando as próximas funções. Essas funções enviadas para que sejam executadas em outra thread precisam de um callback. Um callback é basicamente uma função que será executada quando a função principal terminar. Esses callbacks podem ter responsabilidades diversas, como por exemplo, chamar outras funções e executar alguma lógica. Como o V8 é single thread e só existe uma stack, os callbacks precisam esperar a sua vez de serem chamados. Enquanto esperam, os callbacks ficam em um lugar chamado task queue ou fila de tarefas. Sempre que a thread principal finalizar uma tarefa, o que significa que a stack estará vazia, uma nova tarefa é movida da task queue para a stack onde será executada. Para entender melhor vamos ver a imagem abaixo:

Esse loop, conhecido como Event Loop, é infinito e será responsável por chamar as próximas tarefas da task queue enquanto o Node.js estiver rodando.

Micro e Macro Tasks

Até aqui vimos como funciona a stack, o multithread e também como são enfileirados os callbacks na task queue. Agora vamos conhecer os tipos de tasks (tarefas) que são enfileiradas na task queue, que podem ser micro tasks ou macro tasks.

Macro tasks

Alguns exemplos conhecidos de macro tasks são: setTimeout, I/O, setInterval. Segundo a especificação do WHATWG somente uma macro task deve ser processada em um ciclo do Event Loop.

Micro tasks

Alguns exemplos conhecidos de micro tasks são as promises e o process.nextTick. As micro tasks normalmente são tarefas que devem ser executadas rapidamente após alguma ação, ou realizar algo assíncrono sem a necessidade de inserir uma nova task na task queue. A especificação do WHATWG diz que após o Event Loop processar a macro task da task queue todas as micro tasks disponíveis devem ser processadas e, caso elas chamem outras micro tasks, essas também devem ser resolvidas para que somente então ele chame a próxima macro task.

O exemplo abaixo demonstra como funciona esse fluxo:

Configuração do ambiente de desenvolvimento

Durante todo o livro a versão usada do Node.js será a 6.9.1 LTS (long term support). Para que seja possível usar as funcionalidades mais atuais do javascript será necessário o Ecmascript na versão 6 ES6 (ES2015 ou javascript 2015), aqui iremos chamar de ES6.

Como a versão do Node.js que usaremos não dá suporte inteiramente ao ES6 será necessário o uso de um transpiler que vai tornar possível a utilização de 100% das funcionalidades do ES6.

O que é um transpiler

Transpilers também são conhecidos como compiladores source-to-source. Usando um transpiler é possível escrever código utilizando as funcionalidade do ES6 ou versões mais novas e transformar o código final em um código suportado pela versão do Node.js que estaremos usando, no caso a 6.x. Um dos transpilers mais conhecidos do universo javascript é o Babel.js. Criado em 2015 por Sebastian McKenzie, o Babel permite utilizar as últimas funcionalidades do javascript e ainda assim executar o código em browser engines que ainda não as suportam nativamente, como no caso do v8 (engine do chrome na qual o Node.js roda), pois ele traduz o código gerado para uma forma entendível.

Gerenciamento de projeto e dependências

A maioria das linguagens possuem um gerenciador, tanto para automatizar tarefas, build, executar testes quanto para gerenciar dependencias. O javascript possui uma variada gama de gerenciadores, como o Grunt, Gulp e Brocoli para gerenciar e automatizar tarefas e o Bower para gerenciar dependencias de projetos front-end. Para o ambiente Node.js é necessário um gerenciador que também permita a automatização de tarefas e customização de scripts.

Nesse cenário entra o npm (Node Package Manager), criado por Isaac Z. Schlueter o npm foi adotado pelo Node.js e é instalado automaticamente junto ao Node. O npm registry armazena mais de 400,000 pacotes públicos e privados de milhares de desenvolvedores e empresas possibilitando a divisão e contribuição de pacotes entre a comunidade. O cliente do npm (interface de linha de comando) permite utilizar o npm para criar projetos, automatizar tarefas e gerenciar dependências.

Iniciando o projeto

Para iniciar um projeto em Node.js a primeira coisa a fazer é inicializar o npm no diretório onde ficará a aplicação. Para isso, primeiro certifique-se de já ter instalado o Node.js e o npm em seu computador, caso ainda não os tenha vá até o site do Node.js e faça o download https://nodejs.org/en/download/. Ele irá instalar automaticamente também o npm.

Configuração inicial

Crie o diretório onde ficará sua aplicação, após isso, dentro do diretório execute o seguinte comando:

1 $ npm init

Semelhante ao git, o npm inicializará um novo projeto nesse diretório, depois de executar o comando o npm vai apresentar uma série de perguntas (não é necessário respondê-las agora, basta pressionar enter. Você poderá editar o arquivo de configuração depois) como:

  1. name, referente ao nome do projeto.
  2. version, referente a versão.
  3. description, referente a descrição do projeto que está sendo criado.
  4. entry point, arquivo que será o ponto de entrada caso o projeto seja importado por outro.
  5. test command, comando que executará os testes de aplicação.
  6. git repository, repositório git do projeto.
  7. keywords, palavras chave para ajudar outros desenvolvedores a encontrar o seu projeto no npm.
  8. author, autor do projeto.
  9. license referente a licença de uso do código.

Após isso um arquivo chamado package.json será criado com o conteúdo semelhante a:

 1 {
 2   "name": "node-book",
 3     "version": "1.0.0",
 4     "description": "",
 5     "main": "index.js",
 6     "scripts": {
 7       "test": "echo \"Error: no test specified\" && exit 1"
 8     },
 9     "author": "",
10     "license": "ISC"
11 }

O package.json é responsável por guardar as configurações npm do nosso projeto, nele ficarão nossos scripts para executar a aplicação e os testes.

Configurando suporte ao Ecmascript 6

Como vimos anteriormente o Babel sera responsável por nos permitir usar as funcionalidades do ES6, para isso precisamos instalar os pacotes e configurar o nosso ambiente para suportar o ES6 por padrão em nossa aplicação. O primeiro passo é instalar os pacotes do Babel:

1 $ npm install --save-dev babel-cli

Após instalar o Babel é necessário instalar o preset que será usado, no nosso caso será o ES6:

1 $ npm install --save-dev babel-preset-node6

Note que sempre usamos –save-dev para instalar dependências referentes ao Babel pois ele não deve ser usado diretamente em produção, para produção vamos compilar o código, veremos isso mais adiante.

O último passo é informar para o Babel qual preset iremos usar, para isso basta criar um arquivo no diretório raiz da nossa aplicação chamado .babelrc com as seguintes configurações:

1 {
2   "presets": ["node6"]
3 }

Feito isso a aplicação já estará suportando 100% o ES6 e será possível utilizar as funcionalidades da versão. O código dessa etapa está disponivel neste link.

Configurando o servidor web

Como iremos desenvolver uma aplicação web precisaremos de um servidor que nos ajude a trabalhar com requisições HTTP, transporte de dados, rotas e etc. Existem muitas opções no universo Node.js como o Sails.js, Hapi.js e Koa.js. Vamos optar pelo Express.js por possuir um bom tempo de atividade, muito conteúdo na comunidade e é mantido pela Node Foundation.

O Express é um framework para desenvolvimento web para Node.js inspirado no Sinatra que é uma alternativa entre outros frameworks web para a linguagem ruby. Criado por TJ Holowaychuk o Express foi adquirido pela StrongLoop em 2014 e é administrado atualmente pela Node.js Foundation.

Para começar será necessário instalar dois modulos: o express e o body-parser. Conforme o exemplo a seguir:

1 $ npm install --save express body-parser

Quando uma requisição do tipo POST ou PUT é realizada, o corpo da requisição é transportado como texto. Para que seja possível transportar dados como JSON (JavaScript Object Notation) por exemplo existe o modulo body-parser que é um conjunto de middlewares para o express que analisa o corpo de uma requisição e transforma em algo definido, no nosso caso, em JSON.

Agora vamos criar um arquivo chamado server.js no diretório raiz e nele vamos fazer a configuração básica do express:

 1 import express from 'express';
 2 import bodyParser from 'body-parser';
 3 
 4 const app = express();
 5 app.use(bodyParser.json());
 6 
 7 app.get('/', (req, res) => res.send('Hello World!'));
 8 
 9 app.listen(3000, () => {
10   console.log('Example app listening on port 3000!');
11 });

A primeira instrução no exemplo acima é a importação dos módulos express e body-parser que foram instalados anteriormente. Em seguida uma nova instância do express é criada e associada a constante app. Para utilizar o body-parser é necessário configurar o express para utilizar o middleware, o express possui um método chamado use onde é possível passar middlewares como parâmetro, no código acima foi passado o bodyParser.json() responsável por transformar o corpo das requisições em JSON.

A seguir é criada uma rota, os verbos HTTP como GET, POST, PUT, DELETE são funções no express que recebem como parâmetro um padrão de rota, no caso acima /, e uma função de callback que será chamada quando a rota receber uma requisição. Os parametros req e res representam request (requisição) e response (resposta) e serão injetados automaticamente pelo express quando a requisição for recebida. Para finalizar, a função listen é chamada recebendo um número referente a porta na qual a aplicação ficará exposta, no nosso caso é a porta 3000.

O último passo é configurar o package.json para iniciar nossa aplicação, para isso vamos adicionar um script de start dentro do objeto scripts:

1 "scripts": {
2   "start": "babel-node ./server.js",
3   "test": "echo \"Error: no test specified\" && exit 1"
4 },

Alterado o package.json basta executar o comando:

1 $ npm start

Agora a aplicação estará disponível em http://localhost:3000/. O código dessa etapa está disponivel neste link.

Entendendo o Middleware pattern

O padrão de Middleware implementado pelo express já é bem conhecido e tem sido usado por desenvolvedores em outras linguagens há muitos anos. Podemos dizer que se trata de uma implementação do padrão intercepting filter pattern do chain of responsibility.

A implementação representa um pipeline de processamento onde handlers, units e filters são funções. Essas funções são conectadas criando uma sequência de processamento assíncrona que permite pré-processamento, processamento e pós-processamento de qualquer tipo de dado.

Uma das principais vantagens desse pattern é a facilidade de adicionar plugins de maneira não intrusiva.

O diagrama abaixo representa a implementação do Middleware pattern:

O primeiro componente que devemos observar no diagrama acima é o Middleware Manager, ele é responsável por organizar e executar as funções. Alguns dos detalhes mais importantes dessa implementação são:

  • Novos middlewares podem ser invocados usando a função use() (o nome não precisa ser estritamente use, aqui estamos usando o express como base). Geralmente novos middlewares são adicionados ao final do pipeline, mas essa não é uma regra obrigatória.
  • Quando um novo dado é recebido para processamento, o middleware registrado é invocado em um fluxo de execução assíncrono. Cada unidade no pipeline recebe o resultado da anterior como input.
  • Cada pedaço do middleware pode decidir parar o processamento simplesmente não chamando o callback, ou em caso de erro, passando o erro por callback. Normalmente erros disparam um fluxo diferente de processamento que é dedicado ao tratamento de erros.

O exemplo abaixo mostra um caminho de erro:

No express, por exemplo, o caminho padrão espera os parâmetros request, response e next, caso receba um quarto parâmetro, que normalmente é nomeado como error, ele vai buscar um caminho diferente.

Não há restrições de como os dados são processados ou propagados no pipeline. Algumas estratégias são:

  • Incrementar os dados com propriedades ou funções.
  • Substituir os dados com o resultado de algum tipo de processamento.
  • Manter a imutabilidade dos dados sempre retornando uma cópia como resultado do processamento.

A implementação correta depende de como o Middleware Manager é implementado e do tipo de dados que serão processados no próprio middleware. Para saber mais sobre o pattern sugiro a leitura do livro Node.js Design Patterns.

Middlewares no Express

O exemplo a seguir mostra uma aplicação express simples, com uma rota que devolve um “Hello world” quando chamada:

 1 const express = require('express');
 2 
 3 const app = express();
 4 
 5 app.get('/', function(req, res, next) {
 6 	console.log('route / called');
 7   res.send('Hello World!');
 8 });
 9 
10 app.listen(3000, () => {
11 	console.log('app is running');
12 });

Agora vamos adicionar uma mensagem no console que deve aparecer antes da mensagem da rota:

 1 const express = require('express');
 2 
 3 const app = express();
 4 
 5 app.use((req, res, next) => {
 6   console.log('will run before any route');
 7   next();
 8 });
 9 
10 app.get('/', function(req, res, next) {
11 	console.log('route / called');
12   res.send('Hello World!');
13 });
14 
15 app.listen(3000, () => {
16 	console.log('app is running');
17 });

Middlewares são apenas funções que recebem os parâmetros requisição (req), resposta (res) e próximo (next), executam alguma lógica e chamam o próximo middleware chamando next. No exemplo acima chamamos o use passando uma função que será o middleware, ela mostra a mensagem no console e depois chama o next().

Se executarmos esse código e acessarmos a rota / a saída no terminal será:

1 app is running
2 will run before any route
3 route / called

Ok! Mas como eu sabia que iria executar antes? Como vimos anteriormente no middleware pattern, o middleware manager executa uma sequência de middlewares, então a ordem do use interfere na execução, por exemplo, se invertermos a ordem, como no código abaixo:

 1 const express = require('express');
 2 
 3 const app = express();
 4 
 5 
 6 app.get('/', function(req, res, next) {
 7 	console.log('route / called');
 8   res.send('Hello World!');
 9 });
10 
11 app.use((req, res, next) => {
12 	console.log('will run before any route');
13 	next();
14 });
15 
16 app.listen(3000, () => {
17 	console.log('app is running');
18 });

A saida será:

1 app is running
2 route / called

Dessa vez o nosso middleware não foi chamado, isso acontece porque a rota chama a função res.send() invés de next(), ou seja, ela quebra a sequência de middlewares.

Também é possível usar middlewares em rotas específicas, como abaixo:

 1 const express = require('express');
 2 
 3 const app = express();
 4 
 5 app.use('/users', (req, res, next) => {
 6 	console.log('will run before users route');
 7 	next();
 8 });
 9 
10 app.get('/', function(req, res, next) {
11 	console.log('route / called');
12   res.send('Hello World!');
13 });
14 
15 app.get('/users', function(req, res, next) {
16 	console.log('route /users called');
17 	res.send('Hello World!');
18 });
19 
20 app.listen(3000, () => {
21 	console.log('app is running');
22 });

Caso seja feita uma chamada para / a saída sera:

1 app is running
2 route / called

Já para /users veremos a seguinte saída no terminal:

1 app is running
2 will run before users route
3 route /users called

O express também possibilita ter caminhos diferentes em caso de erro:

 1 const express = require('express');
 2 
 3 const app = express();
 4 
 5 app.use((req, res, next) => {
 6 	console.log('will run before any route');
 7 	next();
 8 });
 9 
10 app.use((err, req, res, next) => {
11 	console.log('something goes wrong');
12 	res.status(500).send(err.message);
13 });
14 
15 app.get('/', function(req, res, next) {
16 	console.log('route / called');
17   res.send('Hello World!');
18 });
19 
20 
21 app.listen(3000, () => {
22 	console.log('app is running');
23 });

Não mudamos nada no código de exemplo, apenas adicionamos mais um middleware que recebe o parâmetro err. Executando o código teremos a seguinte saída:

1 app is running
2 will run before any route
3 route / called

Apenas o primeiro middleware foi chamado, o middleware de erro não. Vamos ver o que acontece quando passamos um erro para o next do primeiro middleware.

 1 const express = require('express');
 2 
 3 const app = express();
 4 
 5 app.use((req, res, next) => {
 6 	console.log('will run before any route');
 7 	next(new Error('failed!'));
 8 });
 9 
10 app.use((err, req, res, next) => {
11 	console.log('something goes wrong');
12 	res.status(500).send(err.message);
13 });
14 
15 app.get('/', function(req, res, next) {
16 	console.log('route / called');
17   res.send('Hello World!');
18 });
19 
20 
21 app.listen(3000, () => {
22 	console.log('app is running');
23 });

A saída será:

1 app is running
2 will run before any route
3 something goes wrong

Essas são as formas mais comuns de utilizar middlewares, mas suas combinações são infinitas. Ao decorrer do livro utilizaremos middlewares para casos distintos.

Desenvolvimento guiado por testes

Agora que vamos começar a desenvolver nossa aplicação precisamos garantir que a responsabilidade, as possíveis rotas, as requisições e as respostas estão sendo atendidas; que estamos entregando o que prometemos e que está tudo funcionando. Para isso, vamos seguir um modelo conhecido como TDD (Test Driven Development ou Desenvolvimento Guiado por Testes).

TDD - Test Driven Development

O TDD é um processo de desenvolvimento de software que visa o feedback rápido e a garantia de que o comportamento da aplicação está cumprindo o que é requerido. Para isso, o processo funciona em ciclos pequenos e os requerimentos são escritos como casos de teste.

A prática do TDD aumentou depois que Kent Beck publicou o livro TDD - Test Driven Development e fomentou a discussão em torno do tema. Grandes figuras da comunidade ágil como Martin Fowler também influenciaram na adoção dessa prática publicando artigos, ministrando palestras e compartilhando cases de sucesso.

Os ciclos do TDD

Quando desenvolvemos guiados por testes, o teste acaba se tornando uma consequência do processo, ja que vai ser ele que vai determinar o comportamento esperado da implementação. Para que seja possível validar todas as etapas, o TDD se divide em ciclos que seguem um padrão conhecido como: Red, Green, Refactor.

Red

Significa escrever o teste antes da funcionalidade e executá-lo. Nesse momento, como a funcionalidade ainda não foi implementada, o teste deve quebrar. Essa fase também serve para verificar se não há erros na sintáxe e na semântica.

Green

Refere-se a etapa em que a funcionalidade é adicionada para que o teste passe. Nesse momento ainda não é necessário ter a lógica definida mas é importante atender aos requerimentos do teste. Aqui podem ser deixados to-dos, dados estáticos, fixmes, ou seja, o suficiente para o teste passar.

Refactor

É onde se aplica a lógica necessária. Como o teste já foi validado nos passos anteriores, o refactor garantirá que a funcionalidade está sendo implementada corretamente. Nesse momento devem ser removidos os dados estáticos e todos itens adicionadas apenas para forçar o teste a passar, em seguida deve ser feita a implementação real para que o teste volte a passar. A imagem abaixo representa o ciclo do TDD:

A pirâmide de testes

A pirâmide de testes é um conceito criado por Mike Cohn, escritor do livro Succeeding with Agile. O livro propõe que hajam mais testes de baixo nível, ou seja, testes de unidade, depois testes de integração e no topo os testes que envolvem interface.

O autor observa que os testes de interface são custosos, para alguns testes é necessário inclusive licença de softwares. Apesar de valioso, esse tipo de teste necessita da preparação de todo um ambiente para rodar e tende a ocupar muito tempo. O que Mike defende é ter a base do desenvolvimento com uma grande cobertura de testes de unidade; no segundo nível, garantir a integração entre os serviços e componentes com testes de integração, sem precisar envolver a interface do usuário. E no topo, possuir testes que envolvam o fluxo completo de interação com a UI, para validar todo o fluxo.

Vale lembrar que testes de unidade e integração podem ser feitos em qualquer parte da aplicação, tanto no lado do servidor quanto no lado do cliente, isso elimina a necessidade de ter testes complexos envolvendo todo o fluxo.

Os tipos de testes

Atualmente contamos com uma variada gama de testes, sempre em crescimento de acordo com o surgimento de novas necessidades. Os mais comuns são os teste de unidade e integração, nos quais iremos focar aqui.

Testes de unidade (Unit tests)

Testes de unidade são a base da pirâmide de testes. Segundo Martin Fowler testes unitários são de baixo nível, com foco em pequenas partes do software e tendem a ser mais rapidamente executados quando comparados com outros testes, pois testam partes isoladas.

Mas o que é uma unidade afinal? Esse conceito é divergente e pode variar de projeto, linguagem, time e paradigma de programação. Linguagens orientadas a objeto tendem a ter classes como uma unidade, já linguagens procedurais ou funcionais consideram normalmente funções como sendo uma unidade. Essa definição é algo muito relativo e depende do contexto e do acordo dos desenvolvedores envolvidos no processo. Nada impede que um grupo de classes relacionadas entre sí ou funções, sejam uma unidade.

No fundo, o que define uma unidade é o comportamento e a facilidade de ser isolada das suas dependências (dependências podem ser classes ou funções que tenham algum tipo de interação com a unidade). Digamos que, por exemplo, decidimos que as nossas unidade serão as classes e estamos testando uma função da classe Billing que depende de uma função da classe Orders. A imagem abaixo ilustra a dependência:

Para testar unitariamente é necessário isolar a classe Billing da sua dependência, a classe Orders, como na imagem a seguir:

Esse isolamento pode ser feito de diversas maneiras, por exemplo utilizando mocks ou stubs ou qualquer outra técnica de substituição de dependência e comportamento. O importante é que seja possível isolar a unidade e ter o comportamento esperado da dependência.

Testes de integração (Integration tests)

Testes de integração servem para verificar se a comunicação entre os componentes de um sistema está ocorrendo conforme o esperado. Diferente dos testes de unidade, onde a unidade é isolada de duas dependências, no teste de integração deve ser testado o comportamento da interação entre as unidades. Não há um nível de granularidade específico, a integração pode ser testada em qualquer nível, seja a interação entre camadas, classes ou até mesmo serviços.

No exemplo a seguir temos uma arquitetura comum de aplicações Node.js e desejamos testar a integração entre as rotas, controllers, models e banco de dados:

Nossa integração pode ser desde a rota até salvar no banco de dados (nesse caso, MongoDB), dessa maneira é possível validar todo o fluxo até o dado ser salvo no banco, como na imagem a seguir:

Esse teste é custoso porém imprescindível. Será necessário limpar o banco de dados a cada teste e criar os dados novamente, além de custar tempo e depender de um serviço externo como o MongoDB. Um grau de interação desse nível terá vários possíveis casos de teste, como por exemplo: o usuário mandou um dado errado e deve receber um erro de validação. Para esse tipo de cenário pode ser melhor diminuir a granularidade do teste para que seja possível ter mais casos de teste. Para um caso onde o controller chama o model passando dados inválidos e a válidação deve emitir um erro, poderíamos testar a integração entre o controller e o model, como no exemplo a seguir:

Nesse exemplo todos os componentes do sistema são facilmente desacopláveis, podem haver casos onde o model depende diretamente do banco de dados e como queremos apenas testar a validação não precisamos inserir nada no banco, nesse caso é possível substituir o banco de dados ou qualquer outra dependência por um mock ou stub para reproduzir o comportamento de um banco de dados sem realmente chamar o banco.

Teste de integração de contrato (Integration contract tests)

Testes de contrato ganharam muita força devido ao crescimento das APIs e dos micro serviços. Normalmente, quando testamos a nossa aplicação, mesmo com o teste de integração, tendemos a não usar os serviços externos e sim um substituto que devolve a resposta esperada. Isso por que serviços externos podem afetar no tempo de resposta da requisição, podem cair, aumentar o custo e isso pode afetar nossos testes. Mas por outro lado, quando isolamos nossa aplicação dos outros serviços para testar ficamos sem garantia de que esses serviços não mudaram suas APIs e que a resposta esperada ainda é a mesma. Para solucionar esses problemas existem os testes de contrato.

A definição de um contrato

Sempre que consumimos um serviço externo dependemos de alguma parte dele ou de todos os dados que ele provém e o serviço se compromete a entregar esses dados. O exemplo abaixo mostra um teste de contrato entre a aplicação e um serviço externo, nele é verificado se o contrato entre os dois ainda se mantém o mesmo.

É importante notar que o contrato varia de acordo com a necessidade, nesse exemplo a nossa aplicação depende apenas dos campos email e birthday então o contrato formado entre eles verifica apenas isso. Se o name mudar ele não quebrará nossa aplicação nem o contrato que foi firmado. Em testes de contrato o importante é o tipo e não o valor. No exemplo verificamos se o email ainda é String e se o campo birthday ainda é do tipo Date, dessa maneira garantimos que a nossa aplicação não vai quebrar. O exemplo a seguir mostra um contrato quebrado onde o campo birthday virou born, ou seja, o serviço externo mudou o nome do campo, nesse momento o contrato deve quebrar.

Testes de contrato possuem diversas extensões, o caso acima é chamado de consumer contract onde o consumidor verifica o contrato e, caso o teste falhe, notifica o provider (provedor) ou altera sua aplicação para o novo contrato. Também existe o provider contracts onde o próprio provedor testa se as alterações feitas irão quebrar os consumidores.

Test Doubles

Testar código com ajax, network, timeouts, banco de dados e outras dependências que produzem efeitos colaterais é sempre complicado. Por exemplo, quando se usa ajax, ou qualquer outro tipo de networking, é necessário comunicar com um servidor que irá responder para a requisição; já com o banco de dados será necessário inicializar um serviço para tornar possível o teste da aplicação: limpar e criar tabelas para executar os testes e etc.

Quando as unidades que estão sendo testadas possuem dependências que produzem efeitos colaterais, como os exemplos acima, não temos garantia de que a unidade está sendo testada isoladamente. Isso abre espaço para que o teste quebre por motivos não vinculados a unidade em sí, como por exemplo o serviço de banco não estar disponível ou uma API externa retornar uma resposta diferente da esperada no teste.

Há alguns anos atrás Gerard Meszaros publicou o livro XUnit Test Patterns: Refactoring Test Code e introduziu o termo Test Double (traduzido como “dublê de testes”) que nomeia as diferentes maneiras de substituir dependências. A seguir vamos conhecer os mais comuns test doubles e quais são suas características, prós e contras.

Para facilitar a explicação será utilizado o mesmo exemplo para os diferentes tipos de test doubles, também será usada uma biblioteca de suporte chamada Sinon.js que possibilita a utilização de stubs, mocks e spies.

A controller abaixo é uma classe que recebe um banco de dados como dependência no construtor. O método que iremos testar unitariamente dessa classe é o método getAll, ele retorna uma consulta do banco de dados com uma lista de usuários.

 1 const Database = {
 2   findAll() {}
 3 }
 4 
 5 class UsersController {
 6   constructor(Database) {
 7     this.Database = Database;
 8   }
 9 
10   getAll() {
11     return this.Database.findAll('users');
12   }
13 }

Fake

Durante o teste, é frequente a necessidade de substituir uma dependência para que ela retorne algo específico, independente de como for chamada, com quais parâmetros, quantas vezes, a resposta sempre deve ser a mesma. Nesse momento a melhor escolha são os Fakes. Fakes podem ser classes, objetos ou funções que possuem uma resposta fixa independente da maneira que forem chamadas. O exemplo abaixo mostra como testar a classe UsersController usando um fake:

 1 describe('UsersController getAll()', () => {
 2   it('should return a list of users', () => {
 3     const expectedDatabaseResponse = [{
 4       id: 1,
 5       name: 'John Doe',
 6       email: 'john@mail.com'
 7     }];
 8 
 9     const fakeDatabase = {
10       findAll() {
11         return expectedDatabaseResponse;
12       }
13     }
14     const usersController = new UsersController(fakeDatabase);
15     const response = usersController.getAll();
16 
17     expect(response).to.be.eql(expectedDatabaseResponse);
18   });
19 });

Nesse caso de teste não é necessária nenhuma biblioteca de suporte, tudo é feito apenas criando um objeto fake para substituir a dependência do banco de dados. O método findAll passa a ter uma resposta fixa, que é uma lista com um usuário. Para validar o teste é necessário verificar se a resposta do método getAll do controller responde com uma lista igual a declarada no expectedDatabaseResponse.

Vantagens:

  • Simples de escrever
  • Não necessita de bibliotecas de suporte
  • Desacoplado da dependencia original

Desvantagens:

  • Não possibilita testar múltiplos casos
  • Só é possível testar se a saída está como esperado, não é possível validar o comportamento interno da unidade

Quando usar fakes:

Fakes devem ser usados para testar dependências que não possuem muitos comportamentos.

Spy

Como vimos anteriormente os fakes permitem substituir uma dependência por algo customizado mas não possibilitam saber, por exemplo, quantas vezes uma função foi chamada, quais parâmetros ela recebeu e etc. Para isso existem os spies, como o próprio nome já diz, eles gravam informações sobre o comportamento do que está sendo “espionado”. No exemplo abaixo é adicionado um spy no método findAll do Database para verificar se ele está sendo chamado com os parâmetros corretos:

 1 describe('UsersController getAll()', () => {
 2   it('should database findAll with correct parameters', () => {
 3     const findAll = sinon.spy(Database, 'findAll');
 4 
 5     const usersController = new UsersController(Database);
 6     usersController.getAll();
 7 
 8     sinon.assert.calledWith(findAll, 'users');
 9     findAll.restore();
10   });
11 });

Note que é adicionado um spy na função findAll do Database, dessa maneira o Sinon devolve uma referência a essa função e também adiciona alguns comportamentos a ela que possibilitam realizar checagens como sinon.assert.calledWith(findAll, ‘users’) onde é verificado se a função foi chamada com o parâmetro esperado.

Vantagens:

  • Permite melhor assertividade no teste
  • Permite verificar comportamentos internos
  • Permite integração com dependências reais

Desvantagens:

  • Não permitem alterar o comportamento de uma dependência
  • Não é possível verificar múltiplos comportamentos ao mesmo tempo

Quando usar spies:

Spies podem ser usados sempre que for necessário ter assertividade de uma dependência real ou, como em nosso caso, em um fake. Para casos onde é necessário ter muitos comportamos é provável que stubs e mocks venham melhor a calhar.

Stub

Fakes e spies são simples e substituem uma dependência real com facilidade, como visto anteriormente, porém, quando é necessário representar mais de um cenário para a mesma dependência eles podem não dar conta. Para esse cenário entram na jogada os Stubs. Stubs são spies que conseguem mudar o comportamento dependendo da maneira em que forem chamados, veja o exemplo abaixo:

 1 describe('UsersController getAll()', () => {
 2   it('should return a list of users', () => {
 3     const expectedDatabaseResponse = [{
 4       id: 1,
 5       name: 'John Doe',
 6       email: 'john@mail.com'
 7     }];
 8 
 9     const findAll = sinon.stub(Database, 'findAll');
10     findAll.withArgs('users').returns(expectedDatabaseResponse);
11 
12     const usersController = new UsersController(Database);
13     const response = usersController.getAll();
14 
15     sinon.assert.calledWith(findAll, 'users');
16     expect(response).to.be.eql(expectedDatabaseResponse);
17     findAll.restore();
18   });
19 });

Quando usamos stubs podemos descrever o comportamento esperado, como nessa parte do código:

1 findAll.withArgs('users').returns(expectedDatabaseResponse);

Quando a função findAll for chamada com o parâmetro users, retorna a resposta padrão.

Com stubs é possível ter vários comportamentos para a mesma função com base nos parâmetros que são passados, essa é uma das maiores diferenças entre stubs e spies.

Como dito anteriormente, stubs são spies que conseguem alterar o comportamento. É possível notar isso na asserção sinon.assert.calledWith(findAll, ‘users’) ela é a mesma asserção do spy anterior. Nesse teste são feitas duas asserções, isso é feito apenas para mostrar a semelhança com spies, múltiplas asserções em um mesmo caso de teste é considerado uma má prática.

Vantagens:

  • Comportamento isolado
  • Diversos comportamentos para uma mesma função
  • Bom para testar código assíncrono

Desvantagens:

  • Assim como spies não é possível fazer múltiplas verificações de comportamento

Quando usar stubs:

Stubs são perfeitos para utilizar quando a unidade tem uma dependência complexa, que possui múltiplos comportamentos. Além de serem totalmente isolados os stubs também tem o comportamento de spies o que permite verificar os mais diferentes tipos de comportamento.

Mock

Mocks e stubs são comumente confundidos pois ambos conseguem alterar comportamento e também armazenar informações. Mocks também podem ofuscar a necessidade de usar stubs pois eles podem fazer tudo que stubs fazem. O ponto de grande diferença entre mocks e stubs é sua responsabilidade: stubs tem a responsabilidade de se comportar de uma maneira que possibilite testar diversos caminhos do código, como por exemplo uma resposta de uma requisição http ou uma exceção; Já os mocks substituem uma dependência permitindo a verificação de múltiplos comportamentos ao mesmo tempo. O exemplo a seguir mostra a classe UsersController sendo testada utilizando Mock:

 1 describe('UsersController getAll()', () => {
 2   it('should call database with correct arguments', () => {
 3     const databaseMock = sinon.mock(Database);
 4     databaseMock.expects('findAll').once().withArgs('users');
 5 
 6     const usersController = new UsersController(Database);
 7     usersController.getAll();
 8 
 9     databaseMock.verify();
10     databaseMock.restore();
11   });
12 });

A primeira coisa a se notar no código é a maneira de fazer asserções com Mocks, elas são descritas nessa parte:

1 databaseMock.expects('findAll').once().withArgs('users');

Nela são feitas duas asserções, a primeira para verificar se o método findAll foi chamado uma vez e na segunda se ele foi chamado com o argumento users, em seguida o código é executado e é chamada a função verify() do Mock que irá verificar se as expectativas foram atingidas.

Vantagens:

  • Verificação interna de comportamento
  • Diversas asserções ao mesmo tempo

Desvantagens:

  • Diversas asserções ao mesmo tempo podem tornar o teste difícil de entender.

Quando usar mocks:

Mocks são úteis quando é necessário verificar múltiplos comportamentos de uma dependência. Isso também pode ser sinal de um design de código mal pensado, onde a unidade tem muita responsabilidade. É necessário ter muito cuidado ao usar Mocks já que eles podem tornar os testes pouco legíveis.

O ambiente de testes em javascript

Diferente de muitas linguagens que contam com ferramentas de teste de forma nativa ou possuem algum xUnit (JUnit, PHPUnit, etc) no javascript temos todos os componentes das suites de testes separados, o que nos permite escolher a melhor combinação para a nossa necessidade (mas também pode criar confusão). Em primeiro lugar precisamos conhecer os componentes que fazem parte de uma suíte de testes em javascript:

Test runners

Test runners são responsáveis por importar os arquivos de testes e executar os casos de teste. Eles esperam que cada caso de teste devolva true ou false. Alguns dos test runners mais conhecidos de javascript são o Mocha e o Karma.

Bibliotecas de Assert

Alguns test runners possuem bibliotecas de assert por padrão, mas é bem comum usar uma externa. Bibliotecas de assert verificam se o teste está cumprindo com o determinado fazendo a afirmação e respondendo com true ou false para o runner. Algumas das bibliotecas mais conhecidas são o chai e o assert.

Bibliotecas de suporte

Somente executar os arquivos de teste e fazer o assert nem sempre é o suficiente. Pode ser necessário substituir dependências, subir servidores fake, alterar o DOM e etc. Para isso existem as bibliotecas de suporte. As bibliotecas de suporte se separam em diversas responsabilidades, como por exemplo: para fazer mocks e spys temos o SinonJS e o TestDoubleJS já para emular servidores existe o supertest.

Configurando testes de integração

Iremos testar de fora para dentro, ou seja, começaremos pelos testes de integração e seguiremos para os testes de unidade.

Instalando Mocha, Chai e Supertest

Para começar vamos instalar as ferramentas de testes com o comando abaixo:

1 $ npm install --save-dev mocha chai supertest

Vamos instalar três módulos:

  • Mocha: módulo que ira executar as suites de teste.
  • Chai: módulo usado para fazer asserções.
  • Supertest: módulo usado para emular e abstrair requisições http.

Separando execução de configuração

Na sequência, será necessário alterar a estrutura de diretórios da nossa aplicação atual, criando um diretório chamado src, onde ficará o código fonte. Dentro dele iremos criar um arquivo chamado app.js que terá a responsabilidade de iniciar o express e carregar os middlewares. Ele ficará assim:

1 import express from 'express';
2 import bodyParser from 'body-parser';
3 
4 const app = express();
5 app.use(bodyParser.json());
6 
7 app.get('/', (req, res) => res.send('Hello World!'));
8 
9 export default app;

Aqui copiamos o código do server.js e removemos a parte do app.listen, a qual iniciava a aplicação, e adicionamos o export default app para exportar o app como um módulo.
Agora precisamos alterar o server.js no diretório raiz para que ele utilize o app.js:

1 import app from './src/app';
2 const port = 3000;
3 
4 app.listen(port, () => {
5   console.log(`app running on port ${port}`);
6 });

Note que agora separamos a responsabilidade de inicializar o express e carregar os middlewares da parte de iniciar a aplicação em sí. Como nos testes a aplicação será inicializada pelo supertest e não pelo express como é feito no server.js, esse separação torna isso fácil.

Configurando os testes

Agora que a aplicação está pronta para ser testada, vamos configurar os testes. O primeiro passo é criar o diretório test na raiz do projeto, e dentro dele o diretório onde ficarão os testes de integração, vamos chamar esse diretório de integration.
A estrutura de diretórios ficará assim:

1 ├── package.json
2 ├── server.js
3 ├── src
4 │   └── app.js
5 └── test
6 │   └── integration

Dentro de integration vamos criar os arquivos de configuração para os testes de integração. O primeiro será referente as configurações do Mocha, vamos criar um arquivo chamado mocha.opts dentro do diretório integration com o seguinte código:

1 --require test/integration/helpers.js
2 --reporter spec
3 --compilers js:babel-core/register
4 --slow 5000

O primeiro require será o arquivo referente as configurações de suporte para os testes, o qual criaremos a seguir. Na linha seguinte definimos qual será o reporter, nesse caso, o spec. Reporters definem o estilo da saída do teste no terminal.
Na terceira linha definimos os compilers, como iremos usar ES6, também nos testes usaremos o compiler do babel no Mocha. E na última linha o slow referente a demora máxima que um caso de teste pode levar, como testes de integração tendem a depender de agentes externos como banco de dados e etc, é necessário ter um tempo maior de slow para eles.

O próximo arquivo que iremos criar nesse mesmo diretório é o helpers.js. Ele terá o seguinte código:

1 import supertest from 'supertest';
2 import chai from 'chai';
3 import app from '../../src/app.js';
4 
5 global.app = app;
6 global.request = supertest(app);
7 global.expect = chai.expect;

O arquivo helpers é responsável por inicializar as configurações de testes que serão usadas em todos os testes de integração, removendo a necessidade de ter de realizar configurações em cada cenário de teste.

Primeiro importamos os módulos necessários para executar os testes de integração que são o supertest e o chai e também a nossa aplicação express que chamamos de app.
Depois definimos as globais usando global. Globais fazem parte do Mocha, tudo que for definido como global poderá ser acessado em qualquer teste sem a necessidade de ser importado.
No nosso arquivo helpers configuramos o app para ser global, ou seja, caso seja necessário usá-lo em um caso de teste basta chamá-lo diretamente. Também é definido um global chamado request, que é o supertest recebendo o express por parâmetro.
Lembram que falei da vantagem de separar a execução da aplicação da configuração do express? Agora o express pode ser executado por um emulador como o supertest.
E por último o expect do Chai que será utilizado para fazer as asserções nos casos de teste.

Criando o primeiro caso de teste

Com as configurações finalizadas agora nos resta criar nosso primeiro caso de teste. Vamos criar um diretório chamado routes dentro do diretório integration e nele vamos criar o arquivo products_spec.js que vai receber o teste referente as rotas do recurso products da nossa API.
A estrutura de diretórios deve estar assim:

 1 ├── package.json
 2 ├── server.js
 3 ├── src
 4 │   └── app.js
 5 └── test
 6 │  └── integration
 7 │    ├── helpers.js
 8 │    ├── mocha.opts
 9 │    └── routes
10 │      └── products_spec.js

Agora precisamos escrever nosso caso de teste, vamos começar com o seguinte código no arquivo products_spec.js:

1 describe('Routes: Products', () => {
2 
3 });

O describe é uma global do Mocha usada para descrever suítes de testes que contém um ou mais casos de testes e/ou contém outras suítes de testes. Como esse é o describe que irá englobar todos os testes desse arquivo seu texto descreve a responsabilidade geral da suíte de testes que é testar a rota products.
Agora vamos adicionar um produto padrão para os nossos testes:

1 describe('Routes: Products', () => {
2   const defaultProduct = {
3     name: 'Default product',
4     description: 'product description',
5     price: 100
6   };
7 });

Como a maioria dos testes precisará de um produto, tanto para inserir quanto para verificar nas buscas, criamos uma constante chamada defaultProduct para ser reusada pelos casos de teste.
O próximo passo é descrever a nossa primeira suíte de testes:

 1 describe('Routes: Products', () => {
 2   const defaultProduct = {
 3     name: 'Default product',
 4     description: 'product description',
 5     price: 100
 6   };
 7 
 8   describe('GET /products', () => {
 9     it('should return a list of products', done => {
10 
11     });
12   });
13 });

Adicionamos mais um describe para deixar claro que todas as suítes de teste dentro dele fazem parte do método http GET na rota /products. Isso facilita a legibilidade do teste e deixa a saída do terminal mais clara.

A função it também é uma global do Mocha e é responsável por descrever um caso de teste.
Descrições de casos de teste seguem um padrão declarativo, como no exemplo acima: “Isso deve retornar uma lista de produtos”.
Note que também é passado um parâmetro chamado done para o caso de teste, isso ocorre porque testes que executam funções assíncronas, como requisições http, precisam informar ao Mocha quando o teste finalizou e fazem isso chamando a função done.
Vejamos na implementação a seguir:

 1 describe('Routes: Products', () => {
 2   const defaultProduct = {
 3     name: 'Default product',
 4     description: 'product description',
 5     price: 100
 6   };
 7 
 8   describe('GET /products', () => {
 9     it('should return a list of products', done => {
10 
11       request
12       .get('/products')
13       .end((err, res) => {
14         expect(res.body[0]).to.eql(defaultProduct);
15         done(err);
16       });
17     });
18   });
19 });

Na implementação do teste usamos o supertest que exportamos globalmente como request no helpers.js. O supertest nos permite fazer uma requisição http para uma determinada rota e verificar a sua resposta.
Quando a requisição terminar a função end será chamada pelo supertest e vai receber a resposta ou um erro, caso ocorra. No exemplo acima é verificado se o primeiro elemento da lista de produtos retornada é igual ao nosso defaultProduct.
O expect usado para fazer a asserção faz parte do Chai e foi exposto globalmente no helpers.js.
Para finalizar, notificamos o Mocha que o teste finalizou chamando a função done que recebe err como parâmetro, caso algum erro ocorra ele irá mostrar a mensagem de erro no terminal.

Executando os testes

Escrito nosso teste, vamos executá-lo. Para automatizar a execução vamos adicionar a seguinte linha no package.json dentro de scripts:

1 "test:integration": "NODE_ENV=test mocha --opts test/integration/mocha.opts test\
2 /integration/**/*_spec.js"

Estamos adicionando uma variável de ambiente como test, que além de boa prática também nos será útil em seguida, e na sequência as configurações do Mocha.

Para executar os testes agora basta executar o seguinte comando no terminal, dentro do diretório root da aplicação:

1 $ npm run test:integration

A saída deve ser a seguinte:

 1 Routes: Products
 2 GET /products
 3 1) should return a list of products
 4 
 5 0 passing (172ms)
 6   1 failing
 7 
 8 
 9   1) Routes: Products GET /products should return a list of products:
10   Uncaught AssertionError: expected undefined to deeply equal { Object (name, de\
11 scription, ...) }

Isso quer dizer que o teste está implementado corretamente, sem erros de sintaxe por exemplo, mas está falhando pois ainda não temos esse comportamento na aplicação. Essa é a etapa RED do TDD, conforme vimos anteriormente.

Fazendo os testes passarem

Escrevemos nossos testes e eles estão no estado RED, ou seja, implementados mas não estão passando. O próximo passo, seguindo o TDD, é o GREEN onde vamos implementar o mínimo para fazer o teste passar.
Para isso, precisamos implementar uma rota na nossa aplicação que suporte o método http GET e retorne uma lista com, no mínimo, um produto igual ao nosso defaultProduct do teste. Vamos alterar o arquivo app.js e adicionar a seguinte rota:

1 app.get('/products', (req, res) => res.send([{
2   name: 'Default product',
3   description: 'product description',
4   price: 100
5 }]));

Como vimos no capítulo sobre os middlewares do express, os objetos de requisição (req) e resposta (res) são injetados automaticamente pelo express nas rotas. No caso acima usamos o método send do objeto de resposta para enviar uma lista com um produto como resposta da requisição, o que deve ser suficiente para que nosso teste passe.
Com as alterações o app.js deve estar assim:

 1 import express from 'express';
 2 import bodyParser from 'body-parser';
 3 
 4 const app = express();
 5 app.use(bodyParser.json());
 6 
 7 app.get('/', (req, res) => res.send('Hello World!'));
 8 app.get('/products', (req, res) => res.send([{
 9   name: 'Default product',
10   description: 'product description',
11   price: 100
12 }]));
13 
14 export default app;

Agora que já temos a implementação, podemos executar nosso teste novamente:

1 $ npm run test:integration

A saída deve ser de sucesso, como essa:

1 Routes: Products
2 GET /products
3 ✓ should return a list of products
4 
5 
6 1 passing (164ms)

Nosso teste está passando, e estamos no estado GREEN do TDD, ou seja, temos o teste e a implementação suficiente para ele passar. O próximo passo será o REFACTOR onde iremos configurar as rotas.

O código dessa etapa está disponível neste link.

Estrutura de diretórios e arquivos

Um dos primeiros desafios quando começamos uma aplicação em Node.js é estruturar o projeto. Uma grande conveniência do Node, por ser javascript, é a liberdade para estrutura, design de código, patterns e etc. Porém, isso também pode gerar confusão para os novos desenvolvedores.

A maioria dos projetos no github, por exemplo, possuem estruturas que diferem entre si, essa variação acontece pois cada desenvolvedor cria a estrutura da forma que se enquadrar melhor a sua necessidade.

Mesmo assim podemos aproveitar os padrões comuns entre esses projetos para estruturar nossa aplicação de maneira que atenda as nossas necessidades e também fique extensível, legível e facilmente integrável com ferramentas externas, como Travis, CodeClimate e etc.

O diretório raiz

O diretório raiz do projeto é o ponto de entrada e fornece a primeira impressão. No exemplo a seguir, temos uma estrutura comum em aplicações usando o framework Express.js:

1 ├── app.js
2 ├── controllers
3 ├── middlewares
4 ├── models
5 ├── package.json
6 └── tests

Essa estrutura é legível e organizada, mas com o crescimento da aplicação pode misturar diretórios de código com diretórios de teste, build e etc. Um padrão comum em diversas linguagens é armazenar o código da aplicação em um diretório source normalmente chamado src.

1 ├── package.json
2 ├── server.js
3 ├── src
4 │   ├── controllers
5 │   ├── middlewares
6 │   └── models
7 │   └── app.js
8 └── tests

Dessa maneira o código da aplicação é isolado em um diretório deixando a raiz do projeto mais limpa e acabando com a mistura de diretórios de código com diretórios de testes e arquivos de configuração.

O que fica no diretório raiz?

No exemplo acima movemos o código da aplicação para o diretório src mas mantivemos o diretório tests, isso acontece porque testes são executados por linha de comando ou por outras ferramentas. Inclusive os test runners como mocha e karma esperam que o diretório tests esteja no diretório principal. Outros diretórios comumente localizados na raiz são scripts de suporte ou build, exemplos, documentação e arquivos estáticos. No exemplo abaixo vamos incrementar nossa aplicação com mais alguns diretórios:

 1 ├── env
 2 │   ├── dev.env
 3 │   └── prod.env
 4 ├── package.json
 5 ├── public
 6 │   ├── assets
 7 │   ├── css
 8 │   ├── images
 9 │   └── js
10 ├── scripts
11 │   └── deploy.sh
12 ├── server.js
13 ├── src
14 │   ├── app.js
15 │   ├── controllers
16 │   ├── middlewares
17 │   ├── models
18 │   └── routes
19 └── tests

O diretório public é responsável por guardar tudo aquilo que vai ser entregue para o usuário. Mantê-lo na raiz facilita a criação de rotas de acesso e a movimentação dos assets, caso necessário. Os diretórios scripts e env são relacionados a execução da aplicação e serão chamados por alguma linha de comando ou ferramenta externa, colocá-los em um diretório acessível promove a usabilidade.

Separação da execução e aplicação

No segundo passo, quando movemos o código para o diretório src, criamos um arquivo chamado app.js e mantemos o server.js no diretório raiz, dessa maneira deixamos o server.js com a responsabilidade de chamar o app.js e inicializar a aplicação. Assim isolamos a aplicação da execução e deixamos que ela seja executada por quem chamar, nesse caso o server.js, mas também poderia ser um módulo, como o supertest, que vai fazer uma abstração HTTP para executar os testes e acessar as rotas.

Dentro do diretório source

Agora que já entendemos o que fica fora do diretório src vamos ver como organizá-lo baseado nas nossas necessidades.

1 ├── src
2 │   ├── app.js
3 │   ├── controllers
4 │   ├── middlewares
5 │   ├── models
6 │   └── routes

Essa estrutura é bastante utilizada, ela é clara e separa as responsabilidades de cada componente, além de permitir o carregamento dinâmico.

Responsabilidades diferentes dentro de um mesmo source

Ás vezes, quando começamos uma aplicação, já sabemos o que será desacoplado e queremos dirigir nosso design para que no futuro seja possível separar e tornar parte do código um novo módulo. Outra necessidade comum é ter APIs específicas para diferentes tipos de clientes, como no exemplo a seguir:

 1 └── src
 2     ├── app.js
 3     ├── mobile
 4     │   ├── controllers
 5     │   ├── index.js
 6     │   ├── middlewares
 7     │   ├── models
 8     │   └── routes
 9     └── web
10         ├── controllers
11         ├── index.js
12         ├── middlewares
13         ├── models
14         └── routes

Esse cenário funciona bem mas pode dificultar o reúso de código entre os componentes. Então, antes de implementar, tenha certeza que seu caso de uso permite a separação dos clientes sem que um dependa do outro.

Server e client no mesmo repositório

Muitas vezes temos o backend e o front-end separados mas versionados juntos, no mesmo repositório, seja ele git, mercurial, ou qualquer outro controlador de versão. A estrutura mais comum que pude observar na comunidade para esse tipo de situação é separar o server e o client como no exemplo abaixo:

 1 ├── client
 2 │   ├── controllers
 3 │   ├── models
 4 │   └── views
 5 ├── client.js
 6 ├── config
 7 ├── package.json
 8 ├── server
 9 │   ├── controllers
10 │   ├── models
11 │   └── routes
12 ├── server.js
13 └── tests

Essa estrutura é totalmente adaptável ás necessidades. No exemplo acima, os testes de ambas as aplicações estão no diretório tests no diretório raiz. Assim, se o projeto for adicionado em uma integração contínua ele vai executar a bateria de testes de ambas as aplicações. O server.js e o client.js são responsáveis por iniciar as respectivas aplicações. Podemos ter um npm start no package.json que inicie os dois arquivos juntos.

Separação por funcionalidade

Um padrão bem frequente é o que promove a separação por funcionalidade. Nele abstraímos os diretórios baseado nas funcionalidades e não nas responsabilidades, como no exemplo abaixo:

1 └── src
2     ├── app.js
3     ├── orders
4     │   ├── orders.controller.js
5     │   └── orders.routes.js
6     └── products
7         ├── products.controller.js
8         ├── products.model.js
9         └── products.routes.js

Essa estrutura possui uma boa legibilidade e escalabilidade, por outro lado, pode crescer muito tornando o reúso de componentes limitado e dificultando o carregamento dinâmico de arquivos.

Conversão de nomes

Quando separamos os diretórios por suas responsabilidades pode não ser necessário deixar explícito a responsabilidade no nome do arquivo.

Veja o exemplo abaixo:

1 └── src
2     ├── controllers
3     │   └── products.js
4     └── routes
5         └── products.js

Como o nosso diretório é responsável por informar qual a responsabilidade dos arquivos que estão dentro dele, podemos nomear os arquivos sem adicionar o sufixo _ + nome do diretório (por exemplo: “_controller”). Além disso, o javascript permite nomear um módulo quando o importamos, permitindo que mesmo arquivos com o mesmo nome sejam facilmente distinguidos por quem está lendo o código, veja o exemplo:

1 Import ProductsController from './src/controllers/products'; 
2 Import ProductsRoute from './src/routes/products'; 

Dessa maneira não adicionamos nenhuma informação desnecessária ao nomes dos arquivos e ainda mantemos a legibilidade do código.

No decorrer do livro utilizaremos o exemplo seguindo o padrão MVC com a diretório source e os demais diretórios dentro, como controllers, models e etc.

Rotas com o express router

O express possui um middleware nativo para lidar com rotas, o Router. O Router é responsável por administrar as rotas da aplicação e pode ser passado como parâmetro para o app.use(). Utilizando o Router é possível desacoplar as rotas e remover a necessidade de usar o app (instância do express) em outros lugares da aplicação.

Separando as rotas

Vamos alterar nossa aplicação para separar as rotas do app. Para isso devemos criar um diretório chamado routes dentro de src. Os diretórios devem ficar assim:

1 ├── package.json
2 ├── server.js
3 ├── src
4 │   ├── app.js
5 │   └── routes

Dentro de routes criaremos um arquivo chamado index.js que será responsável por carregar todas as rotas da aplicação:

1 import express from 'express';
2 
3 const router = express.Router();
4 
5 export default router;

No código acima importamos o express, acessamos o Router dentro dele e depois o exportamos. Agora que temos um arquivo para administrar as rotas podemos mover a lógica de administração das rotas que estão no app.js para o nosso index.js. Primeiro movemos a rota padrão. O arquivo de rotas deverá ficar assim:

1 import express from 'express';
2 
3 const router = express.Router();
4 
5 router.get('/', (req, res) => res.send('Hello World!'));
6 
7 export default router;

Rotas por recurso

No código anterior não movemos a rota products porque ela não ficará no index.js. Cada recurso da api terá seu próprio arquivo de rotas e o index.js será responsável por carregar todos eles.

Agora, vamos criar um arquivo para as rotas do recurso products da nossa api.

Para isso será necessário criar um arquivo chamado products.js dentro do diretório routes, ele terá o seguinte código:

1 import express from 'express';
2 
3 const router = express.Router();
4 
5 export default router;

Agora podemos mover a rota products do app.js para o products.js. Ele deve ficar assim:

 1 import express from 'express';
 2 
 3 const router = express.router();
 4 
 5 router.get('/', (req, res) => res.send([{
 6   name: 'default product',
 7   description: 'product description',
 8   price: 100
 9 }]));
10 
11 export default router;

Note que agora o padrão da rota não é mais /products e somente / , isso é uma boa prática para separar recursos da api. Como nosso arquivo é products.js as rotas dentro dele serão referentes ao recurso products da api, assim internamente não precisamos repetir esse prefixo, deixaremos para o index carregar essa rota e dar o prefixo pra ela. Vamos alterar o index.js para carregar a nossa nova rota, ele deve ficar assim:

1 import express from 'express';
2 import productsRoute from './products';
3 
4 const router = express.Router();
5 
6 router.use('/products', productsRoute);
7 router.get('/', (req, res) => res.send('Hello World!'));
8 
9 export default router;

Primeiro importamos a rota que foi criada anteriormente e damos o nome de productsRoute. Depois, para carregar a rota, chamamos a função use do router passando o prefixo da rota que será /products e o productsRoute que importamos.

Com as rotas configuradas, o último passo é alterar o app.js para carregar nosso arquivo de rotas, ele deve ficar assim:

1 import express from 'express';
2 import bodyParser from 'body-parser';
3 import routes from './routes';
4 
5 const app = express();
6 app.use(bodyParser.json());
7 app.use('/', routes);
8 
9 export default app;

As rotas que estavam no app.js foram movidas para seus respectivos arquivos e agora importamos apenas o routes. Como foi criado um index.js dentro de routes não é necessário especificar o nome do arquivo, apenas importar o diretório /routes e automaticamente o módulo do Node.js procurará primeiro por um arquivo index.js e o importará. Em seguida o routes é passado como parâmetro para a função use junto com o /, significa que toda requisição vai ser administrada pelo routes.

Router paths

Nos passos anteriores foram criadas algumas rotas que simbolizam caminhos na aplicação combinando um padrão e um método HTTP, por exemplo, uma requisição do tipo get na rota / irá retornar Hello World, já em /products irá retornar um produto fake. Essa é a maneira de definir endpoints em APIs com o express router.

O caminho passado por parâmetro para o método HTTP é chamado de path, por exemplo router.get(“/products”). Paths podem ser strings, patterns ou expressões regulares. Caso precise testar rotas complexas o express possui um testador de rotas online onde é possível adicionar o caminho e verificar como ele será interpretado pelo express router.

Executando os testes

Nesse momento nossos testes devem estar passando novamente, o que irá nos garantir que nossa refatoração foi concluída com sucesso.

O código dessa etapa está disponivel aqui

Controllers

Os controllers serão responsáveis por receber as requisições das rotas, interagir com o Model quando necessário e retornar a resposta para o usuário. No nosso código atual, as rotas estão com muita responsabilidade e difíceis de testar isoladamente, pois dependemos do express. Para corrigir esse comportamento precisamos adicionar os controllers. Vamos criar os controllers guiados por testes de unidade, assim será possível validar o comportamento de forma separada do nosso sistema em si.

Configurando os testes de unidade

Como vimos no capítulo de testes de unidade, testes de unidade servem para testar pequenas partes do software isoladamente.

Para começar, crie um diretório chamado unit dentro do diretório test, na raiz do projeto. Assim como fizemos nos testes de integração, criaremos os arquivos de configuração para os testes. Vamos criar um arquivo chamado helpers.js dentro de unit, com o seguinte código:

1 import chai from 'chai';
2 
3 global.expect = chai.expect;

Em seguida, vamos criar o arquivo mocha.opts para as configurações do Mocha. Ele deve possuir o seguinte código:

1 --require test/unit/helpers.js
2 --reporter spec
3 --compilers js:babel-core/register
4 --slow 5000

A última etapa de configuração dos testes de unidade será a criação de um comando para executar os testes. Vamos adicionar o seguinte script no package.json:

1 "test:unit": "NODE_ENV=test mocha --opts test/unit/mocha.opts test/unit/**/*_spe\
2 c.js"

Para testar se o comando esta funcionando basta executar:

1 $ npm run test:unit

A saída do terminal deve informar que não conseguiu encontrar os arquivos de teste:

1 Warning: Could not find any test files matching pattern: test/unit/**/*_spec.js
2 No test files found

Vamos criar nosso primeiro teste de unidade para o nosso futuro controller de produtos. A separação de diretórios será semelhante a da aplicação com controllers, models e etc.

Criaremos um diretório chamado controllers dentro de unit e dentro dele um arquivo com o cenário de teste que vamos chamar de products_spec.js. Em seguida vamos executar os testes unitários novamente, a saída deve ser a seguinte:

1 0 passing (2ms)

Ok, nenhum teste está passando pois ainda não criamos nenhum.

Testando o controller unitariamente

Vamos começar a escrever o teste. O primeiro passo será adicionar a descrição desse cenário de testes, como no código a seguir:

1 describe('Controllers: Products', () => {
2 
3 });

Esse cenário irá englobar todos os testes do controller de products. Vamos criar cenários para cada um dos métodos, o próximo cenário terá o seguinte código:

1 describe('Controllers: Products', () => {
2 
3     describe('get() products', () => {
4 
5     });
6 
7 });

Precisamos agora criar nosso primeiro caso de teste para o método get. Começaremos nosso caso de teste descrevendo o seu comportamento:

1 describe('Controllers: Products', () => {
2 
3   describe('get() products', () => {
4     it('should return a list of products', () => {
5 
6     });
7   });
8 
9 });

Segundo a descrição do nosso teste, o método get deve retornar uma lista de produtos. Esse é o comportamento que iremos garantir que está sendo contemplado. Começaremos iniciando um novo controller como no código a seguir:

 1 import ProductsController from '../../../src/controllers/products';
 2 
 3 describe('Controllers: Products', () => {
 4 
 5   describe('get() products', () => {
 6     it('should return a list of products', () => {
 7 
 8       const productsController = new ProductsController();
 9 
10     });
11   });
12 });

Importamos o ProductsController do diretório onde ele deve ser criado e dentro do caso de teste inicializamos uma nova instância. Nesse momento se executarmos nossos testes de unidade devemos receber o seguinte erro:

1 Error: Cannot find module '../../../src/controllers/products'
2     at Function.Module._resolveFilename (module.js:455:15)
3     at Function.Module._load (module.js:403:25)
4 

A mensagem de erro explica que o módulo products não foi encontrado, como esperado. Vamos criar o nosso controller para que o teste passe. Vamos adicionar um diretório chamado controllers em src e dentro dele vamos criar o arquivo products.js, que será o controller para o recurso de products da API:

1 class ProductsController {
2 
3 }
4 
5 export default ProductsController;

Com o controller criado no diretório correto o nosso teste deve estar passando, vamos tentar novamente os testes unitários:

1 $ npm run test:unit

A saída do terminal deve ser a seguinte:

1   Controllers: Products
2     get() products
3       ✓ should return a list of products
4 
5 
6   1 passing (176ms)

Até o momento ainda não validamos o nosso comportamento esperado, apenas foi validado que o nosso controller existe. Agora precisamos garantir que o comportamento esperado no teste está sendo coberto, para isso precisamos testar se o método get chama a função de resposta do express. Antes de começar esse passo precisamos instalar o Sinon, uma biblioteca que irá nos ajudar a trabalhar com spies, stubs e mocks, os quais serão necessários para garantir o isolamento dos testes unitários.

Mocks, Stubs e Spies com Sinon.js

Para instalar o Sinon basta executar o seguinte comando:

1 $ npm install --save-dev sinon

Após a instalação ele já estará disponível para ser utilizado em nossos testes. Voltando ao teste, vamos importar o Sinon e também usar um spy para verificar se o método get do controller está realizando o comportamento esperado. O código do teste deve ficar assim:

 1 import ProductsController from '../../../src/controllers/products';
 2 import sinon from 'sinon';
 3 
 4 describe('Controllers: Products', () => {
 5   const defaultProduct = [{
 6     name: 'Default product',
 7     description: 'product description',
 8     price: 100
 9   }];
10 
11   describe('get() products', () => {
12     it('should return a list of products', () => {
13       const request = {};
14       const response = {
15         send: sinon.spy()
16       };
17 
18       const productsController = new ProductsController();
19       productsController.get(request, response);
20 
21       expect(response.send.called).to.be.true;
22       expect(response.send.calledWith(defaultProduct)).to.be.true;
23     });
24   });
25 });

Muita coisa aconteceu nesse bloco de código, mas não se preocupe, vamos passar por cada uma das alterações.

A primeira adição foi o import do Sinon, módulo que instalamos anteriormente.

Logo após a descrição do nosso cenário de teste principal adicionamos uma constant chamada defaultProduct que armazena um array com um objeto referente a um produto com informações estáticas. Ele será útil para reaproveitarmos código nos casos de teste.

Dentro do caso de teste foram adicionadas duas constants: request, que é um objeto fake da requisição enviada pela rota do express, que vamos chamar de req na aplicação, e response, que é um objeto fake da resposta enviada pela rota do express, a qual vamos chamar de res na aplicação.

Note que a propriedade send do objeto response recebe um spy do Sinon, como vimos anteriormente, no capítulo de test doubles, os spies permitem gravar informações como quantas vezes uma função foi chamada, quais parâmetros ela recebeu e etc. O que será perfeito em nosso caso de uso pois precisamos validar que a função send do objeto response está sendo chamada com os devidos parâmetros.

Até aqui já temos a configuração necessária para reproduzir o comportamento que esperamos. O próximo passo é chamar o método get do controller passando os objetos request e response que criamos. E o último passo é verificar se o método get está chamando a função send com o defaultProduct como parâmetro. Para isso foram feitas duas asserções, a primeira verifica se a função send foi chamada, e a segunda verifica se ela foi chamada com o defaultProduct como parâmetro.

Nosso teste está pronto, se executarmos os testes unitários devemos receber o seguinte erro:

 1   Controllers: Products
 2     get() products
 3       1) should return a list of products
 4 
 5 
 6   0 passing (156ms)
 7   1 failing
 8 
 9   1) Controllers: Products get() products should return a list of products:
10      TypeError: productsController.get is not a function
11       at Context.it (test/unit/controllers/products_spec.js:19:26)

O erro explica que productsController.get não é uma função, então vamos adicionar essa função ao controller. A função get deverá possuir a lógica que agora está na rota de produtos. Vamos adicionar o método get no ProductsController, o código deve ficar assim:

 1 class ProductsController {
 2 
 3   get(req, res) {
 4     return res.send([{
 5       name: 'Default product',
 6       description: 'product description',
 7       price: 100
 8     }])
 9   }
10 }
11 
12 export default ProductsController;

O método get deve receber os objetos de requisição e resposta e enviar um array com um produto estático como resposta.

Vamos executar os testes novamente, a saída do terminal deve ser a seguinte:

1   Controllers: Products
2     get() products
3       ✓ should return a list of products
4 
5 
6   1 passing (189ms)

Integrando controllers e rotas

Nosso controller está feito e estamos obtendo o comportamento esperado, mas até então não integramos com a aplicação. Para realizar essa integração basta alterar a rota de produtos para usar o controller. Edite o arquivo products.js em src/routes, removendo o bloco de código que foi movido para o controller, e adicione a chamada para o método get. A rota de produtos deve ficar assim:

1 import express from 'express';
2 import ProductsController from '../controllers/products';
3 
4 const router = express.Router();
5 const productsController = new ProductsController();
6 router.get('/', (req, res) => productsController.get(req, res));
7 
8 export default router;

Vamos executar os testes de integração para garantir que o controller foi integrado corretamente com o resto da nossa aplicação.

1 $ npm run test:integration

A saída do terminal deve ser a seguinte:

1   Routes: Products
2     GET /products
3       ✓ should return a list of products
4 
5 
6   1 passing (251ms)

Os código desta etapa esta disponível aqui

Configurando o MongoDB como banco de dados

/todo

Introdução ao MongoDB

/todo

Configurando o banco de dados com Mongoose

Para integrar nossa aplicação com o MongoDB vamos utilizar o Mongoose que é um ODM (Object Document Mapper). O Mongoose irá abstrair o acesso ao banco de dados e ainda irá se responsabilizar por transformar os dados do banco em Models, facilitando a estruturação de nossa aplicação com o padrão MVC.

Para instalar o Mongoose basta executar o seguinte comando npm:

1 $ npm install mongoose --save

Após a instalação o Mongoose estará disponível para ser utilizado. O próximo passo será configurar a aplicação para conectar com o banco de dados. Para isso vamos criar um diretório chamado config dentro de src e dentro dele um arquivo chamado database.js que será responsável por toda configuração do banco de dados.

A estrutura de diretórios deve estar assim:

1 ├── src
2    ├── app.js
3    ├── config
4       └── database.js
5    ├── controllers
6       └── products.js
7    └── routes
8        ├── index.js
9        └── products.js

A primeira coisa que deve ser feita no database.js é importar o módulo do Mongoose, como no código abaixo:

1 import mongoose from 'mongoose';

O próximo passo será informar qual biblioteca de Promises será utilizada. Essa é uma necessidade da versão 4 do Mongoose, como estamos utilizando o Node.js na versão 6 que já conta com Promises por padrão, será necessário fazer somente o seguinte:

1 mongoose.Promise = Promise;

Aqui estamos dizendo para o Mongoose utilizar a Promise oficial do Node.js.

Seguindo a configuração do banco de dados é necessário informar a url onde está o MongoDB. No meu caso está no meu computador então a url será localhost seguido do nome que daremos ao banco de dados:

1 const mongodbUrl = process.env.MONGODB_URL || 'mongodb://localhost/test';

Note que primeiro verificamos se não existe uma variável de ambiente, caso não exista é usado o valor padrão que irá se referir ao localhost e ao banco de dados test. Dessa maneira, poderemos utilizar o MongoDB tanto para testes quanto para rodar o banco da aplicação, sem precisar alterar o código.

No próximo passo vamos criar uma função para conectar com o banco de dados:

1 const connect = () => mongoose.connect(mongodbUrl);

Acima, criamos uma função que retorna uma conexão com o MongoDB, esse retorno é uma Promise, ou seja, somente quando a conexão for estabelecida a Promise será resolvida. Isso é importante pois precisamos garantir que nossa aplicação só vai estar disponível depois que o banco de dados estiver conectado e acessível.

O último passo é exportar o módulo de configuração do banco de dados:

1 export default {
2   connect
3 }

O código do database.js deve estar similar ao que segue:

 1 import mongoose from 'mongoose';
 2 
 3 mongoose.Promise = Promise;
 4 
 5 const mongodbUrl = process.env.MONGODB_URL || 'mongodb://localhost/test';
 6 
 7 const connect = () => mongoose.connect(mongodbUrl);
 8 
 9 export default {
10   connect
11 }

Pronto, o banco de dados está configurado. Nosso próximo passo será integrar o banco de dados com a aplicação, para que ela inicialize o banco sempre que for iniciada.

Integrando o Mongoose com a aplicação

O módulo responsável por inicializar a aplicação é o app.js, então, ele que vai garantir que o banco estará disponível para que a aplicação possa consumi-lo. Vamos alterar o app.js para que ele integre com o banco de dados, atualmente ele está assim:

1 import express from 'express';
2 import bodyParser from 'body-parser';
3 import routes from './routes';
4 
5 const app = express();
6 app.use(bodyParser.json());
7 app.use('/', routes);
8 
9 export default app;

O primeiro passo é importar o módulo responsável pelo banco de dados, o database.js, que fica dentro do diretório config. Os imports devem ficar assim:

1  import express from 'express';
2  import bodyParser from 'body-parser';
3  import routes from './routes';
4 + import database from './config/database';

A seguir vamos alterar um pouco o código anterior que utiliza o express e as rotas movendo o seguinte trecho:

1 - app.use(bodyParser.json());
2 - app.use('/', routes);
3  
4 - export default app;

Os trechos em vermelho serão movidos para dentro de uma nova função, como no código abaixo:

1 + const configureExpress = () => {
2 +  app.use(bodyParser.json());
3 +  app.use('/', routes);
4 +
5 +  return app;
6 +};

Acima criamos uma função nomeada configureExpress que terá a tarefa de configurar o express e retornar uma nova instância de aplicação configurada.

A última etapa da nossa alteração é inicializar o banco antes da aplicação. Como o moongose retorna uma Promise, vamos esperar ela ser resolvida para então chamar a função que criamos anteriormente e que configura o express:

1 + export default () => database.connect().then(configureExpress);

No bloco acima exportamos uma função que retorna uma Promise. A primeira chamada é a função connect do database, que criamos na etapa anterior, assim que essa Promise for resolvida, significa que o banco de dados estará disponível, então é chamada a função configureExpress que irá configurar o express e retornar uma nova instância da aplicação. Esse pattern é conhecido como chained promises.

Note que a função configureExpress não precisaria existir, poderíamos ter uma função diretamente dentro do then do connect e nela configurar o express, porém criar uma função que descreva o que está sendo feito torna o código mais claro e desacoplado. Pode se ler mais sobre o assunto nesta issue do airbnb.

O app.js depois de alterado deve estar assim:

 1 import express from 'express';
 2 import bodyParser from 'body-parser';
 3 import routes from './routes';
 4 import database from './config/database'
 5 
 6 const app = express();
 7 
 8 const configureExpress = () => {
 9   app.use(bodyParser.json());
10   app.use('/', routes);
11 
12   return app;
13 };
14 
15 export default () => database.connect().then(configureExpress);

Como alteramos o app para retornar uma função, que por sua vez retorna uma Promise, será necessário alterar o server.js para fazer a inicialização de maneira correta.

Alterando a inicilização

O server.js é o arquivo responsável por inicializar a aplicação, chamando o app. Como alteramos algumas coisas na etapa anterior precisamos atualizá-lo. Vamos começar alterando o nome do módulo na importação:

1 - import app from './src/app';
2 + import setupApp from './src/app';

O módulo foi alterado de app para setupApp, por quê? Porque agora ele é uma função e esse nome reflete mais a sua responsabilidade.

O próximo passo é alterar a maneira como o app é chamado:

 1 -app.listen(port, () => {
 2 -  console.log(`app running on port ${port}`);
 3 -});
 4 +setupApp()
 5 + .then(app => app.listen(port, () => console.log(`app running on port ${port}`)\
 6 ))
 7 + .catch(error => {
 8 +   console.error(error);
 9 +   process.exit(1);
10 + });

Como o código anterior devolvia uma instância da aplicação diretamente, era apenas necessário chamar o método listen do express para inicializar a aplicação. Agora temos uma função que retorna uma promise devemos chamá-la e ela vai inicializar o app, inicializando o banco, configurando o express e retornando uma nova instância da aplicação, só então será possível inicializar a aplicação chamando o listen.

Até esse momento espero que vocês já tenham lido a especificação de Promises mais de 10 vezes e já sejam mestres na implementação. Quando um problema ocorre a Promise é rejeitada. Esse erro pode ser tratado usando um catch como no código acima. Acima, recebemos o erro e o mostramos no console.log, em seguida encerramos o processo do Node.js com o código 1. Dessa maneira o processo é finalizado informando que houve um erro em sua inicialização. Informar o código de saída, é uma boa prática finalizar o processo com código de erro e conhecido como graceful shutdowns e faz parte da lista do 12 factor app de boas práticas para desenvolvimento de software moderno.

As alterações necessárias para integrar com o banco de dados estão finalizadas, vamos executar os testes de integração para garantir:

1 $ npm run test:integration

A saida será:

 1  Routes: Products
 2     GET /products
 3       1) should return a list of products
 4 
 5 
 6   0 passing (152ms)
 7   1 failing
 8 
 9   1) Routes: Products GET /products should return a list of products:
10      TypeError: Cannot read property 'get' of undefined
11       at Context.done (test/integration/routes/products_spec.js:21:7)

O teste quebrou! Calma, isso era esperado. Assim como o server.js o teste de integração inicia a aplicação usando o módulo app, então ele também deve ser alterado para lidar com a Promise.

Vamos começar alterando o helpers.js dos testes de integração, como no código abaixo:

1 -import app from '../../src/app.js';
2 +import setupApp from '../../src/app.js';
3   
4 -global.app = app;
5 -global.request = supertest(app);
6 +global.setupApp = setupApp;
7 +global.supertest = supertest;

Assim como no server.js, alteramos o nome do módulo de app para setupApp e o exportamos globalmente. Também removemos o request do conceito global que era uma instância do supertest com o app configurado, deixaremos para fazer isso no próximo passo.

Agora é necessário alterar o products_spec.js para inicializar a aplicação antes de começar a executar os casos de teste usando o callback before do Mocha:

 1 describe('Routes: Products', () => {
 2 +  let request;
 3 +
 4 +  before(()=> {
 5 +    return setupApp()
 6 +      .then(app => {
 7 +        request = supertest(app)
 8 +      })
 9 +  });
10 +

No bloco acima, criamos um let para o request do supertest e no before a aplicação é inicializada. Assim que o setupApp retornar uma instância da aplicação é possível inicializar o supertest e atribuir a let request que definimos anteriormente.

Executando os testes novamente, a saída deve ser a seguinte:

1   Routes: Products
2     GET /products
3       ✓ should return a list of products
4 
5 
6   1 passing (336ms)

Caso ocorra um erro como: “MongoError: failed to connect to server [localhost:27017] on first connect”:

 1   Routes: Products
 2     1) "before all" hook
 3 
 4 
 5   0 passing (168ms)
 6   1 failing
 7 
 8   1) Routes: Products "before all" hook:
 9      MongoError: failed to connect to server [localhost:27017] on first connect
10       at Pool.<anonymous> (node_modules/mongodb-core/lib/topologies/server.js:32\
11 6:35)
12       at Connection.<anonymous> (node_modules/mongodb-core/lib/connection/pool.j\
13 s:270:12)
14       at Socket.<anonymous> (node_modules/mongodb-core/lib/connection/connection\
15 .js:175:49)
16       at emitErrorNT (net.js:1272:8)
17       at _combinedTickCallback (internal/process/next_tick.js:74:11)
18       at process._tickCallback (internal/process/next_tick.js:98:9)

A mensagem de erro explica que o MongoDB não está executando em localhost na porta 7000, verifique e tente novamente.

O código desta etapa esta disponível aqui.

O padrão MVC

MVC é um design pattern de arquitetura que foca na separação de responsabilidades. O MVC separa os dados de negócio (Models) da interface do usuário (Views) e usa um componente para ligá-los (Controllers), normalmente os controllers são responsáveis por receber a entrada do usuário e coordenar Models e Views.

Voltando ao tempo do Smalltalk

Para entender o padrão MVC é necessário voltar alguns anos no tempo, aos anos 70 para ser mais preciso, fase da emergência das interfaces gráficas de usuário. Na década de 70, Trygve Reenskaug trabalhava em uma linguagem de programação chamada Smalltalk, com a qual ele desenvolveu e aplicou o padrão MVC com objetivo de separar a interface do usuário da lógica da aplicação, conceito conhecido como Separated Presentation. Essa arquitetura consistia em:

  • Um elemento de domínio denominado Model que não deveria ter conhecimento da interface e interação do usuário (Views e Controllers).
  • A camada de apresentação e interação seria feita pelas views e controllers, com um controller e uma view para cada elemento que seria mostrado na tela.
  • A responsabilidade do controller era receber entradas das views, como por exemplo: teclas pressionadas, formularios ou eventos de click.
  • Para atualizar a view era utilizado um padrão conhecido como Observer. Cada vez que o model mudava, essa mudança era refletida na view.

É impressionante ver que um padrão utilizando até hoje como o Observer (também implementado como pub/sub hoje em dia) era parte crucial do MVC. No Smalltak MVC ambos views e controllers observavam o model, para que qualquer mudança conseguisse ser refletida na view. Para quem deseja se aprofundar mais nas origens do padrão MVC indico o artigo GUI Architectures, do Martin Fowler, que conta a história do MVC ao longo dos anos.

MVC no javascript

Nos anos de hoje o MVC é base para a maioria dos frameworks e arquiteturas de projetos em diversas linguagens. Frameworks como SpringMVC do Java, Symfony do PHP e Ruby on Rails do Ruby utilizam esse padrão para o desenvolvimento.

No javascript o MVC começou no contexto dos browsers, quando as aplicações começaram a ter mais responsabilidades no front-end com as single page apps o javascript passou pela mesma necessidade do Smalltalk nos anos 70: separar a interface da lógica de negócio. Uma gama de frameworks para o front-end apareceram aplicando MVC com diversas variações, como por exemplo o Backbone e o Ember.js. A chegada do javascript no server side com o Node.js trouxe a mesma necessidade, frameworks como Express.js e Sails.js utilizam MVC como base de arquitetura para criação de aplicações.

Conforme mencionado, o MVC é composto por três componentes:

Models

Os Models são responsáveis por administrar os dados da aplicação. Os models variam muito de aplicações e frameworks mas normalmente eles são responsáveis por validar os dados e também persistir sincronizando com um localStorage ou banco de dados. Models podem ser observados por mais de uma view; Views podem precisar de partes diferentes dos dados de um model. Por exemplo, um model de usuário pode ser utilizado em uma view para mostrar nome e email, e em outra para mudar a senha.

Views

Views são uma forma visual de representar os models e apresentar a informaçao para o usuario. Views normalmente observam os models para refletirem suas mudanças na tela. A maioria dos livros de design patterns se referem à views como “burras”, pois sua única responsabilidade é mostrar o estado do model.

Controllers

Controllers agem como mediadores entre views e models. Resumidamente, sua responsabilidade é atualizar o model quando a view muda e atualizar a view quando o model muda.

Para os desenvolvedores vindos do javascript no front-end há uma variação enorme em como o MVC é implementado, muitas vezes omitindo o C, ou seja, não utilizando controllers, isso pois as necessidades no front-end são diferentes. Um exemplo de implementação de controller no front-end que sempre deu muita discussão é a do Angular.js versão 1.x, que é totalmente acoplado a view utilizando um padrão chamado de 2 way data binding que faz com que as alterações na tela sejam enviadas para o controller e as alterações no controller enviadas para a view. Além de ser responsável por escutar e emitir eventos, o que atribui muita responsabilidade para os controllers, assim quebrando o princípio de responsabilidade única.

Em 2015 com o surgimento do React e frameworks como Flux, o MVC no front-end perdeu força pois a componentização, programação reativa e as arquiteturas unidirecionais começaram a fazer mais sentido no contexto dos browsers.

Já no server side com Node.js esse padrão ainda é muito utilizado e útil. No Sails.js a implementação de um controller para administrar as requisições e conversar com o model é obrigatório. Já no Express.js essa conversão não é obrigatória, mas é sugerida. O próprio gerador de código do Express já cria o diretório para controllers separadamente.

As vantagens de utilizar MVC

A separação de responsabilidades facilita a modularização das funcionalidades da aplicação e possibilita:

  • Manutenibilidade: Quando uma modificação precisa ser feita é mais fácil de descobrir onde é e também onde vai causar impacto.
  • Desacoplar models e views facilita os testes em ambos isoladamente. Testando a lógica de negócio em models e a usabilidade em views.
  • Reutilização de código

MVC em API

Para APIs, a parte da view não é aplicada. No decorrer do livro será aplicado o padrão MVC, adaptado para o contexto da API que será desenvolvida, com Controllers e Models. Em APIs o controller recebe a requisição da rota, faz a chamada para o model realizar a lógica de negócio e retorna a resposta para o usuário.

Models

Como visto no capítulo sobre MVC, os models são responsáveis pelos dados, persistência e validação na aplicação. Aqui estamos utilizando o Mongoose, que já provê uma API para a utilização de models.

Criando o model com Mongoose

O primeiro passo será a criação de um diretório chamando models e um arquivo chamado products.js dentro de src, como no exemplo abaixo:

1 ├── src
2 │   ├── models
3 │   │   └── product.js

No products.js devemos começar importando o módulo do mongoose:

1 import mongoose from 'mongoose';

Após isso será necessário descrever o schema do model de products. O schema é utilizado pelo mongoose para válidar e mapear os dados do model. Cada schema representa uma collection do MongoDB.

Adicione um schema como o seguinte:

1 const schema = new mongoose.Schema({
2   name: String,
3   description: String,
4   price: Number
5 });

No bloco acima uma nova instância de schema é definida e atribuída a constant schema, o model está definido, agora basta exportá-lo para que ele possa ser utilizado na aplicação:

1 const Product = mongoose.model('Product', schema);
2 
3 export default Product;

Chamando mongoose.model com um nome, no nosso caso Product definimos um model no módulo global do mongoose. O que significa que qualquer lugar que importar o módulo do mongoose a partir de agora na aplicação poderá acessar o model de products que foi definido pois o módulo do mongoose é um Singleton.

A versão final do model Product deve ficar similar a esta:

 1 import mongoose from 'mongoose';
 2 
 3 const schema = new mongoose.Schema({
 4   name: String,
 5   description: String,
 6   price: Number
 7 });
 8 const Product = mongoose.model('Product', schema);
 9 
10 export default Product;

Singleton Design Pattern

No Node.js, e no Javascript em geral, existem inúmeras maneiras de aplicar o Singleton, vamos revisar as formas mais utilizadas. Tradicionalmente o Singleton restringe a inicialização de uma nova classe a um único objeto ou referência. Segundo Addy Osmani, no livro Javascript Design Patterns:

With JavaScript, singletons serve as a namespace provider which isolate implementation code from the global namespace so-as to provide a single point of access for functions.

Traduzindo livremente:

Singletons em javascript servem como um provedor de namespaces isolando a implementação do código do namespace global possibilitando assim acesso a somente um ponto, que podem ser funções ou classes por exemplo.

No código a seguir definimos um Model no Mongoose:

 1 import mongoose from 'mongoose';
 2 
 3 const schema = new mongoose.Schema({
 4   name: String,
 5   description: String,
 6   price: Number
 7 });
 8 const Product = mongoose.model('Product', schema);
 9 
10 export default Product;

Note que importamos o módulo do Mongoose e não iniciamos uma nova instância com new, apenas acessamos o módulo diretamente. Em seguida, definimos um novo schema para o Model e então, utilizando a função mongoose.model, definimos um model chamado Product na instância do mongoose que importamos.

Agora se importarmos o módulo do Mongoose em qualquer outro lugar da aplicação e acessarmos os models teremos uma resposta como a seguinte:

1 //src/routes/products.js
2 
3 import mongoose from 'mongoose';
4 
5 console.log(mongoose.models);

O console.log mostrará:

1 { Product:
2    { [Function: model]
3 ...

Essa é a implementação e a responsabilidade de um Singleton: prover acesso a mesma instância independente de quantas vezes ou da maneira que for chamada.

Vamos ver como é implementado o Singleton no código do Mongoose. No arquivo /lib/index.js do módulo temos a seguinte função:

 1 function Mongoose() {
 2   this.connections = [];
 3   this.plugins = [];
 4   this.models = {};
 5   this.modelSchemas = {};
 6   // default global options
 7   this.options = {
 8     pluralization: true
 9   };
10   var conn = this.createConnection(); // default connection
11   conn.models = this.models;
12 }

Para quem não é familiarizado com es2015, a função Mongoose() representará uma classe. No final do arquivo podemos ver como o módulo é exportado:

1 var mongoose = module.exports = exports = new Mongoose;

Essa atribuições: var mongoose = module.exports = exports não são o nosso foco. A parte importante dessa linha é o new Mongoose que garante que o módulo exportado será uma nova instância da classe Mongoose.

Você pode estar se perguntando se uma nova instância será criada sempre que importamos o módulo, a resposta é não. Módulos no Node.js são cacheados uma vez que carregados, o que significa que o que acontece no module.exports só acontecerá uma vez a cada inicialização da a aplicação ou quando o cache for limpo (o que só pode ser feito manualmente). Dessa maneira o código acima exporta uma referência a uma nova classe e quando a importamos temos acesso diretamente a seus atributos e funções internas.

Singletons são extremamente úteis para manter estado em memória possibilitando segurança entre o compartilhamento de uma mesma instância a todos que a importarem.

Veremos mais sobre módulos no capítulo sobre modularização.

Integrando models e controllers

Até agora nosso controller responde com dados fakes e nosso teste de integração ainda está no estado GREEN. Adicionamos o model e agora precisamos integrar ele com o controller e depois com a rota para que seja possível finalizar a integração e completar o passo de REFACTOR do nosso teste de integração.

Para começar vamos atualizar o teste de unidade do controller para refletir o comportamento que esperamos. Para isso devemos começar atualizando o arquivo test/unit/controllers/products_spec.js importando os módulos necessários para descrever o comportamento esperado no teste:

1 import ProductsController from '../../../src/controllers/products';
2 import sinon from 'sinon';
3 +import Product from '../../../src/models/product';

Aqui importamos o módulo referente ao model de Product que criamos anteriormente e será usado pelo controller.

Agora vamos mudar o caso de teste incluindo o comportamento que esperamos quando integrarmos com o model.

1   describe('get() products', () => {
2 -   it('should return a list of products', () => {
3 +   it('should call send with a list of products', () => {
4       const request = {};
5       const response = {
6         send: sinon.spy()
7       };
8 +     Product.find = sinon.stub();

No código acima começamos atualizando a descrição, não iremos checar o retorno pois a saída da função get é uma chamada para a função send do express, então nossa descrição deve refletir isso, dizemos que: “Isso deve chamar a função send com uma lista de produtos”.

Logo após atribuimos um stub para a função find do model Product. Desta maneira será possível adicionar qualquer comportamento para essa função simulando uma chamada de banco de dados por exemplo. O próximo passo será mudar o seguinte código para utilizar o stub:

1 -      const productsController = new ProductsController();
2 -      productsController.get(request, response);
3 +      Product.find.withArgs({}).resolves(defaultProduct);

No withArgs({}) dizemos para o stub que quando ele for chamado com um objeto vazio ele deve resolver uma Promise retornando o defaultProduct. Esse comportamento será o mesmo que o moongose fará quando buscar os dados do banco de dados. Mas como queremos testar isoladamente vamos remover essa integração com o banco de dados utilizando esse stub.

Agora precisamos mudar o comportamento esperado:

1 -      expect(response.send.called).to.be.true;
2 -      expect(response.send.calledWith(defaultProduct)).to.be.true;
3 +      const productsController = new ProductsController(Product);
4 +      return productsController.get(request, response)
5 +        .then(() => {
6 +          sinon.assert.calledWith(response.send, defaultProduct);
7 +        });
8 +    });

No código acima, o primeiro passo foi iniciar uma nova instância de ProductsController passando por parâmetro o model. Dessa maneira esperamos que cada instância de controller possua um model. Na linha seguinte retornamos a função get do productsController. Isso por que ela será uma Promise, e precisamos retornar para que nosso test runner, o Mocha, a chame e a resolva. Quando a Promise é resolvida é checado se a função send do objeto response, que é um spy, foi chamada com o defaultProduct:

1 sinon.assert.calledWith(response.send, defaultProduct);

Isso valida que a função get foi chamada, chamou a função find do model Product passando um objeto vazio e ele retornou uma Promise contendo o defaultProduct. O código final deve estar similar a este:

 1 import ProductsController from '../../../src/controllers/products';
 2 import sinon from 'sinon';
 3 import Product from '../../../src/models/product';
 4 
 5 describe('Constrollers: Products', () => {
 6   const defaultProduct = [{
 7     name: 'Default product',
 8     description: 'product description',
 9     price: 100
10   }];
11 
12   describe('get() products', () => {
13     it('should call send with a list of products', () => {
14       const request = {};
15       const response = {
16         send: sinon.spy()
17       };
18       Product.find = sinon.stub();
19 
20       Product.find.withArgs({}).resolves(defaultProduct);
21 
22       const productsController = new ProductsController(Product);
23       return productsController.get(request, response)
24         .then(() => {
25           sinon.assert.calledWith(response.send, defaultProduct);
26         });
27     });
28 
29   });
30 });

Se executarem os testes de unidade agora, eles estão falhando, então vamos a implementação!

Atualizando o controller para utilizar o model

Agora precisamos atualizar o controller products que fica em: src/controllers/products.js. Vamos começar adicionando um construtor para poder receber o model Product, como no código a seguir:

1 class ProductsController {
2 +  constructor(Product) {
3 +    this.Product = Product;
4 +  };

O construtor irá garantir que toda a vez que alguém tentar criar uma instância do controller ele deve passar o model Product por parâmetro. Mas ai vocês me perguntam, mas por que não importar ele diretamente no productsController.js? Pois assim não seria possível usar stub no model e tornaria o código acoplado. Veremos mais sobre como gerenciar dependencias nos capítulos seguintes.

Seguindo a atualização do controller agora devemos atualizar o método que estamos testando, o get. Como no código a seguir:

 1    get(req, res) {
 2 -    return res.send([{
 3 -      name: 'Default product',
 4 -      description: 'product description',
 5 -      price: 100
 6 -    }])
 7 +    return this.Product.find({})
 8 +      .then(products => res.send(products));
 9     }
10   }

Aqui removemos o produto fake que era retornado, para aplicar a lógica real de integração com o banco. Note que this.Product.find({}) segundo a documentação do mongoose irá retornar uma lista de objetos, então o que está sendo feito quando a Promise resolver é passar essa lista para a função send do objeto res do express para que ele retorne para o usuário que fez a requisição.

Essa é a implementação necessária para que o teste passe, vamos rodá-lo:

1 $ npm run test:unit

A resposta deve ser:

1   Controllers: Products
2     get() products
3       ✓ should call send with a list of products
4 
5 
6   1 passing (217ms)

Testando casos de erro

Até agora apenas testamos o happy path (termo usado para descrever o caminho feliz esperado em um teste), mas o que acontecerá se der algum erro na consulta ao banco? Que código de erro e mensagem devemos enviar para o usuário?

Vamos escrever um caso de teste unitário para esse comportamento, o caso de teste deve ser como o seguinte:

 1     it('should return 400 when an error occurs', () => {
 2       const request = {};
 3       const response = {
 4         send: sinon.spy(),
 5         status: sinon.stub()
 6       };
 7 
 8       response.status.withArgs(400).returns(response);
 9       Product.find = sinon.stub();
10       Product.find.withArgs({}).rejects({ message: 'Error' });
11 
12       const productsController = new ProductsController(Product);
13 
14       return productsController.get(request, response)
15         .then(() => {
16           sinon.assert.calledWith(response.send, 'Error');
17         });
18     });

Devemos dar atenção a dois pontos nesse teste, primeiro é:

1 response.status.withArgs(400).returns(response);

Onde dizemos que: Quando a função status for chamada com o argumento 400 ela deve retornar o objeto response, isso por que a API do express concatena as chamadas de funções. O próximo ponto é:

1 Product.find.withArgs({}).rejects({message: 'Error'});

Aqui utilizamos o stub para rejeitar a Promise e simular uma consulta ao banco que retornou uma falha. Se executarmos os testes agora receberemos um erro, pois não implementamos ainda, então vamos implementar. Atualize a função get do controller de products adicionando um catch na busca, ele deve ficar assim:

1   get(req, res) {
2     return this.Product.find({})
3       .then(products => res.send(products))
4       .catch(err => res.status(400).send(err.message));
5   }

Aqui é dito que, quando ocorrer algum erro, o status da requisição será 400, usamos res.status que é uma função do express que adiciona o statusCode da resposta HTTP. Após isso enviamos a resposta adicionando a mensagem do erro como corpo utilizando a função send do objeto de resposta do express.

Agora basta executar os testes de unidade novamente e eles devem estar passando:

1 $ npm run test:unit

A resposta deve ser:

1   Constrollers: Products
2     get() products
3       ✓ should call send with a list of products
4       ✓ should return 400 when an error occurs
5 
6 
7   2 passing (223ms)

Nossa unidade está pronta para ser integrada com o resto da aplicação, faremos isso no próximo passo.

O passo Refactor do TDD

Lembram que nosso teste de integração está no passo GREEN do TDD? Ou seja, está com lógica suficiente para passar mas não está com a implementação real. Agora que o controller já está completo, integrando com o model, é o melhor momento para refatorar o resto dos componentes fazendo a integração com o model e controller.

Integração entre rota, controller e model

Nesse passo vamos refatorar nossa rota de products para que ela consiga criar o controller corretamente, passando o model como dependência. Altere o arquivo src/routes/products.js para que ele fique como o código a seguir:

 1 import express from 'express';
 2   import ProductsController from '../controllers/products';
 3 + import Product from '../models/product';
 4   
 5   const router = express.Router();
 6 - const productsController = new ProductsController();
 7 + const productsController = new ProductsController(Product);
 8   router.get('/', (req, res) => productsController.get(req, res));
 9   
10   export default router;

A única mudança é que a nova instância do controller recebe o model Product por parâmetro. A integração parece estar pronta, vamos executar os testes de integração:

1 $ npm run test:integration

A saida sera como a seguinte:

 1 Routes: Products
 2     GET /products
 3       1) should return a list of products
 4 
 5 
 6   0 passing (286ms)
 7   1 failing
 8 
 9   1) Routes: Products GET /products should return a list of products:
10      Uncaught AssertionError: expected undefined to deeply equal { Object (name,\
11  description, ...) }
12       at Test.request.get.end (test/integration/routes/products_spec.js:41:34)
13       at Test.assert (node_modules/supertest/lib/test.js:179:6)
14       at Server.assert (node_modules/supertest/lib/test.js:131:12)
15       at emitCloseNT (net.js:1549:8)
16       at _combinedTickCallback (internal/process/next_tick.js:71:11)
17       at process._tickCallback (internal/process/next_tick.js:98:9)

O teste falhou, e isso é esperado, pois agora utilizamos o MongoDB e vamos precisar criar um produto antes de executar o teste para que seja possível reproduzir o cenário que queremos.

Vamos adicionar o que precisamos no teste de integração da rota de productos, abra o arquivo test/integration/routes/products_spec.js. A primeira coisa é a resposta que esperamos do MongoDB. O MongoDB adiciona alguns campos aos documentos salvos que são _v, corresponde a versão do documento e _id que é o identificador único do documento, normalmente um uuid.v4.

 1 const defaultProduct = {
 2       name: 'Default product',
 3       description: 'product description',
 4       price: 100
 5     };
 6 +  const expectedProduct = {
 7 +    __v: 0,
 8 +    _id: '56cb91bdc3464f14678934ca',
 9 +    name: 'Default product',
10 +    description: 'product description',
11 +    price: 100
12 +  };
13 +

Logo abaixo do defaultProduct adicionamos uma constant chamada expectedProduct correspondente ao produto que esperamos ser criado pelo MongoDB. Agora já possuímos o produto que queremos salvar que é defaultProduct e também o que esperamos de resposta do MongoDB.

Como estamos testando a rota products que retorna todos os produtos, precisamos ter produtos no banco de dados para poder validar o comportamento. Para isso iremos utilizar o callback do Mocha chamado beforeEach, que significa: antes de cada. Esse callback é executado pelo Mocha antes de cada caso de teste, então ele é perfeito para nosso cenário onde precisamos ter um produto disponível no banco antes de executar o teste.

Logo abaixo do código anterior adicione o seguinte código:

1 + beforeEach(() => {
2 +   const product = new Product(defaultProduct);
3 +   product._id = '56cb91bdc3464f14678934ca';
4 +   return Product.remove({})
5 +     .then(() => product.save());
6 + });
7 +

O que o código acima faz, é criar um novo produto utilizando os dados da constant defaultProduct e atribuir a nova instância do produto a constant product. Na linha seguinte a propriedade product._id do objeto criado pelo mongoose é sobrescrita por um id estático que geramos. Por padrão o mongoose gera um uuid para cada novo documento, mas no caso do teste precisamos saber qual é o id do documento que estamos salvando para poder comparar dentro do caso de teste, se utilizarmos o uuid gerado pelo mongoose o teste nunca conseguirá comparar o mesmo id. Dessa maneira sobrescrevemos por um id gerado por nós mesmos. Existem vários sites na internet para gerar uuid, aqui no livro por exemplo foi utilizado este: uuid generator.

Após a atribuição do id retornamos uma Promise que remove todos os produtos do banco de dados e depois salva o produto que criamos.

O próximo passo é garantir que iremos deixar o terreno limpo após executar o teste. Quando criamos testes que criam dados em banco de dados, escrevem arquivos em disco, ou seja, testes que podem deixar rastros para outros testes devemos limpar todo o estado e garantir que após a execução do teste não terá nenhum vestígio para os próximos. Para isso vamos adicionar também o callback afterEach que significa: Depois de cada, para garantir que o MongoDB ficará limpo, ou seja, sem dados. Para isso adicione o seguinte código logo abaixo do anterior:

1 +  afterEach(() => Product.remove({}));

O último passo é atualizar o caso de teste para que ele verifique o expectedProduct no lugar do defaultProduct:

 1    describe('GET /products', () => {
 2       it('should return a list of products', done => {
 3   
 4         request
 5         .get('/products')
 6         .end((err, res) => {
 7 -         expect(res.body[0]).to.eql(defaultProduct);
 8 +         expect(res.body).to.eql([expectedProduct]);
 9           done(err);
10         });
11       });

O código final do products_spec.js deve estar similar a este:

 1 import Product from '../../../src/models/product';
 2 
 3 describe('Routes: Products', () => {
 4   let request;
 5 
 6   before(()=> {
 7     return setupApp()
 8       .then(app => {
 9         request = supertest(app)
10       });
11   });
12 
13   const defaultProduct = {
14     name: 'Default product',
15     description: 'product description',
16     price: 100
17   };
18   const expectedProduct = {
19     __v: 0,
20     _id: '56cb91bdc3464f14678934ca',
21     name: 'Default product',
22     description: 'product description',
23     price: 100
24   };
25 
26   beforeEach(() => {
27     const product = new Product(defaultProduct);
28     product._id = '56cb91bdc3464f14678934ca';
29     return Product.remove({})
30       .then(() => product.save());
31   });
32 
33   afterEach(() => Product.remove({}));
34 
35   describe('GET /products', () => {
36     it('should return a list of products', done => {
37 
38       request
39         .get('/products')
40         .end((err, res) => {
41           expect(res.body).to.eql([expectedProduct]);
42           done(err);
43         });
44     });
45   });
46 });

Executando os testes de integração novamente:

1 $ npm run test:integration

Devemos ter a seguinte resposta:

1   Routes: Products
2     GET /products
3 
4       ✓ should return a list of products
5 
6 
7   1 passing (307ms)

Nosso ciclo de TDD nos testes de integração está completo, refatoramos e adicionamos o comportamento esperado. Esse padrão onde começamos por testes de integração, depois criamos componentes internos como fizemos com controllers e models e utilizamos o teste de integração para válidar todo o comportamento, é conhecido como outside-in, termo esse que falaremos a seguir.

O código deste capitulo está disponível aqui.

Behaviour Driven Development - BDD

Como vimos até agora, o ponto forte do TDD é garantir que o código possuirá testes automatizados e também guiar o desenvolvimento para um código desacoplado e legível.

Quando desenvolvemos guiados por testes utilizando TDD praticamos o chamado inside-out (em português, de dentro para fora) ou seja, começamos a testar pelas unidades e módulos internos e deixamos para fazer a integração dessas partes depois. Começar de dentro para fora leva a dúvidas, como: Como saber quando acabou? Será que está testado o suficiente? Será que isso deve ser testado? Ok, Está testado! mas será que quando integrar vai funcionar? Assim é muito fácil perder de vista o objetivo do que está sendo desenvolvido.

A fraqueza do TDD é induzir os desenvolvedores a testarem o código da maneira que eles entendem ao invés de testar se o código cumpre o que foi requerido pelo negócio.

Para contornar esse problema nasceu o BDD, Behaviour Driven Development (desenvolvimento guiado por comportamento). O lançamento do livro “The RSpec Book: Behaviour Driven Development with RSpec, Cucumber, and Friends” escrito por David Chelimsky com o apoio do Dan North, que também ajudou na escrita de ferramentas como Rspec, framework de testes para Ruby, e o Cucumber, uma ferramenta de automatização de testes, foi um dos responsáveis pela disseminação do BDD.

Como o BDD funciona

Quando um analista de negócio (também conhecido como BA que vem de Business Analyst) escreve um caso de uso ele detalha como o sistema deve se comportar. Quando ele escreve estórias de usuário ele descreve critérios de aceitação que definem os comportamentos esperados do sistema.

O exemplo abaixo demonstra uma estória escrita por um BA para a criação de produtos em uma pagina de admin:

1 Feature: Product management
2 Scenario: User adds a new product
3   Given I go to the new products page
4   When I fill the form
5   And I press submit
6   Then I should be redirected to a page showing the created product

A principal ideia no exemplo acima é quebrar o cenário em três partes:

GIVEN (dado): Descreve o estado inicial, antes de começar o comportamento que está sendo especificado.

WHEN (quando): Descreve o comportamento esperado.

THEN (então): Descreve as mudanças esperadas baseadas no comportamento.

O exemplo anterior utiliza o Cucumber que é uma das ferramentas mais utilizadas para implementação de BDD. A grande vantagem do Cucumber é transformar especificações como a que vimos acima em casos de teste executáveis, permitindo que desenvolvimento, negócio e marketing trabalhem juntos validando se uma determinada funcionalidade está sendo implementada como esperado. Esse tipo de ferramenta é utilizada para business facing tests, testes que não serão validados por programadores. Com uma ferramenta que disponibiliza a validação dos comportamentos é possível que clientes, usuários, BA ou qualquer pessoa envolvida verifique se o comportamento esperado está sendo contemplado, quebrando a barreira de comunicação entre desenvolvedores e não desenvolvedores.

O GivenWhenThen e o BDD não se prendem a uma ferramenta ou padrão de uso, pode-se utilizar os ensinamentos adotando o que for possível para melhorar a qualidade do que estamos desenvolvendo. Mesmo não trabalhando em um projeto ágil, onde não existam estórias de usuário nem Cucumber, é possível aplicar técnicas do BDD.

O outside-in

O principal foco do BDD é garantir que o comportamento descrito está sendo contemplado no desenvolvimento e para isso ele trabalha de fora para dentro.

Dan North no artigo What’s in a Story diz que:

O behaviour-driven development (BDD), é uma metodologia outside-in. Ela começa por fora, identificando as necessidades do negócio e depois parte para a definição das funcionalidades para alcançar essas necessidades. Cada funcionalidade é capturada como uma estória, na qual é definido o escopo da funcionalidade com o seu critério de aceitação.

No artigo é possível entender que o BDD funciona de fora para dentro em nível gerencial, mas isso também se aplica ao desenvolvimento da funcionalidade. Quando desenvolvemos uma funcionalidade seguindo a metodologia clássica do TDD tendemos a começar desenvolvendo as unidades do sistema, e depois fazer a integração entre elas, essa forma de desenvolver isola camadas e ofusca a visão do objetivo principal, o que realmente é importante para o usuário. É difícil saber se algo está sendo testado o suficiente e se vai funcionar quando integrado com as outras partes do sistema.

Aplicando BDD e TDD é possível unir os benefícios do desenvolvimento guiado por testes com o valor do desenvolvimento guiado por comportamento, vamos falar sobre isso no próximo capítulo.

BDD com Mocha e Chai

Mocha e Chai são os módulos que utilizamos no decorrer do livro, ambos baseados no Rspec do Ruby. No exemplo a seguir veremos como utilizar as técnicas do BDD para guiar o desenvolvimento.

Exemplo com Mocha:

1 describe('Products Management', () => {
2   describe('when adding products', () => {
3     it('should save a product into the database', () => {
4     });
5   });
6 });

Como vimos no capítulo onde configuramos os testes, utilizamos o describe do Mocha para criar uma suíte de testes. No exemplo acima adicionamos uma suíte principal, que descreve o cenário como um todo, a Products Management, essa suíte de testes vai tratar do gerenciamento de produtos da aplicação.

Seguindo o GivenWhenThen adicionamos uma nova suíte que descreve o comportamento esperado: when adding products, ou seja, quando adicionar produtos então devem acontecer as mudanças esperadas. Para o Then utilizamos o it do Mocha. O it se refere a um caso de teste, no exemplo acima estabelecemos que: should save a product into the database (deve salvar um produto no banco de dados). Tanto o Mocha quanto o Chai são híbridos e permitem aplicar técnicas de TDD e BDD o it por exemplo é inspirado no BDD.

Quando executarmos o teste a saída no terminal será:

1   Products Management
2     when adding products
3       ✓ should save a product into the database
4 
5 
6   1 passing

Note que não é necessário nenhum código para entender a intenção desse teste, a maneira como ele foi construído descreve a intenção.

O Mocha é um test runner, sua responsabilidade é executar os testes. Para fazer as verificações conhecidas no TDD como asserts (asserções), e no BDD como expectations(expectativas), o Mocha possibilita utilizar módulos externos, como o Chai.

O Chai é uma biblioteca que tem a responsabilidade de fazer asserts/expectations, ele foi criado seguindo os princípios de descrição do BDD mas também suporta o TDD.

O combo Mocha/Chai ficou conhecido pela forma como os dois se complementam e possibilitam a criação de testes legíveis. No exemplo abaixo adicionamos uma expectation utilizando o Chai:

1 describe('Products Management', () => {
2   describe('when adding products', () => {
3     it('should save a product into the database', () => {
4 
5       expect(Product.save()).to.be.equal(true);
6     });
7   });
8 });

O expect faz parte da API do Chai, com ele é possível encadear expectativas. O exemplo acima dispensa explicações, basta traduzir livremente para o português e temos a descrição exata do que está sendo verificado: Espera que Product.save() seja igual a true (verdadeiro).

CRUD operations

Neste capítulo serão adicionadas as operações de CRUD (Create, Read, Update, Delete) possibilitando a alteração do recurso product. Serão aplicadas as técnicas de BDD que foram vistas anteriormente, aqui chamaremos os testes de alto nível de teste de integração end 2 end (de ponta a ponta) ou teste de aceitação. Estes testes irão validar se a funcionalidade foi implementada como esperado.

Busca por id

Um padrão bastante comum em APIs REST é a busca de um recurso pelo identificador (id), por exemplo http://minha_api/products/56cb91bdc3464f14678934ca deve retornar o produto correspondente ao id informado na rota. Nossa API ainda não suporta essa funcionalidade, então vamos implementá-la. Como esse será um novo endpoint precisamos começar pelo teste de integração end 2 end que fica no arquivo: test/integration/routes/products_spec.js

Iniciaremos permitindo o reuso de código, tornando-o mais genérico, permitindo assim a reutilização:

 1 +  const defaultId = '56cb91bdc3464f14678934ca';
 2     const defaultProduct = {
 3       name: 'Default product',
 4       description: 'product description',
 5       price: 100
 6     };
 7     const expectedProduct = {
 8      __v: 0,
 9 -    _id: '56cb91bdc3464f14678934ca',
10 +    _id: defaultId,
11      name: 'Default product',
12      description: '

O _id foi extraido do expectedProduct para uma constant isso será útil para testar a rota a seguir.

Dentro do cenário de testes: GET /products adicionaremos o seguinte teste:

 1  context('when an id is specified', done => {
 2       it('should return 200 with one product', done => {
 3 
 4         request
 5           .get(`/products/${defaultId}`)
 6           .end((err, res) => {
 7             expect(res.statusCode).to.eql(200);
 8             expect(res.body).to.eql([expectedProduct]);
 9             done(err);
10           });
11       });
12     });

Note que esse caso de teste é similar ao “should return a list of products”, que está dentro do mesmo cenário. Adicionamos um context, pois mesmo sendo no /products agora o foco será o contexto de busca por id onde é feito um GET para products/56cb91bdc3464f14678934ca, ou seja filtrando somente um produto.

Adicionado o teste, vamos executá-lo:

1 $ npm run test:integration

A saida deve ser a seguinte:

 1     GET /products
 2       ✓ should return a list of products
 3       when an id is specified
 4         1) should return 200 with one product
 5 
 6 
 7   1 passing (141ms)
 8   1 failing
 9 
10   1) Routes: Products GET /products when an id is specified should return 200 wi\
11 th one product:
12 
13       Uncaught AssertionError: expected 404 to deeply equal 200
14       + expected - actual
15 
16       -404
17       +200

Quebrou! Agora já temos por onde começar. Era esperado 200, código http de sucesso, e recebemos 404, código http de não encontrado (NOT_FOUND), o que significa que ainda não existe a rota que está sendo requisitada, esse deve ser o primeiro passo a ser implementado.

Vamos alterar o arquivo de rotas de produtos: src/routes/products.js, adicionando a seguinte rota:

1 router.get('/', (req, res) => productsController.get(req, res));
2 + router.get('/:id', (req, res) => productsController.getById(req, res));

Executando os testes novamente o erro deve ser outro, como o seguinte:

1 1) Routes: Products GET /products when an id is specified should return 200 with\
2  one product:
3 
4       Uncaught AssertionError: expected 500 to deeply equal 200
5       + expected - actual
6 
7       -500
8       +200

500, Internal Server Error: Isso significa que a rota foi encontrada mas houve outro erro ao seguir a requisição, pois não temos mais nada implementado. O próximo passo é adicionar lógica para termos uma resposta.

O erro 500 aconteceu pois o método para buscar por id não existe no productsController, agora é o momento de criá-lo. Note que o teste end 2 end serve como um guia para garantir que estamos no caminho certo. Quando a rota foi criada não havia necessidade de testes pois a lógica era simples, já no controller o teste será necessário pois ele vai conter certa lógica. Nesse momento seguimos o ciclo do TDD. Algo como a imagem abaixo:

bdd end 2 end
bdd end 2 end

O teste de ponta a ponta é o teste de aceitação, além de guiar o desenvolvimento ele também é responsável por validar se a funcionalidade que estamos desenvolvendo está ou não completa. Esse teste não segue o fluxo do TDD, pois ele será executado inúmeras vezes até que passe, quando ele passar significa que tudo que é necessário para a funcionalidade estar completa foi desenvolvido.

Dentro desse grande teste de aceitação serão incluídos inúmeros outros testes, que podem ser de integração, de unidade e etc; esses testes sim seguirão o ciclo do TDD.

Os métodos que serão criados no controller seguirão o TDD como já vimos no livro. Vamos começar melhorando o reaproveitamento de código alterando o teste de unidade test/unit/controllers/products_spec.js:

 1 +  const defaultRequest = {
 2 +    params: {}
 3 +  };
 4   
 5    describe('get() products', () => {
 6      it('should call send with a list of products', () => {
 7 -      const request = {};
 8        const response = {
 9          send: sinon.spy()
10       };

O objeto request foi movido para fora do teste e foi renomeado para defaultRequest para permitir sua reutilização por todos os casos de teste. Também adicionamos um objeto params dentro do defaultRequest para que fique similar ao objeto enviado pelo express.

A próxima alteração a ser feita é a requisição para o controller, como alteramos o nome de request para defaultRequest será necessário alterar a seguinte linha:

1 +      return productsController.get(defaultRequest, response)

Pronto, o teste deve estar assim:

 1  const defaultRequest = {
 2     params: {}
 3   };
 4 
 5   describe('get() products', () => {
 6     it('should call send with a list of products', () => {
 7       const response = {
 8         send: sinon.spy()
 9       };
10 
11       Product.find = sinon.stub();
12       Product.find.withArgs({}).resolves(defaultProduct);
13 
14       const productsController = new ProductsController(Product);
15 
16       return productsController.get(defaultRequest, response)
17         .then(() => {
18           sinon.assert.calledWith(response.send, defaultProduct);
19         });
20     });

Execute os testes de unidade, eles devem estar passando.

 1 $ npm run test:unit
 2 
 3 
 4   Controllers: Products
 5     get() products
 6       ✓ should call send with a list of products
 7       ✓ should return 400 when an error occurs
 8 
 9 
10   2 passing

Testes verdes! Vamos criar o caso de teste para a busca por id, o teste ficará assim:

 1 describe('getById()', () => {
 2     it('should call send with one product', () => {
 3       const fakeId = 'a-fake-id';
 4       const request = {
 5         params: {
 6           id: fakeId
 7         }
 8       };
 9       const response = {
10         send: sinon.spy()
11       };
12 
13       Product.find = sinon.stub();
14       Product.find.withArgs({ _id: fakeId }).resolves(defaultProduct);
15 
16       const productsController = new ProductsController(Product);
17 
18       return productsController.getById(request, response)
19         .then(() => {
20           sinon.assert.calledWith(response.send, defaultProduct);
21         });
22     });
23   });

O nome “should call send with one product” reflete o cenário que esperamos, ou seja, é esperado que o método send seja chamado com apenas um produto. Dentro do teste é criado uma constant chamada fakeId referente ao id do produto que será buscado. Logo após é criado um objeto request igual ao enviado pelo express nas requisições, quando um parâmetro é enviado o express adiciona ele dentro do objeto params, como no código acima onde adicionamos o id como parâmetro.

A Próxima parte do código do teste que devemos dar atenção é esta:

1 Product.find.withArgs({ _id: fakeId }).resolves(defaultProduct);

Aqui é utilizado o stub do método find para adicionar um comportamento sempre que ele for chamado recebendo o parâmetro _id com o valor do fakeId. O _id é a chave primária do Mongo então para fazer o filtro por id precisamos fazer uma busca pela chave _id. O método withArgs do stub do Sinon serve para adicionar um comportamento baseado em uma condição, no nosso caso quando o método find for chamado com o parâmetro _id com o valor do fakeId ele deve resolver uma Promise retornando o defaultProduct, simulando assim uma chamada ao banco de dados.

O método que será chamado é o getById, como no trecho abaixo:

1 return productsController.getById(request, response)

Vamos executar os testes de unidade:

1 $ npm run test:unit

Devemos receber o seguinte erro:

 1 Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       1) should call send with one product
 7 
 8 
 9   2 passing (22ms)
10   1 failing
11 
12   1) Controller: Products getById() should call send with one product:
13      TypeError: productsController.getById is not a function

O erro diz que o método getById não é uma função, isso porque ainda não foi implementada a lógica, o stub que criamos não foi chamado e não retornou uma Promise. Vamos mudar o teste de unidade para o passo Green implementando o necessário para que o mesmo passe.

Devemos criar o método getById no controller de products que fica em: src/controllers/products.js. O código suficiente para o teste passar contém:

 1     getById(req, res) {
 2 
 3     return Promise.resolve([{
 4       __v: 0,
 5       _id: '56cb91bdc3464f14678934ca',
 6       name: 'Default product',
 7       description: 'product description',
 8       price: 100
 9     }])
10       .then(products => res.send(products))
11   }

O trecho acima retorna uma Promise resolvida com um array contendo um produto fake, igual o que esperamos no caso de teste, e após o método send é chamado com esse produto.

Executando os testes de unidade:

1 $ npm run test:unit

A resposta deve ser:

1   Controller: Products
2     get() products
3       ✓ should call send with a list of products
4       ✓ should return 400 when an error occurs
5     getById()
6       ✓ should call send with one product
7 
8 
9   3 passing (20ms)

Teste unitário passando! Agora ele está no passo Green do TDD. Podemos partir para o Refactor.

Vamos alterar o método getById em src/controllers/products.js para ficar similar a este:

1   getById(req, res) {
2     const { params: { id } } = req;
3 
4     return this.Product.find({ _id:id })
5       .then(products => res.send(products))
6       .catch(err => res.status(400).send(err.message));
7   }

Na primeira linha extraímos o id do objeto params dentro de req e no método find do mongoose adicionamos um filtro por id.

Realizada a alteração basta executar os testes novamente, começando pelo teste de unidade:

1 $ npm run test:unit

A saida deve ser:

1   Controller: Products
2     get() products
3       ✓ should call send with a list of products
4       ✓ should return 400 when an error occurs
5     getById()
6       ✓ should call send with one product
7 
8 
9   3 passing (17ms)

Após os testes de unidade, devemos executar nosso teste end 2 end para validar:

1 $ npm run test:integration

A saida deve ser:

1   Routes: Products
2     GET /products
3       ✓ should return a list of products
4       when an id is specified
5         ✓ should return 200 with one product
6 
7 
8   2 passing (152ms)

O teste passou! Ou seja, nossa funcionalidade está implementada. Não se preocupe se ainda está meio confuso. Vamos aplicar esse conceito no decorrer do livro para a criação dos outros endpoints da API, aqui o objetivo é mostrar como o BDD e o TDD trabalham juntos e como a combinação dos dois ajuda no desenvolvimento criando um visão do que deve ser desenvolvido.

Criando um recurso

Nos passos anteriores trabalhamos nas buscas para listar todos os produtos e também filtrar por apenas um produto. Nesta etapa vamos trabalhar na criação de produtos na API.

Começaremos adicionando um teste de integração end 2 end no arquivo test/integration/routes/products_spec.js com o seguinte código:

 1    describe('POST /products', () => {
 2     context('when posting a product', () => {
 3 
 4       it('should return a new product with status code 201', done => {
 5         const customId = '56cb91bdc3464f14678934ba';
 6         const newProduct = Object.assign({},{ _id: customId, __v:0 }, defaultPro\
 7 duct);
 8         const expectedSavedProduct = {
 9           __v: 0,
10           _id: customId,
11           name: 'Default product',
12           description: 'product description',
13           price: 100
14         };
15 
16         request
17           .post('/products')
18           .send(newProduct)
19           .end((err, res) => {
20             expect(res.statusCode).to.eql(201);
21             expect(res.body).to.eql(expectedSavedProduct);
22             done(err);
23           });
24       });
25     });
26   });

Usamos um novo describe pois separamos os cenários de testes por recursos da API, isso facilita a legibilidade e entendimento dos testes. O nosso teste deve criar um produto e retornar 201, com o produto criado. Note que é criado um customId e logo após sobrescrevemos o id do defaultProduct pelo customId usando Object.assign, para copiar o objeto e atribuir um novo valor ao id. Isso é necessário porque um novo produto será criado e ele precisa ter um id diferente do defaultProduct que já foi criado no beforeEach.

Em seguida criamos o expectedSavedProduct. Este é o objeto referente ao que esperamos que a rota de criação de produtos devolva no teste.

Na sequência, utilizamos o supertest para realizar um HTTP POST para a rota /products da API, enviando o objeto newProduct, anteriormente criado.

Quando a requisição terminar a resposta será validada:

1 expect(res.statusCode).to.eql(201);
2 expect(res.body).to.eql(expectedSavedProduct);

O teste vai verificar se a resposta da requisição é igual ao expectedSavedProduct, e o código http é igual a 201. Se sim, nosso produto foi criado com sucesso.

Executando os testes de integração, conforme:

1 $ npm run test:integration

Teremos a seguinte resposta:

 1  Routes: Products
 2     GET /products
 3       ✓ should return a list of products
 4       when an id is specified
 5         ✓ should return 200 with one product
 6     POST /products
 7       when posting a product
 8         1) should return a new product with status code 201
 9 
10 
11   2 passing (179ms)
12   1 failing
13 
14   1) Routes: Products POST /products when posting a product should return a new \
15 product with status code 201:
16 
17       Uncaught AssertionError: expected 404 to deeply equal 201
18       + expected - actual
19 
20       -404
21       +201

Já vimos esse cenário antes: esperávamos 200 e recebemos 404, ou seja, a rota não foi encontrada. Vamos adicioná-la no arquivo src/routes/products.js

1 router.get('/', (req, res) => productsController.get(req, res));
2 router.get('/:id', (req, res) => productsController.getById(req, res));
3 + router.post('/', (req, res) => productsController.create(req, res));

Executando os testes novamente a saída deve ser:

1       Uncaught AssertionError: expected 500 to deeply equal 201
2       + expected - actual
3 
4       -500
5       +201

Erro interno! É hora de implementar o controller.

Abra o teste de unidade em test/unit/controllers/products_spec.js e adicione o seguinte teste:

 1   describe('create() product', () => {
 2     it('should call send with a new product', () => {
 3       const requestWithBody = Object.assign({}, { body: defaultProduct[0] }, def\
 4 aultRequest);
 5       const response = {
 6         send: sinon.spy(),
 7         status: sinon.stub()
 8       };
 9       class fakeProduct {
10         save() {}
11       }
12 
13       response.status.withArgs(201).returns(response);
14       sinon.stub(fakeProduct.prototype, 'save').withArgs().resolves();
15 
16       const productsController = new ProductsController(fakeProduct);
17 
18       return productsController.create(requestWithBody, response)
19         .then(() => {
20           sinon.assert.calledWith(response.send);
21         });
22     });
23   });

Para simular um objeto de request do express precisamos de um objeto que possua além das propriedades do defaultRequest também um body que contenha os dados enviados por post. Para isso é criado o requestWithBody, um novo objeto criado a partir dos dados padrão de request que usamos nos testes anteriores e adicionado um body com o defaultProduct. Dessa maneira possuímos uma requisição de post idêntica a enviada pelo express.

Os objetos response e fakeProduct seguem o mesmo padrão dos outros casos de teste. A única mudança é:

1 response.status.withArgs(201).returns(response);

Aqui definimos que response.status deve ser chamado com 201, ou seja, que o recurso foi criado com sucesso; Para simular a ação de save no banco pelo model do mongoose adicionamos o seguinte stub:

1 sinon.stub(fakeProduct.prototype, 'save').withArgs().resolves();

Quando o método save do fakeProduct for chamado com qualquer argumento, ele vai retornar uma Promise resolvida.

Já temos os testes necessários e podemos rodar os testes de unidade:

1 $ npm run test:unit

A saída será:

 1     Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       1) should call send with a new product
 9 
10 
11   3 passing (22ms)
12   1 failing
13 
14   1) Controller: Products create() product should call send with a new product:
15      TypeError: productsController.create is not a function

Ainda não criamos o método create no controller, esse será o nosso próximo passo. Vamos criar um método create no productsController:

1   create(req, res) {
2 
3     res.send(req.body);
4     return Promise.resolve();
5 
6   }

No teste verificamos se o response.send está sendo chamado com um produto criado, e esperamos por uma Promise. Essa é a menor implementação possível para atender ao teste. Ao executar os testes de unidade novamente devemos ter a seguinte resposta:

 1   Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9 
10 
11   4 passing (26ms)

Estamos no estado Green dos testes de unidade. Vamos partir para o Refactor alterando o productsController, adicionando o seguinte:

1   create(req, res) {
2     const product = new this.Product(req.body);
3 
4     return product.save()
5       .then(() => res.status(201).send(product));
6   }

Após a alteração o teste deve estar passando com sucesso. Agora é necessário testar o caso de erro que é muito importante na hora de criar algum recurso.

Para testar um caso de erro precisamos que o método de criação de produto do ProductsController retorne um erro. Podemos criar este cenário tanto no teste de integração end 2 end quanto no teste de unidade. Como o teste de integração cobre a rota que recebe a resposta do controller e envia para o usuário, é indiferente a resposta que ele vai receber, independente de ser sucesso ou erro ela apenas será repassada. Para testarmos esse cenário com mais assertividade e mais controle dos componentes envolvidos vamos testar somente de forma unitária. Nos próximos capítulos vamos ver algumas maneiras de trabalhar com erros em testes de integração, mas neste momento vamos focar no nível unitário.

Adicione o seguinte teste no arquivo test/unit/controllers/products_spec.js:

 1 	context('when an error occurs', () => {
 2       it('should return 422', () => {
 3         const response = {
 4           send: sinon.spy(),
 5           status: sinon.stub()
 6         };
 7 
 8         class fakeProduct {
 9           save() {}
10         }
11 
12         response.status.withArgs(422).returns(response);
13         sinon.stub(fakeProduct.prototype, 'save').withArgs().rejects({ message: \
14 'Error' });
15 
16         const productsController = new ProductsController(fakeProduct);
17 
18         return productsController.create(defaultRequest, response)
19           .then(() => {
20             sinon.assert.calledWith(response.status, 422);
21           });
22       });
23     });

Imagino que vocês já estejam treinados em escrever testes usando o Sinon. O caso de teste acima informa que esse teste deve retornar o código 422 quando acontecer um erro na criação de um novo produto.

Segundo a especificação do HTTP 1.1 o código 422 faz parte dos grupos de erro 4xx e significa Unprocessable Entity ou seja, a entidade não pode ser processada. Esse código de erro é utilizado para cenários onde a requisição foi recebida pelo servidor mas os dados não puderam ser validados. Um exemplo clássico é o caso do email, o usuário pode ter enviado os dados corretamente, mas o email é inválido. O servidor deve responder com 422, informando que recebeu os dados mas não conseguiu validar.

Para simular um caso de erro precisamos fazer com que o método de save do Mongoose retorne um erro. Como ele é uma Promise basta rejeitarmos o stub, como é feito aqui:

1 sinon.stub(fakeProduct.prototype, 'save').withArgs().rejects({ message: 'Error' \
2 });

Executando os testes de unidade:

1 $ npm run test:unit

A saida será:

 1   Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         1) should return 422
11 
12 
13   4 passing (26ms)
14   1 failing
15 
16   1) Controller: Products create() product when an error occurs should return 42\
17 2:
18      Error

Como já era esperado recebemos um erro, pois ainda não implementamos essa lógica. Vamos atualizar o método create no ProductsController e adicionar um catch para pegar o erro quando caso ele ocorra:

1   create(req, res) {
2     const product = new this.Product(req.body);
3 
4 		return product.save()
5 			.then(() => res.status(201).send(product))
6 +			.catch(err => res.status(412).send(err.message));
7   }

Executando os testes novamente, a saída deve ser:

 1   Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11 
12 
13   5 passing (28ms)

Perfeito! Nossa rota de criação de produtos está pronta. Vamos nos certificar de que a implementação da funcionalidade está correta executando o teste end 2 end:

 1 $ npm run test:integration
 2 
 3 Routes: Products
 4     GET /products
 5       ✓ should return a list of products
 6       when an id is specified
 7         ✓ should return 200 with one product
 8     POST /products
 9       when posting a product
10         ✓ should return a new product with status code 201
11 
12 
13   3 passing (169ms)

Atualizando um recurso

Já é possível criar e listar produtos na nossa API, o próximo passo é a edição. Como de costume, vamos começar pelo teste end 2 end descrevendo o comportamento esperado dessa funcionalidade.

Adicione o seguinte cenário contendo um caso de teste no arquivo test/integration/routes/products_spec.js

 1   describe('PUT /products/:id', () => {
 2     context('when editing a product', () => {
 3       it('should update the product and return 200 as status code', done => {
 4         const customProduct = {
 5           name: 'Custom name'
 6         };
 7         const updatedProduct = Object.assign({}, customProduct, defaultProduct)
 8 
 9         request
10           .put(`/products/${defaultId}`)
11           .send(updatedProduct)
12           .end((err, res) => {
13             expect(res.status).to.eql(200);
14             done(err);
15           });
16       });
17     });
18   });

Esse teste é muito similar ao teste de criação de produto, a única alteração é o verbo e a rota para a qual a requisição é feita, conforme o seguinte trecho de código:

1       .put(`/products/${defaultId}`)

Segundo o Rest, para a criação de um novo recurso utilizamos o verbo POST e para a atualização de um recurso devemos utilizar PUT.

O produto que será atualizado é o defaultProduct, este produto é criado antes de cada teste pelo beforeEach. Para não atualizar o defaultProduct diretamente vamos criar um objeto a partir dele usando Object.assign:

1  const updatedProduct = Object.assign({}, customProduct, defaultProduct)

Executando os testes de integração:

1 $ npm run test:integration

A saída deve ser:

 1    Routes: Products
 2     GET /products
 3       ✓ should return a list of products
 4       when an id is specified
 5         ✓ should return 200 with one product
 6     POST /products
 7       when posting a product
 8         ✓ should return a new product with status code 201
 9     PUT /products/:id
10       when editing a product
11         1) should update the product and return 200 as status code
12 
13 
14   3 passing (403ms)
15   1 failing
16 
17   1) Routes: Products PUT /products/:id when editing a product should update the\
18  product and return 200 as status code:
19 
20       Uncaught AssertionError: expected 404 to deeply equal 200
21       + expected - actual
22 
23       -404
24       +200

O teste retornar que esperava 200 (sucesso) mas recebeu 404 (não encontrado), como já esperávamos. Vamos alterar o arquivo src/routes/products.js e adicionar a seguinte rota:

1 + router.put('/:id', (req, res) => productsController.update(req, res)); 

Executando os testes novamente a saída deve ser:

1   1) Routes: Products PUT /products/:id when editing a product should update the\
2  product and return 200 as status code:
3 
4       Uncaught AssertionError: expected 500 to deeply equal 200
5       + expected - actual
6 
7       -500
8       +200

É hora de descer para o nível de unidade para fazer a implementação no controller. Vamos adicionar o seguinte cenário de teste unitário em test/unit/controllers/products_spec.js

 1   describe('update() product', () => {
 2     it('should respond with 200 when the product is updated', () => {
 3       const fakeId = 'a-fake-id';
 4       const updatedProduct = {
 5         _id: fakeId,
 6         name: 'Updated product',
 7         description: 'Updated description',
 8         price: 150
 9       };
10       const request = {
11         params: {
12           id: fakeId
13         },
14         body: updatedProduct
15       };
16       const response = {
17         sendStatus: sinon.spy()
18       };
19 
20       class fakeProduct {
21         static findOneAndUpdate() {}
22       }
23 
24       const findOneAndUpdateStub = sinon.stub(fakeProduct, 'findOneAndUpdate');
25       findOneAndUpdateStub.withArgs({ _id: fakeId }, updatedProduct).resolves(up\
26 datedProduct);
27 
28       const productsController = new ProductsController(fakeProduct);
29 
30       return productsController.update(request, response)
31         .then(() => {
32           sinon.assert.calledWith(response.sendStatus, 200);
33         });
34     });
35   });

Este teste é maior, mas não há nada que já não tenhamos feito em outros testes. A chave para o update de um recurso é o id dele. Criamos uma constant chamada fakeId para poder reutilizá-lo em outras partes do teste, em seguida criamos um objeto chamado updatedProduct que representa o produto que será atualizado pelo Mongoose.

Para simular a requisição que será feita pelo express criamos um objeto com as mesmas chaves que o express envia na requisição, como podemos ver aqui:

1       const request = {
2         params: {
3           id: fakeId
4         },
5         body: updatedProduct
6       };

O método do Mongoose que utilizaremos para atualizar o recurso é o findOneAndUpdate, segundo a documentação é necessário passar o _id e o objeto que queremos atualizar. Para testar isoladamente criamos um model fake, o fakeProduct, que possui o método a seguir:

1       class fakeProduct {
2         static findOneAndUpdate() {}
3       }

Para adicionar o comportamento esperado no método findOneAndUpdate vamos transformá-lo em um stub:

1       const findOneAndUpdateStub = sinon.stub(fakeProduct, 'findOneAndUpdate');

Depois, definimos que quando o stub for chamado com um objeto que contenha uma chave _id e um objeto igual ao updatedProduct, ele deve resolver uma Promise:

1 findOneAndUpdateStub.withArgs({ _id: fakeId }, updatedProduct).resolves(updatedP\
2 roduct);

Segundo a documentação do Mongoose, o método findOneAndUpdate não retorna valor, por isso a Promise não irá retornar nada, somente será resolvida para dar sucesso.

Executando os testes de unidade:

1 $ npm run test:unit 

Vamos ter a seguinte saída:

 1   Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       1) should respond with 200 when the product is updated
13 
14 
15   5 passing (38ms)
16   1 failing
17 
18   1) Controller: Products update() product should respond with 200 when the prod\
19 uct is updated:
20      TypeError: productsController.update is not a function
21       at Context.it (test/unit/controllers/products_spec.js:154:

O teste retorna que o método update não existe, então vamos adicioná-lo com lógica suficiente apenas para que ele passe:

1 	update(req, res) {
2 		res.sendStatus(200);
3 		return Promise.resolve();
4 	}

No teste acima esperamos que o objeto response do express seja chamado com 200, o que garante que o produto foi atualizado. Essa é a implementação mínima para fazer o teste passar.

Executando os testes de unidade:

1 $ npm run test:unit

Devemos ter a seguinte saída:

 1  Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       ✓ should respond with 200 when the product is updated
13 
14 
15   6 passing (37ms)

Vamos à refatoração, o código do método update deve ficar assim:

1  update(req, res) {
2     return this.Product.findOneAndUpdate({ _id: req.params.id}, req.body)
3       .then(() => res.sendStatus(200));
4   }

Vamos executar os testes de unidade:

1 $ npm run test:unit

A saida deve ser:

 1    Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       ✓ should respond with 200 when the product is updated
13 
14 
15   6 passing (27ms)

Também vamos executar os testes de integração end 2 end:

1 $ npm run test:integration

A saída deve ser:

 1   Routes: Products
 2     GET /products
 3       ✓ should return a list of products
 4       when an id is specified
 5         ✓ should return 200 with one product
 6     POST /products
 7       when posting a product
 8         ✓ should return a new product with status code 201
 9     PUT /products/:id
10       when editing a product
11         ✓ should update the product and return 200 as status code
12 
13 
14   4 passing (184ms)

Todos os testes estão passando e a atualização de produtos está funcionando. O próximo passo é adicionar um teste para o caso de algum erro acontecer, similar ao que já foi feito na criação de produtos.

Vamos atualizar o teste test/unit/controllers/products_spec.js adicionando o seguinte caso de teste dentro do cenário update:

 1     context('when an error occurs', () => {
 2       it('should return 422', () => {
 3         const fakeId = 'a-fake-id';
 4         const updatedProduct = {
 5           _id: fakeId,
 6           name: 'Updated product',
 7           description: 'Updated description',
 8           price: 150
 9         };
10         const request = {
11           params: {
12             id: fakeId
13           },
14           body: updatedProduct
15         };
16         const response = {
17           send: sinon.spy(),
18           status: sinon.stub()
19         };
20 
21         class fakeProduct {
22           static findOneAndUpdate() {}
23         }
24 
25         const findOneAndUpdateStub = sinon.stub(fakeProduct, 'findOneAndUpdate');
26         findOneAndUpdateStub.withArgs({ _id: fakeId }, updatedProduct).rejects({\
27  message: 'Error' });
28         response.status.withArgs(422).returns(response);
29 
30         const productsController = new ProductsController(fakeProduct);
31 
32         return productsController.update(request, response)
33           .then(() => {
34             sinon.assert.calledWith(response.send, 'Error');
35           });
36       });
37 
38     });

Não há nada de novo comparado a o que foi feito no create, foi utilizada a mesma técnica de stub como podemos ver aqui:

1 findOneAndUpdateStub.withArgs({ _id: fakeId }, updatedProduct).rejects({ message\
2 : 'Error' });

Ao executar os testes de unidade:

1 $ npm run test:unit

Devemos ter a seguinte saída:

 1   Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       ✓ should respond with 200 when the product is updated
13       when an error occurs
14         1) should return 422
15 
16 
17   6 passing (26ms)
18   1 failing
19 
20   1) Controller: Products update() product when an error occurs should return 42\
21 2:
22      Error

Como já esperávamos, o teste está falhando. Vamos atualizar método update do productsController adicionando o método método catch:

1 	update(req, res) {
2 		return this.Product.findOneAndUpdate({ _id: req.params.id}, req.body)
3 +			.then(() => res.sendStatus(200))
4 +			.catch(err => res.status(422).send(err.message));
5 	}

Executando os testes novamente, a saída deve ser:

 1    Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       ✓ should respond with 200 when the product is updated
13       when an error occurs
14         ✓ should return 422
15 
16 
17   7 passing (46ms)

Note que não fizemos o processo Green, isso porque a implementação era clara e simples. Não é necessário escrever código por obrigação, o estágio de Green serve para ajudar e validar o teste.

Deletando um recurso

Já temos o C.R.U do CRUD, o último passo é o DELETE que vai permitir a remoção de recursos da API.

Como sempre, começamos pelo teste de integração end 2 end em test/integration/routes/products_spec.js

 1   describe('DELETE /products/:id', () => {
 2     context('when deleting a product', () => {
 3       it('should delete a product and return 204 as status code', done => {
 4 
 5         request
 6           .delete(`/products/${defaultId}`)
 7           .end((err, res) => {
 8             expect(res.status).to.eql(204);
 9             done(err);
10           });
11       });
12     });
13   });

Dessa vez enviamos uma requisição do tipo DELETE:

1       .delete(`/products/${defaultId}`)

Passando um id de produto. Segundo a especificação do HTTP/1.1 o método delete serve para deletar um recurso do servidor com base na url enviada. A documentação também diz que as respostas podem ser: 200 - a resposta contém um corpo, 202 se a ação não vai ser realizada agora (vai ocorrer em background) ou 204 quando não há retorno, somente a notificação de sucesso. Na maioria das APIs Rest é comum o uso do código 204. Ele é um código de sucesso utilizado para momentos onde é necessário notificar o sucesso, mas a resposta não vai ter dados.

Após adicionar o teste, vamos executar os testes:

1 $ npm run test:integration

A saída deve ser a seguinte:

 1     Routes: Products
 2     GET /products
 3       ✓ should return a list of products
 4       when an id is specified
 5         ✓ should return 200 with one product
 6     POST /products
 7       when posting a product
 8         ✓ should return a new product with status code 201
 9     PUT /products/:id
10       when editing a product
11         ✓ should update the product and return 200 as status code
12     DELETE /products/:id
13       when deleting a product
14         1) should delete a product and return 204 as status code
15 
16 
17   4 passing (221ms)
18   1 failing
19 
20   1) Routes: Products DELETE /products/:id when deleting a product should delete\
21  a product and return 204 as status code:
22 
23       Uncaught AssertionError: expected 404 to deeply equal 204
24       + expected - actual
25 
26       -404
27       +204

Como esperado, temos como retorno o código 404 (Not Found). Vamos adicionar a rota no arquivo src/routes/products.js.

1 + router.delete('/:id', (req, res) => productsController.remove(req, res));

Executando os testes novamente o retorno deve ser:

1  1) Routes: Products DELETE /products/:id when deleting a product should delete \
2 a product and return 204 as status code:
3 
4       Uncaught AssertionError: expected 500 to deeply equal 204
5       + expected - actual
6 
7       -500
8       +204

Agora é hora de adicionar os testes de unidade para o controller. No arquivo test/unit/controllers/products_spec.js adicione o seguinte cenário de teste com o caso de teste a seguir:

 1    describe('delete() product', () => {
 2     it('should respond with 204 when the product is deleted', () => {
 3       const fakeId = 'a-fake-id';
 4       const request = {
 5         params: {
 6           id: fakeId
 7         }
 8       };
 9       const response = {
10         sendStatus: sinon.spy()
11       };
12 
13       class fakeProduct {
14         static remove() {}
15       }
16 
17       const removeStub = sinon.stub(fakeProduct, 'remove');
18 
19       removeStub.withArgs({ _id: fakeId }).resolves([1]);
20 
21       const productsController = new ProductsController(fakeProduct);
22 
23       return productsController.remove(request, response)
24         .then(() => {
25           sinon.assert.calledWith(response.sendStatus, 204);
26         });
27     });
28   });

O método utilizado para remover um produto é o remove, segundo a documentação do Mongoose ele recebe uma ou mais condições para serem removidas e retorna um número inteiro referente a quantos itens foram deletados do banco de dados. Para simular esse cenário vamos criar o seguinte stub no código acima:

1       removeStub.withArgs({ _id: fakeId }).resolves([1]);

Aqui informamos que quando o método remove for chamado com um _id igual ao fakeId deve resolver uma Promise devolvendo um array com 1 elemento.

Executando os testes de unidade devemos ter a seguinte saída:

 1    Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       ✓ should respond with 200 when the product is updated
13       when an error occurs
14         ✓ should return 422
15     delete() product
16       1) should respond with 204 when the product is deleted
17 
18 
19   7 passing (46ms)
20   1 failing
21 
22   1) Controller: Products delete() product should respond with 204 when the prod\
23 uct is deleted:
24      TypeError: productsController.remove is not a function
25       at Context.it (test/unit/controllers/products_spec.js:220:33)

O método remove não foi encontrado, então vamos criá-lo no ProductsController, o código deve ficar assim:

1 	remove(req, res) {
2 		res.sendStatus(204);
3 		return Promise.resolve();
4 	}

Executando os testes de unidade, devemos ter a seguinte saída:

 1     Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       ✓ should respond with 200 when the product is updated
13       when an error occurs
14         ✓ should return 422
15     delete() product
16       ✓ should respond with 204 when the product is deleted
17 
18 
19   8 passing (32ms)

Agora no passo de refatoração adicionaremos a lógica real do método:

1   remove(req, res) {
2     return this.Product.remove({ _id: req.params.id})
3       .then(() => res.sendStatus(204));
4   }

Ambos os testes devem seguir passando com sucesso.

O último passo é o tratamento dos possíveis erros. Nos testes de unidade vamos adicionar o seguinte teste dentro do cenário do método remove:

 1        context('when an error occurs', () => {
 2       it('should return 400', () => {
 3         const fakeId = 'a-fake-id';
 4         const request = {
 5           params: {
 6             id: fakeId
 7           }
 8         };
 9         const response = {
10           send: sinon.spy(),
11           status: sinon.stub()
12         };
13 
14         class fakeProduct {
15           static remove() {}
16         }
17 
18         const removeStub = sinon.stub(fakeProduct, 'remove');
19 
20         removeStub.withArgs({ _id: fakeId }).rejects({message: 'Error'});
21         response.status.withArgs(400).returns(response);
22 
23         const productsController = new ProductsController(fakeProduct);
24 
25         return productsController.remove(request, response)
26           .then(() => {
27             sinon.assert.calledWith(response.send, 'Error');
28           });
29       });
30 
31     });

Executando os testes de unidade devemos ter a seguinte saída:

 1   Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       ✓ should respond with 200 when the product is updated
13       when an error occurs
14         ✓ should return 422
15     delete() product
16       ✓ should respond with 204 when the product is deleted
17       when an error occurs
18         1) should return 400
19 
20 
21   8 passing (35ms)
22   1 failing
23 
24   1) Controller: Products delete() product when an error occurs should return 40\
25 0:
26      Error

A implementação ficará da seguinte maneira:

1 	remove(req, res) {
2 		return this.Product.remove({ _id: req.params.id})
3 			.then(() => res.sendStatus(204))
4 			.catch(err => res.status(400).send(err.message));
5 	}

Note que agora retornamos 400 e não mais 422, 400 significa Bad Request e é um erro genérico, como o delete não recebe e nem valida dados não caberia utilizar o código 422.

Executando os testes de unidade agora a saída deve ser:

 1    Controller: Products
 2     get() products
 3       ✓ should call send with a list of products
 4       ✓ should return 400 when an error occurs
 5     getById()
 6       ✓ should call send with one product
 7     create() product
 8       ✓ should call send with a new product
 9       when an error occurs
10         ✓ should return 422
11     update() product
12       ✓ should respond with 200 when the product is updated
13       when an error occurs
14         ✓ should return 422
15     delete() product
16       ✓ should respond with 204 when the product is deleted
17       when an error occurs
18         ✓ should return 400
19 
20 
21   9 passing (50ms)

Pronto! As operações de CRUD para o recurso de produtos estão prontas. O código dessa parte pode ser encontrado neste link.

Configuração por ambiente

Antes de seguir adicionando funcionalidades a nossa API vamos fazer algumas melhorias. Como temos testes que nos garantem confiança no código, podemos melhorar o design do nosso código sempre que preciso, buscando sempre evoluir a arquitetura. Vamos começar pelo simples (sempre!), evitando complexidades antes que elas sejam realmente necessárias, e vamos evoluindo a arquitetura conforme a demanda. Nos próximos capítulos vamos adicionar autenticação e migrações de banco de dados, também teremos algumas configurações que precisarão ser feitas.

Essas modificações tornam necessário mover o nosso diretório de config para fora de src, assim será possível o reuso das configurações do ambiente; essa parte ficará mais clara durante a implementação.

Alterando a arquitetura

Vamos começar movendo a pasta src/config para fora do diretório src, a estrutura deve ficar como a seguir:

1 ├── README.md
2 ├── config
3 │   └── database.js
4 ├── package.json
5 ├── server.js
6 ├── src
7 └── test

Feito isso vamos executar os testes:

1 $ npm run test:unit

Os testes de unidade devem estar passando, agora executaremos os testes de integração:

1 $ npm run test:integration

Os testes de integração vão quebrar pois o arquivo de configuração não foi encontrado. Vamos fazer as atualizações necessárias para que o código passe a utilizar a configuração a partir do diretório correto.

Vamos alterar o arquivo src/app.js da seguinte maneira:

1 -import database from './config/database'
2 +import database from '../config/database';

Ao executar os testes de integração novamente eles devem passar.

Configurações por ambiente

Provavelmente nossa aplicação irá rodar em vários ambientes, como desenvolvimento, homologação, produção e também teste. Muitas configurações são específicas por ambiente, como por exemplo o nome/url do banco de dados que vai ser diferente entre os ambientes, e isso deve ser fácil de ser configurado e versionado. Para nos ajudar nessa missão utilizaremos o módulo node-config.

Utilizando o módulo node-config

O módulo config suporta múltiplos ambientes, variáveis de ambiente e muitas outras formas de configuração, o que é muito útil na hora de criar aplicações que irão rodar na nuvem ou até mesmo em diferentes servidores. Para começar, vamos instalar o módulo config com o seguinte comando:

1 $ npm install config@1.29.4

Após a instalação precisamos fazer a configuração. No próprio repositorio no github https://github.com/lorenwest/node-config temos uma vasta documentação de como configurar. Resumindo: será necessário criar um arquivo json para cada ambiente e neste arquivo adicionar as propriedades que queremos. Por padrão ele espera que os arquivos de configuração estejam dentro do diretório config, na raiz do projeto, por isso a mudança na arquitetura foi necessária.

Vamos começar criando uma configuração padrão para a nossa API, caso não tenha um arquivo específico para o ambiente o diretório config será utilizado. Vamos criar o arquivo default.json no diretório config/. Neste arquivo teremos a seguinte instrução:

1 {
2   "database": {
3     "mongoUrl": "mongodb://localhost:27017/shop"
4   }
5 }

Essa é nossa configuração padrão, agora vamos criar uma configuração para teste, crie um arquivo test.json no diretório config com a seguinte instrução:

1 {
2   "database": {
3     "mongoUrl": "mongodb://localhost:27017/test"
4   }
5 }

Nesse caso o mongo vai utilizar um banco de teste. O próximo passo é alterar a aplicação para fazer uso dessas configurações. Começamos importando o módulo config no config/database.js, como no trecho de código abaixo:

1  import mongoose from 'mongoose';
2 +import config from 'config';

Em seguida, vamos alterar a linha onde definimos a constante mongodbUrl para que ela utilize o valor que vem do config:

1 -const mongodbUrl = process.env.MONGODB_URL || 'mongodb://localhost:27017/test';
2 +const mongodbUrl = config.get('database.mongoUrl');

Dessa maneira quem irá cuidar da configuração é o config, ele que vai ter a responsabilidade de entregar o mongoUrl baseado no ambiente em que a aplicação está sendo executada.

O módulo config utiliza a variável de ambiente NODE_ENV para saber qual arquivo carregar, como definimos no package.json o NODE_ENV=test para nossos testes, ele vai carregar o arquivo test.json.

1  "test:integration": "NODE_ENV=test mocha --opts test/integration/mocha.opts tes\
2 t/integration/**/*_spec.js",
3  "test:unit": "NODE_ENV=test mocha --opts test/unit/mocha.opts test/unit/**/*_sp\
4 ec.js"

O módulo config trabalha de forma hierárquica sobrescrevendo os arquivos de ambiente, não é necessário colocar todas as variáveis em um novo json, somente as que serão alteradas para determinado ambiente, dessa forma ele usará o valor default para as que não foram alteradas.

Para finalizar vamos alterar o package.json adicionando o comando de test, para que seja possível executarmos ambos os testes de unidade e integração com apenas um comando:

1 -    "test": "echo \"Error: no test specified\" && exit 1",
2 +    "test": "npm run test:unit && npm run test:integration",

Para testar basta executar o seguinte comando:

1 $ npm test

Os testes de unidade e em seguida os testes de integração serão executados.

O código deste capitulo esta disponível em: passo 9

Usuários e autenticação

Antes de entrarmos na autenticação precisamos implementar a parte de usuários da nossa aplicação. Vamos começar criando tests, controller, model e a configuração das rotas para os usuários.

Neste capítulo não vou mostrar o ciclo de TDD passo-a-passo, pois já focamos nisso anteriormente, se surgir alguma dúvida lembre-se que é o mesmo processo que seguimos ao criar a parte de products. O primeiro passo é criar o controller para usuários em src/controllers/users.js, o arquivo deve ter o seguinte conteúdo:

 1 class UsersController {
 2   constructor(User) {
 3     this.User = User;
 4   };
 5 
 6   get(req, res) {
 7     return this.User.find({})
 8       .then(users => res.send(users))
 9       .catch(err => res.status(400).send(err.message));
10   }
11 
12   getById(req, res) {
13     const { params: { id } } = req;
14 
15     return this.User.find({ _id:id })
16       .then(user => res.send(user))
17       .catch(err => res.status(400).send(err.message));
18   }
19 
20   create(req, res) {
21     const user = new this.User(req.body);
22 
23     return user.save()
24       .then(() => res.status(201).send(user))
25       .catch(err => res.status(422).send(err.message));
26   }
27 
28   update(req, res) {
29     const body = req.body;
30     return this.User.findById(req.params.id)
31       .then(user => {
32         user.name = body.name
33         user.email = body.email
34         user.role = body.role
35         if(body.password) {
36           user.password = body.password
37         }
38         return user.save();
39       })
40       .then(() => res.sendStatus(200))
41       .catch(err => res.status(422).send(err.message));
42   }
43 
44   remove(req, res) {
45     return this.User.remove({ _id: req.params.id})
46       .then(() => res.sendStatus(204))
47       .catch(err => res.status(400).send(err.message));
48   }
49 }
50 
51 export default UsersController;

No método update do UsersController temos um cenário diferente do controller de products, vamos entender melhor o porquê em seguida. No momento, basta entender que vamos buscar o usuário do banco de dados e atualizar as propriedades. Caso o campo password esteja setado, ele também será atualizado. Este método update é referente ao método PUT do http. No PUT é esperado que seja enviado todos os campos que aparecem na requisição quando se faz um GET, por exemplo, se fizermos um get users/id a resposta vai conter name, email, role mas não deve conter o password por motivos de segurança. Sendo assim, o password só será recebido no update quando a intenção for alterar a senha, pois o campo não é obrigatório.

Depois de criar o controller é a hora de criarmos o Model em src/models/user.js com o seguinte trecho de código:

 1 import mongoose from 'mongoose';
 2 
 3 const schema = new mongoose.Schema({
 4   name: String,
 5   email: String,
 6   password: String,
 7   role: String
 8 });
 9 
10 schema.set('toJSON', {
11   transform: (doc, ret, options) => ({
12     _id: ret._id,
13     email: ret.email,
14     name: ret.name,
15     role: ret.role
16   })
17 });
18 
19 const User = mongoose.model('User', schema);
20 
21 export default User;

No Model, além de criar o schema também sobrescrevemos o método toJSON que é responsável por transformar os dados que vem do MongoDB para o formato json; vamos utilizar a função transform, que é nativa do Mongoose, para remover o campo password do objeto final, pois não devemos expor a senha do usuário mesmo como hash. Sempre que o Mongoose faz uma busca no Mongo os dados vem em BSON o formato nativo do MongoDB, similar ao JSON só que binário. Depois de receber os dados o Mongoose faz o processo de serialização onde transforma o BSON que veio do banco em JSON para ser utilizado na aplicação, nesse momento é possível intervir nessa serialização e customizar o resultado final, exatamente o que implementamos no toJSON.

Com o Model pronto, vamos agora criar a rota em src/routes/users.js:

 1 import express from 'express';
 2 import UsersController from '../controllers/users';
 3 import User from '../models/user';
 4 
 5 const router = express.Router();
 6 const usersController = new UsersController(User);
 7 router.get('/', (req, res) => usersController.get(req, res));
 8 router.get('/:id', (req, res) => usersController.getById(req, res));
 9 router.post('/', (req, res) => usersController.create(req, res));
10 router.put('/:id', (req, res) => usersController.update(req, res));
11 router.delete('/:id', (req, res) => usersController.remove(req, res));
12 
13 export default router;

Agora precisamos atualizar o index das rotas em src/routes/index.js, para carregar a rota de usuários:

 1 import express from 'express';
 2 import productsRoute from './products';
 3 +import usersRoute from './users';
 4  
 5  const router = express.Router();
 6  
 7  router.use('/products', productsRoute);
 8 +router.use('/users', usersRoute);
 9  router.get('/', (req, res) => res.send('Hello World!'));
10  
11  export default router;

A próxima etapa será adicionar o arquivo de testes, mas, antes disso, vamos atualizar uma configuração nos nossos testes para que seja possível reutilizar a configuração do Supertest.

Vamos começar tornando global a rotina before que atualmente está disponível apenas para products. Vamos remover o trecho de código referente ao before do arquivo test/integration/routes/products_spec.js:

 1 import Product from '../../../src/models/product';
 2  
 3  describe('Routes: Products', () => {
 4 -  let request;
 5 -
 6 -  before(()=> {
 7 -    return setupApp()
 8 -      .then(app => {
 9 -        request = supertest(app)
10 -      });
11 -  });
12 -
13    const defaultId = '56cb91bdc3464f14678934ca';
14    const defaultProduct = {
15      name: 'Default product',

E vamos inserir esse mesmo bloco nas definições globais, em um novo arquivo que vamos chamar de test/integration/global.js:

1 before(()=> {
2   return setupApp()
3     .then(app => {
4       global.request = supertest(app)
5     });
6 });

A última etapa da nossa refatoração é atualizar o arquivo test/integration/mocha.opts adicionando a chamada para o arquivo global.js que acabamos de criar:

1  --require test/integration/helpers.js
2 +test/integration/global.js
3  --reporter spec
4  --compilers js:babel-core/register
5  --slow 5000
6 +--timeout 5000

Dessa maneira o Mocha vai carregar esse arquivo global e executar o método before sempre antes de qualquer outro callback. Assim, o Supertest vai ser inicializado antes de todos os testes de integração. Também aumentamos o timeout para 5000ms, para evitar que algum teste de integração um pouco mais lento possa quebrar o nosso teste.

Executando os testes:

1 $ npm test

Todos os testes devem estar passando, inclusive os testes de usuários.

Encriptando senhas com Bcrypt

Antes de começarmos a trabalhar na autenticação efetivamente, é necessário fazer mais uma melhoria na API. Note que adicionamos usuários, que possuem senhas, e estamos salvando as senhas diretamente no banco como texto.

Senhas em texto plano

Salvar senhas como texto é a maneira mais simples guardar essa informação, e também a mais insegura, pois, se um hacker tiver acesso ao servidor terá acesso às senhas dos usuários. Como as pessoas costumam utilizar a mesma senha para diferentes fins essa falha na nossa aplicação pode comprometer a segurança dos usuários.

Senhas com hashing de mão única

Hashing de mão única é uma prática de encriptação onde se encripta uma mensagem utilizando um algoritmo que não permite a desencriptação, bem mais seguro do que o texto plano. Porém, se o hacker descobrir o algoritmo utilizado pode fazer uso do mesmo para gerar senhas infinitamente, mais cedo ou mais tarde ele vai encontrar a certa, esse ataque é chamado de brute-force.

Exemplo: SHA256("minhasenha") = "79809644A830EF92424A66227252B87BBDFB633A9DAB18BA450C1B8D35665F20"

Senhas com hashing e salt

Hashing oferece mais segurança do que o texto plano, mas ataques como vimos acima podem acontecer. Uma solução para esse problema é utilizar um salt. Um salt nada mais é do que uma string concatenada a uma mensagem (a senha no nosso caso). Dessa maneira, havendo uma string única para a aplicação é possível gerar um hash que é muito difícil de ser quebrado por brute-force.

Exemplo: SHA256("minhasenha" + "meusalt") = "697FDEADE02B2F4C86A5696D1DF998ADA97A6B1420F5BA0C7B4EE2024DBECD1F"

Note que o hash gerado é diferente do exemplo anterior, para alguém gerar um hash igual a esse utilizando brute-force será necessário saber o salt. Ainda temos uma falha de segurança nesse cenário: Se alguém hackear o servidor e descobrir o salt conseguirá gerar hashs com brute-force que serão iguais aos gerados pela aplicação. Pode parecer muito difícil hackear o servidor, descobrir o salt, descobrir o algoritmo e quebrá-lo com brute-force, mas não é. Se você tem um produto aberto deve se preocupar muito com isso, hackers tentam esse tipo de coisa 24h por dia.

Criando senhas seguras com Bcrypt

Bcrypt é um algoritmo de hashing baseado em Blowfish e com algumas características únicas, como a “key factor” que se refere a habilidade de aumentar a quantidade necessária de processamento para criptografar a informação. Aumentar a complexidade de processamento impossibilita a quebra de hashing por ataques como o brute-force por exemplo, pois o tempo necessário para gerar um hash similar é muito grande. O Bcrypt utiliza ainda um salt que é concatenado com o texto (nesse caso, a senha) para aumentar ainda mais a segurança e aleatoriedade do hash final gerado. Uma boa dica é utilizar um salt aleatório para cada senha gerada, isso garante que, mesmo que existam senhas iguais, elas não terão o mesmo hash final. Mas aí vem a pergunta: se vamos gerar hash aleatórios, como é possível verificar a senha do usuário no momento de fazer login? Mágica! Se passarmos a senha em texto plano (que o usuário vai fornecer na hora do login) e o hash gerado quando o usuário foi salvo no banco, o hash gerado pelo algoritmo será igual ao salvo no banco de dados. Vamos ver como isso funciona na prática, começando com a instalação do Bcrypt:

1 $ npm install bcrypt@2.0.1

Após a instalação do Bcrypt vamos atualizar o Model de user adicionando o seguinte:

 1 import mongoose from 'mongoose';
 2 +import Util from 'util';
 3 +import bcrypt from 'bcrypt';
 4 
 5 +const hashAsync = Util.promisify(bcrypt.hash);
 6 const schema = new mongoose.Schema({
 7   name: String,
 8   email: String,
 9   password: String,
10   role: String
11 });
12 
13 +schema.pre('save', function(next) {
14 +  if(!this.password || !this.isModified('password')) {
15 +   return next();
16 + };
17 +  hashAsync(this.password, 10)
18 +    .then(hashedPassword => {
19 +      this.password = hashedPassword;
20 +      next();
21 +    })
22 +    .catch(err => next(err));
23 +});

Muita coisa acontece nesse bloco de código, vamos começar pelo Bcrypt. O módulo nativo do Bcrypt não suporta promises, ou seja, teríamos que usar callbacks, mas como em todo nosso código utilizamos promises vamos seguir o padrão. Atualmente é simples transformar uma função que utiliza callback para se comportar como uma Promise, basta utilizar o módulo nativo util do Node.js e chamar o método promisify passando a referência da função que utiliza callback, como fizemos com bcrypt.hash, o retorno será uma função que utiliza Promise.

Middlewares no Mongoose

Para garantir que sempre que um usuário for salvo a senha dele será encriptada vamos utilizar uma funcionalidade do Mongoose chamada middlewares (também conhecido como pre e post hooks). No trecho de código anterior utilizamos o pre save, ou seja, esse código será automaticamente executado sempre antes da função save do Model. Começamos verificando se o campo password foi realmente alterado:

1   if(!this.password || !this.isModified('password')) {
2     return next();
3   };

Se o campo password não foi alterado não podemos gerar um hash novo, se não estaríamos gerando um hash de um hash e o usuário não conseguiria mais utilizar a senha. Caso o campo password tenha sido alterado, o trecho de código acima vai gerar um hash para a nova senha do usuário e substituir a antiga no Model, na sequência o Model vai salvar o hash ao invés da senha em texto plano que o usuário enviou. A função hashAsync é a função do Bcrypt que transformamos em Promise, ela será responsável por criar um hash a partir da senha que o usuário enviou.

Além da senha em texto, também passamos número 10 para o Brcrypt, esse número se refere ao factor; o factor é utilizado para dizer ao Bcrypt o número de complexidade que desejamos para gerar o hash, quanto maior for o número mais tempo ele vai levar para gerar o hash e mais difícil será para desencriptar. Na sequência substituimos o password que o usuário enviou pelo hash:

1       this.password = hashedPassword;

O this nesse contexto se refere ao Model do Mongoose, como estamos utilizando um middleware no momento que chamamos o next ele vai chamar a próxima ação da cadeia de middlewares, que provavelmente será a ação de save, caso não exista outro middleware, então o usuário será salvo no banco com o password como hash.

Agora que temos toda a lógica necessária para criar senhas com segurança vamos voltar ao método update do UsersController para entendê-lo melhor:

 1   update(req, res) {
 2     const body = req.body;
 3     return this.User.findById(req.params.id)
 4       .then(user => {
 5         user.name = body.name
 6         user.email = body.email
 7         user.role = body.role
 8         if(body.password) {
 9           user.password = body.password
10         }
11         return user.save();
12       })
13       .then(() => res.sendStatus(200))
14       .catch(err => res.status(422).send(err.message));
15   }

Não vamos alterar nada no código, apenas destaquei o método update novamente para explicar o seu comportamento. Provavelmente alguns de vocês leitores já trabalharam com MongoDB e sabem que ele possui um método de update e nesse caso estamos buscando o usuário do banco, atualizando os dados e chamando o método save. Isso acontece por que o update nativo do MongoDB é baixo nível, ou seja, ele não pertence ao Mongoose, nós não podemos utilizar o middleware schema.pre(‘update’) por exemplo para ter acesso aos dados anteriores e aos novos que estão sendo salvos.

Esse comportamento tem uma razão, o MongoDB como explicado anteriormente, é um banco de dados NoSQL, ou seja, ele não garante integridade dos dados. Vamos analisar um exemplo do método update do Mongoose:

1   update(req, res) {
2     return this.Product.findOneAndUpdate({ _id: req.params.id}, req.body)
3       .then(() => res.sendStatus(200))
4       .catch(err => res.status(422).send(err.message));
5   }

Esse é o ProductsController, como não utilizamos nenhum middleware do Mongoose nesse update podemos utilizar o método update nativo do MongoDB. O que acontece internamente no findOneAndUpdate é o seguinte:

1   db.products.update(
2   { _id: "example-id" },
3     {
4       name: "Updated Name",
5     }
6 )

O Mongoose não busca o produto para atualizar e salvar novamente, ele apenas traduz o findOneAndUpdate em uma query nativa de update do MongoDB, sendo assim mesmo que seja adicionado um middleware no pre update não teremos acesso aos dados anteriores para comparar se a senha do usuário mudou ou não e não saberemos se é necessário gerar outro hash.

Antes de pensar que esse comportamento do Mongoose é “burro” vamos lembrar das premissas do MongoDB enquanto NoSQL é não prover Atomicidade e Consistência, isso significa que se o Moongose buscar o produto para a memória, atualizar os campos que mudaram e salvar novamente ele teria que lidar com concorrência, pois, imagine que nesse meio tempo em que o produto está em memória sendo atualizado outro processo também tentar atualizar o mesmo produto, isso causaria inconsistência. Por isso o Mongoose delega essa responsabilidade para o MongoDB que nativamente trata os updates de forma sequencial.

O código desse capitulo está disponvivel aqui.