Operações de CRUD
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 with o\
11 ne 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 one\
2 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:
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 return a list of products', async () => {
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 - await productsController.get(request, response);
2 + await productsController.get(defaultRequest, response);
Pronto, o teste deve estar assim:
1 const defaultRequest = {
2 params: {}
3 };
4
5 describe('get() products', () => {
6 it('should return a list of products', async () => {
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 await productsController.get(defaultRequest, response);
17
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 return 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 return one product', async () => {
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 await productsController.getById(request, response);
18
19 sinon.assert.calledWith(response.send, defaultProduct);
20 });
21 });
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 MongoDB 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 await 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 return 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 , 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 async getById(req, res) {
2 const response = await Promise.resolve([
3 {
4 __v: 0,
5 _id: '56cb91bdc3464f14678934ca',
6 name: 'Default product',
7 description: 'product description',
8 price: 100
9 }
10 ]);
11
12 res.send(response);
13 }
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 return 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 async getById(req, res) {
2 const {
3 params: { id }
4 } = req;
5
6 try {
7 const product = await this.Product.find({ _id: id });
8 res.send(product);
9 } catch (err) {
10 res.status(400).send(err.message);
11 }
12 }
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 return 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 }, defaultProduct\
7 );
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 prod\
15 uct 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 save a new product successfully', async () => {
3 const requestWithBody = Object.assign(
4 {},
5 { body: defaultProduct[0] },
6 defaultRequest
7 );
8 const response = {
9 send: sinon.spy(),
10 status: sinon.stub()
11 };
12 class fakeProduct {
13 save() {}
14 }
15
16 response.status.withArgs(201).returns(response);
17 sinon
18 .stub(fakeProduct.prototype, 'save')
19 .withArgs()
20 .resolves();
21
22 const productsController = new ProductsController(fakeProduct);
23
24 await productsController.create(requestWithBody, response);
25 sinon.assert.calledWith(response.send);
26 });
27 });
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 return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 1) should save a new product successfully
9
10
11 3 passing (22ms)
12 1 failing
13
14 1) Controller: Products create() product should save a new product successfully:
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 async create(req, res) {
2
3 return await Promise.resolve(res.send(req.body));
4 }
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 return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 async create(req, res) {
2 const product = new this.Product(req.body);
3
4 await product.save();
5 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', async () => {
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
14 .stub(fakeProduct.prototype, 'save')
15 .withArgs()
16 .rejects({ message: 'Error' });
17
18 const productsController = new ProductsController(fakeProduct);
19
20 await productsController.create(defaultRequest, response);
21 sinon.assert.calledWith(response.status, 422);
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' });
Executando os testes de unidade:
1 $ npm run test:unit
A saida será:
1 Controller: Products
2 get() products
3 ✓ should return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 422:
17 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 async create(req, res) {
2 const product = new this.Product(req.body);
3 + try {
4 await product.save();
5 res.status(201).send(product);
6 + } catch (err) {
7 + res.status(422).send(err.message);
8 + }
9 }
Executando os testes novamente, a saída deve ser:
1 Controller: Products
2 get() products
3 ✓ should return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 pro\
18 duct 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 pro\
2 duct 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 has been updated', async () => {
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 updateOne() {}
22 }
23
24 const updateOneStub = sinon.stub(fakeProduct, 'updateOne');
25 updateOneStub
26 .withArgs({ _id: fakeId }, updatedProduct)
27 .resolves(updatedProduct);
28
29 const productsController = new ProductsController(fakeProduct);
30
31 await productsController.update(request, response);
32 sinon.assert.calledWith(response.sendStatus, 200);
33 });
34 });
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 updateOne, 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 updateOne() {}
3 }
Para adicionar o comportamento esperado no método updateOne vamos transformá-lo em um stub:
1 const updateOneStub = sinon.stub(fakeProduct, 'updateOne');
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 updateOneStub.withArgs({ _id: fakeId }, updatedProduct).resolves(updatedProduct);
Segundo a documentação do Mongoose, o método updateOne 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 return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 product \
19 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 async update(req, res) {
2 res.sendStatus(200);
3 return await 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 return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 async update(req, res) {
2 await this.Product.updateOne({ _id: req.params.id}, req.body);
3 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 return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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', async() => {
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 updateOne() {}
23 }
24
25 const updateOneStub = sinon.stub(
26 fakeProduct,
27 'updateOne'
28 );
29 updateOneStub
30 .withArgs({ _id: fakeId }, updatedProduct)
31 .rejects({ message: 'Error' });
32 response.status.withArgs(422).returns(response);
33
34 const productsController = new ProductsController(fakeProduct);
35
36 await productsController.update(request, response);
37 sinon.assert.calledWith(response.send, 'Error');
38
39 });
40 });
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 updateOneStub.withArgs({ _id: fakeId }, updatedProduct).rejects({ message: 'Error' }\
2 );
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 return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 422:
21 Error
Como já esperávamos, o teste está falhando. Vamos atualizar método update do productsController adicionando o método método catch:
1 async update(req, res) {
2 + try {
3 await this.Product.updateOne({ _id: req.params.id }, req.body);
4 res.sendStatus(200);
5 + } catch (err) {
6 + res.status(422).send(err.message);
7 + }
8 }
Executando os testes novamente, a saída deve ser:
1 Controller: Products
2 get() products
3 ✓ should return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 a p\
21 roduct 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 a pr\
2 oduct 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', async() => {
2 it('should respond with 204 when the product has been 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 deleteOne() {}
15 }
16
17 const deleteOneStub = sinon.stub(fakeProduct, 'deleteOne');
18
19 deleteOneStub.withArgs({ _id: fakeId }).resolves();
20
21 const productsController = new ProductsController(fakeProduct);
22
23 await productsController.remove(request, response);
24 sinon.assert.calledWith(response.sendStatus, 204);
25 });
26 });
O método utilizado para remover um produto é o deleteOne, segundo a documentação do Mongoose ele recebe por parametro as condições para deletar o item, no nosso caso o id.
Para simular esse cenário vamos criar o seguinte stub no código acima:
1 deleteOneStub.withArgs({ _id: fakeId }).resolves([1]);
Aqui informamos que quando o método deleteOne 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 return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 product \
23 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 async remove(req, res) {
2 res.sendStatus(204);
3 return await Promise.resolve();
4 }
Executando os testes de unidade, devemos ter a seguinte saída:
1 Controller: Products
2 get() products
3 ✓ should return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 async remove(req, res) {
2 await this.Product.deleteOne({ _id: req.params.id });
3 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', async() => {
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 deleteOne() {}
16 }
17
18 const deleteOneStub = sinon.stub(fakeProduct, 'deleteOne');
19
20 deleteOneStub.withArgs({ _id: fakeId }).rejects({ message: 'Error' });
21 response.status.withArgs(400).returns(response);
22
23 const productsController = new ProductsController(fakeProduct);
24
25 await productsController.remove(request, response);
26 sinon.assert.calledWith(response.send, 'Error');
27 });
28 });
Executando os testes de unidade devemos ter a seguinte saída:
1 Controller: Products
2 get() products
3 ✓ should return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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 400:
25 Error
A implementação ficará da seguinte maneira:
1 async remove(req, res) {
2 try {
3 await this.Product.deleteOne({ _id: req.params.id });
4 res.sendStatus(204);
5 } catch (err) {
6 res.status(422).send(err.message);
7 }
8 }
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 return a list of products
4 ✓ should return 400 when an error occurs
5 getById()
6 ✓ should return one product
7 create() product
8 ✓ should save a new product successfully
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.