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.