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 return a list of products', async() => {
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 +
5 +      await productsController.get(request, response);
6 +
7 +      sinon.assert.calledWith(response.send, defaultProduct)
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('Controllers: Products', () => {
 6   const defaultProduct = [
 7     {
 8       name: 'Default product',
 9       description: 'product description',
10       price: 100
11     }
12   ];
13 
14   describe('get() products', () => {
15     it('should return a list of products', async () => {
16       const request = {};
17       const response = {
18         send: sinon.spy()
19       };
20 
21       Product.find = sinon.stub();
22       Product.find.withArgs({}).resolves(defaultProduct);
23 
24       const productsController = new ProductsController(Product);
25 
26       await productsController.get(request, response);
27 
28       sinon.assert.calledWith(response.send, defaultProduct);
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 +     const products = await this.Product.find({});
 8 +     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 return 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', async () => {
 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       await productsController.get(request, response);
15 
16       sinon.assert.calledWith(response.send, 'Error');
17     });

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   async get(req, res) {
2     try {
3       const products = await this.Product.find({});
4       res.send(products);
5     } catch (err) {
6       res.status(400).send(err.message);
7     }
8   }

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   Controllers: Products
2     get() products
3       ✓ should return a list of products
4       ✓ should return 400 when an error occurs
5 
6 
7   2 passing (13ms)

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