b
Testando o back-end
Começaremos agora a escrever testes para o backend. Já que o backend não contém nenhuma lógica complicada, não faz sentido escrever testes de unidade (unit tests) para ele. A única coisa que provavelmente poderíamos testar com unit test seria o método toJSON que é utilizado para formatar as notas.
Em algumas situações, pode ser vantajoso implementar alguns dos testes de backend fazendo um mock do banco de dados ao invés de utilizar um banco de dados real. Uma biblioteca que poderia ser utilizada para isso é a mongodb-memory-server.
Já que o backend de nossa aplicação ainda é relativamente simples, nós iremos testar a aplicação inteira por meio de sua API REST, o que inclui o banco de dados. Esse tipo de teste no qual múltiplos componentes do sistema estão sendo testados em grupo é chamado de teste de integração.
Ambiente de teste
Em um dos capítulos anteriores do curso, nós mencionamos que quando o seu backend está rodando no Fly.io ou no Render, ele está em modo de produção.
A convenção no Node é definir o modo de execução da aplicação com a variável de ambiente NODE_ENV. Em nossa aplicação atual, nós apenas carregamos as variáveis de ambiente que estão definidas no arquivo .env se a aplicação não está no modo de produção.
Uma prática comum é definir modos separados para desenvolvimento e testes.
A seguir, vamos alterar os scripts no nosso package.json de forma que ao executar os testes, a variável de ambiente NODE_ENV receberá o valor test:
{
// ...
"scripts": {
"start": "NODE_ENV=production node index.js", "dev": "NODE_ENV=development nodemon index.js", "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend",
"deploy": "fly deploy",
"deploy:full": "npm run build:ui && npm run deploy",
"logs:prod": "fly logs",
"lint": "eslint .",
"test": "NODE_ENV=test jest --verbose --runInBand" },
// ...
}
Nós também acrescentamos a opção runInBand no script npm que executa os testes. Essa opção previne que o Jest execute testes em paralelo; nós iremos discutir a importância disso quando nossos testes começarem a utilizar o banco de dados.
Especificamos que o modo da aplicação será desenvolvimento (development) no script npm run dev que utiliza o nodemon. Nós também especificamos que o comando padrão npm start irá definir o modo como sendo produção (production).
Ainda há um pequeno problema na forma como especificamos o modo da aplicação em nossos scripts: não funcionará no Windows. Podemos corrigir isso instalando o pacote cross-env como uma dependência de desenvolvimento, por meio do comando:
npm install --save-dev cross-env
Feito, conseguiremos obter compatibilidade entre plataformas (cross-platform), utilizando a biblioteca cross-env em nossos scripts npm definidos no package.json:
{
// ...
"scripts": {
"start": "cross-env NODE_ENV=production node index.js",
"dev": "cross-env NODE_ENV=development nodemon index.js",
// ...
"test": "cross-env NODE_ENV=test jest --verbose --runInBand",
},
// ...
}
Obs.:: Se você fez o deploy de sua aplicação no Fly.io/Render, tenha em mente que se cross-env estiver salvo como dependência de desenvolvimento, isso poderá causar um erro de aplicação no seu servidor web. Para corrigir isso, altere cross-env para dependência de produção, executando o seguinte comando:
npm install cross-env
Agora nós podemos modificar a forma como nossa aplicação roda em diferentes modos. Como um exemplo, nós configuraremos a aplicação para usar um banco de dados separado quando estiver executando testes.
Podemos criar um banco de dados separado para testes no MongoDB Atlas. Essa não é a melhor solução quando muitas pessoas estão desenvolvimento a mesma aplicação. Execução de testes normalmente requer uma única instância de banco de dados que não é usada por testes em execução simultânea.
Seria melhor rodar nossos testes usando um banco de dados que estivesse instalado e sendo executado localmente na máquina do desenvolvedor. A solução ideal seria que cada execução de teste usasse um banco de dados separado. Isso é "relativamente simples" de alcançar executando Mongo in-memory ou containers do Docker. Não iremos complicar as coisas, mas ao invés continuaremos a usar o banco de dados do MongoDB Atlas.
Vamos fazer algumas mudanças no módulo que define a configuração da aplicação:
require('dotenv').config()
const PORT = process.env.PORT
const MONGODB_URI = process.env.NODE_ENV === 'test' ? process.env.TEST_MONGODB_URI : process.env.MONGODB_URI
module.exports = {
MONGODB_URI,
PORT
}
O arquivo .env tem variáveis separadas para os endereços do banco de dados dos ambientes de desenvolvimento e testes:
MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority
PORT=3001
TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/testNoteApp?retryWrites=true&w=majority
O módulo config que implementamos se assemelha ligeiramente ao pacote node-config. Escrever nossa própria implementação é justificável, uma vez que nossa aplicação é simples e também porque isso nos ensina lições valiosas.
Essas são as únicas mudanças que precisamos fazer no código de nossa aplicação.
Você pode encontrar o código para nossa aplicação atual na íntegra no branch part4-2 deste repositório do GitHub.
supertest
Vamos utilizar o pacote supertest para nos ajudar a escrever os testes para nossa API.
Vamos instalar o pacote como uma dependência de desenvolvimento:
npm install --save-dev supertest
Vamos escrever nosso primeiro teste no arquivo tests/note_api.test.js:
const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
test('notes are returned as json', async () => {
await api
.get('/api/notes')
.expect(200)
.expect('Content-Type', /application\/json/)
})
afterAll(async () => {
await mongoose.connection.close()
})
O teste importa a aplicação Express do módulo app.js e o envolve com a função supertest em um objeto chamado superagent. Esse objeto é atribuído à variável api, usada nos testes para fazer requisições HTTP para o backend.
Nosso teste faz uma requisição HTTP GET na url api/notes e verifica se a requisição é respondida com o código de status 200. O teste também verifica se o cabeçalho Content-Type está configurado como application/json, o que indica que os dados estão no formato desejado.
A checagem do valor do cabeçalho possui uma sintaxe estranha:
.expect('Content-Type', /application\/json/)
O valor desejado foi definido por meio de uma expressão regular, ou simplesmente regex. Uma regex inicia e termina com uma barra /; já que a string que desejamos também possui em seu conteúdo a mesma barra (application/json) precisamos inserir antes dela uma contra-barra \ para que ela não seja entendida como um caractere de encerramento de nossa regex.
Em princípio, poderíamos definir o parâmetro para o teste como uma string:
.expect('Content-Type', 'application/json')
O problema aqui, no entanto, é que ao utilizar uma string, o valor do cabeçalho deve ser exatamente o mesmo. Para a regex que criamos, é aceitável que o cabeçalho contenha a string em questão. O valor atual do cabeçalho é application/json; charset=utf-8, pois ele também contém informação sobre a codificação dos caracteres. No entanto, nosso teste não está interessado nisso, motivo pelo qual é melhor usar neste caso uma regex ao invés de uma string.
O teste contém alguns detalhes que vamos explorar um pouco mais tarde. A arrow function que define o teste é precedida pela palavra-chave async e a chamada dos métodos no objeto api está precedida da palavra-chave await. Nós iremos escrever alguns testes e depois daremos uma olhada nessa mágica async/await. Não se preocupe com isso agora, apenas assegure que os testes do exemplo funcionem corretamente. A sintaxe async/await está relacionada ao fato de fazer requisições para a API em uma operação assíncrona. A sintaxe async/await pode ser utilizada para escrever código assíncrono com aparência de código síncrono.
Assim que todos os testes (atualmente existe apenas um) terminarem a execução, temos que encerrar a conexão usada pelo Mongoose. Isso pode ser facilmente feito com o método afterAll:
afterAll(async () => {
await mongoose.connection.close()
})
Ao executar os testes você pode se deparar com o seguinte alerta no console:
O problema é causado pela versão 6.x do Mongoose; esse problema não acontece quando a versão 5.x é utilizada A documentação do Mongoose não recomenda testar aplicações Mongoose com Jest.
Uma forma de contornar esse problema é adicionar ao diretório tests um arquivo teardown.js contendo o seguinte:
module.exports = () => {
process.exit(0)
}
e acrescentar nas definições do Jest no package.json o seguinte:
{
//...
"jest": {
"testEnvironment": "node",
"globalTeardown": "./tests/teardown.js" }
}
Outro erro que pode aparecer para você nos seus testes é se a execução deles demorar mais do que 5 segundos (5000ms), que é o tempo padrão do Jest para timeout. Isso pode ser resolvido adicionando um terceiro parâmetro na função test:
test('notes are returned as json', async () => {
await api
.get('/api/notes')
.expect(200)
.expect('Content-Type', /application\/json/)
}, 100000)
Esse terceiro parâmetro configura o timeout para 100 segundos (100000ms). Um tempo longo garantirá que seu teste não falhe em razão do tempo que ele leva para executar. (Um timeout muito longo talvez não seja o que você queira para testes baseados em performance ou velocidade, mas para este nosso exemplo é ok).
Um detalhe importante é o seguinte: no começo dessa parte, nós extraímos a aplicação Express no arquivo app.js, e o papel do arquivo index.js foi alterado para iniciar a aplicação na porta especificada com o objeto http integrado do Node:
const app = require('./app') // the actual Express app
const config = require('./utils/config')
const logger = require('./utils/logger')
app.listen(config.PORT, () => {
logger.info(`Server running on port ${config.PORT}`)
})
Os testes somente utilizam a aplicação express definida no arquivo app.js:
const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
// ...
A documentação do supertest informa o seguinte:
se o servidor ainda não estiver ouvindo as conexões, então ele estará ligado em uma porta efêmera, logo não há necessidade de acompanhar as portas.
Em outras palavras, o supertest se responsabiliza de iniciar a aplicação que está sendo testada na porta que está em uso internamente.
Vamos adicionar duas notas no banco de dados de teste, usando o mongo.js (lembre-se de mudar para a url correta do banco de dados).
Vamos escrever alguns testes a mais:
test('there are two notes', async () => {
const response = await api.get('/api/notes')
expect(response.body).toHaveLength(2)
})
test('the first note is about HTTP methods', async () => {
const response = await api.get('/api/notes')
expect(response.body[0].content).toBe('HTML is easy')
})
Ambos os testes armazenam a resposta da requisição na variáveis response, e diferente da versão anterior do teste que utilizava o método provido pelo supertest para verificar o código de status e o cabeçalho, desta vez nós estamos inspecionando os dados de resposta armazenados na propriedade response.body. Nossos testes verificam o formato e o conteúdo dos dados da resposta com o método do Jest expect.
O benefício de usar a sintaxe async/await começa a ficar evidente. Normalmente, teríamos que usar funções de retorno de chamada para acessar os dados retornados pelas promessas, mas, com a nova sintaxe, as coisas estão muito mais simples:
const response = await api.get('/api/notes')
// a execução chega aqui somente após a requisição HTTP estar completa
// o resultado da requisição HTTP é salva na variável response
expect(response.body).toHaveLength(2)
O middleware que exibe informações sobre as solicitações HTTP está obstruindo a saída da execução do teste. Vamos modificar o logger para que ele não imprima no console no modo de teste:
const info = (...params) => {
if (process.env.NODE_ENV !== 'test') { console.log(...params) }}
const error = (...params) => {
if (process.env.NODE_ENV !== 'test') { console.error(...params) }}
module.exports = {
info, error
}
Inicializando o banco de dados antes dos testes
Testar parece ser fácil e atualmente nossos testes estão passando. No entanto, nossos testes são ruins, uma vez que dependem do estado do banco de dados, que agora tem duas notas. Para tornar nossos testes mais robustos, devemos redefinir o banco de dados e gerar os dados de teste necessários de maneira controlada antes de executarmos os testes.
Nosso código já está utilizando a função do Jest afterAll para encerrar a conexão com o banco de dados após a conclusão da execução dos testes. O Jest oferece muitas outras funções que podem ser utilizadas para executar operações uma vez antes de executar qualquer teste ou antes da execução de cada teste.
Vamos inicializar o banco de dados antes de cada teste com a função beforeEach:
const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
const Note = require('../models/note')
const initialNotes = [ { content: 'HTML is easy', important: false, }, { content: 'Browser can execute only JavaScript', important: true, },]
beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(initialNotes[0]) await noteObject.save() noteObject = new Note(initialNotes[1]) await noteObject.save()})// ...
O banco de dados é apagado logo no início, após isso salvamos no banco as duas notas armazenadas no array initialNotes. Fazendo isso, garantimos que o banco de dados esteja no mesmo estado antes da execução de cada teste.
Vamos fazer também algumas alterações nos dois últimos testes:
test('all notes are returned', async () => {
const response = await api.get('/api/notes')
expect(response.body).toHaveLength(initialNotes.length)})
test('a specific note is within the returned notes', async () => {
const response = await api.get('/api/notes')
const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only JavaScript' )})
Dê uma atenção especial no expect do último teste. O comando response.body.map(r => r.content)
é utilizado para criar um array contendo o que está no content de cada nota retornada pela API. O método toContain é utilizado para checar se a nota passada como parâmetro está na lista de notas retornada pela API.
Executando os testes um por um
O comando npm test executa todos os testes da aplicação. Quando estamos escrevendo testes, normalmente é sábio executar um ou dois testes. O Jest oferece algumas formas diferentes de fazer isso, uma delas é o método only. Se os testes estiverem escritos em vários arquivos, esse método não é muito bom.
Uma opção melhor é especificar os testes que precisam ser executados como parâmetros do comando npm test.
O comando a seguir somente executa os testes encontrados no arquivo tests/note_api.test.js:
npm test -- tests/note_api.test.js
A opção -t pode ser utilizada para executar testes com um nome específico:
npm test -- -t "a specific note is within the returned notes"
O parâmetros informado pode referenciar o nome do teste ou o bloco describe. O parâmetro também pode conter somente parte do nome. O comando a seguir vai executar todos os testes que contenha notes em seu nome
npm test -- -t 'notes'
Obs.:: Ao executar um único teste, a conexão mongoose pode permanecer aberta se nenhum teste usando a conexão for executado. O problema pode ser porque o supertest prepara a conexão, mas o Jest não executa a parte afterAll do código.
async/await
Antes de escrevermos mais testes, vamos dar uma olhada nas palavras-chave async e await.
A sintaxe async/await que foi introduzida no ES7, torna possível o uso de funções assíncronas que retornam uma promessa de um jeito que parece com código síncrono.
Como um exemplo, a busca das notas do banco de dados utilizando promessas é assim:
Note.find({}).then(notes => {
console.log('operation returned the following notes', notes)
})
O método Note.find() retorna uma promessa e podemos acessar o resultado da operação registrando a função callback com o método then().
Todo o código que queremos executar quando a operação termina está escrito na função callback. Se quisermos fazer diversas chamadas de funções assíncronas em sequência, a situação logo ficará complicada. As chamadas assíncronas seriam feitas na função callback. Isso resultaria em um código complicado e provavelmente surgiria o chamado callback hell.
Encadeando promessas, nós conseguiríamos manter a situação sob controle e evitar o callback hell ao criar uma cadeia bem limpa de chamadas de métodos then(). Vimos alguns desses durante o curso. Para ilustrar, você pode ver um exemplo de uma função que busca todas as notas e, em seguida, exclui a primeira delas:
Note.find({})
.then(notes => {
return notes[0].remove()
})
.then(response => {
console.log('the first note is removed')
// mais código aqui
})
A cadeia de métodos then() é ok, mas podemos fazer melhor do que isso. As generator functions introduzidas no ES6 trazem um jeito inteligente de escrever código assíncrono de uma forma que "parece síncrono". A sintaxe é um pouco estranha e não é muito utilizada.
As palavras-chave async e await introduzidas no ES7 trazem a mesma funcionalidade dos generators, mas de uma maneira compreensível e sintaticamente mais limpa para todos os cidadãos do mundo JavaScript.
Poderíamos buscar todas as notas no banco de dados utilizando o operador await dessa forma:
const notes = await Note.find({})
console.log('operation returned the following notes', notes)
O código parece idêntico a um código síncrono. A execução do código pausa em const notes = await Note.find({}) e aguarda até que a promessa seja completada (fulfilled), então continua a sua execução na próxima linha. Quando a execução continua, o resultado da operação que retornou a promessa é atribuído à variável notes.
O exemplo um pouco complicado apresentado acima poderia ser implementado usando await assim:
const notes = await Note.find({})
const response = await notes[0].remove()
console.log('the first note is removed')
Graças à nova sintaxe, o código é muito mais simples do que a cadeia de then() anterior.
Existem alguns detalhes importantes para prestar atenção ao usar a sintaxe async/await. Para usar o operador await com operações assíncronas, elas precisam retornar uma promessa. Isso não é um problema em si, já que as funções assíncronas regulares que usam callbacks são fáceis de envolver em promessas.
A palavra-chave await não pode ser utilizada em qualquer lugar do código JavaScript. Somente é possível usar o await dentro de uma função assíncrona (async).
Isso significa que para os exemplos anteriores funcionarem, é preciso que estejam em funções assíncronas. Note a primeira linha da definição da arrow function:
const main = async () => { const notes = await Note.find({})
console.log('operation returned the following notes', notes)
const response = await notes[0].remove()
console.log('the first note is removed')
}
main()
O código declara que a função atribuída a main é assíncrona. Após isso, o código chama a função com main()
.
async/await in the backend
Vamos começar a alterar o backend para usar async/await. Como todas as operações assíncronas atualmente são feitas dentro de uma função, basta mudar as funções que gerenciam a rota.
O código da rota para buscar todas as notas deve se alterado como segue:
notesRouter.get('/', async (request, response) => {
const notes = await Note.find({})
response.json(notes)
})
Podemos verificar que nosso refatoramento foi bem sucedido testando o endpoint pelo navegador e executando os testes que escrevemos mais cedo.
Você pode encontrar o código para da aplicação atual na branch da part4-3 nesse repositório GitHub.
Mais testes e refatoração do backend
Quando o código é refatorado, sempre existe o risco deregressão, o que significa que funcionalidades existentes podem quebrar. Vamos refatorar as operações primeiramente restantes escrevendo um teste para cada rota da API.
Vamos começar com a operação que adiciona uma nova nota. Vamos escrever um teste que adiciona uma nova nota e verifica que se o número de notas retornadas pela API aumenta e se a nova nota adicionada está na lista.
test('a valid note can be added', async () => {
const newNote = {
content: 'async/await simplifies making async calls',
important: true,
}
await api
.post('/api/notes')
.send(newNote)
.expect(201)
.expect('Content-Type', /application\/json/)
const response = await api.get('/api/notes')
const contents = response.body.map(r => r.content)
expect(response.body).toHaveLength(initialNotes.length + 1)
expect(contents).toContain(
'async/await simplifies making async calls'
)
})
O teste falha já que nós estamos acidentalmente retornando o códio de status 200 OK quando uma nova nota é criada. Vamos mudar para 201 CRIADO(CREATED):
notesRouter.post('/', (request, response, next) => {
const body = request.body
const note = new Note({
content: body.content,
important: body.important || false,
})
note.save()
.then(savedNote => {
response.status(201).json(savedNote) })
.catch(error => next(error))
})
Vamos também escrever um teste que verifica que uma nota sem conteúdo não será salva no banco de dados.
test('note without content is not added', async () => {
const newNote = {
important: true
}
await api
.post('/api/notes')
.send(newNote)
.expect(400)
const response = await api.get('/api/notes')
expect(response.body).toHaveLength(initialNotes.length)
})
Ambos os testes checam o estado armazenado no banco de dados após a operação salvar, buscando todas as notas da aplicação.
const response = await api.get('/api/notes')
Os mesmos passos de verificação serão repetidos posteriormente em outros testes, então é uma boa ideia extrair esses passos em uma função auxiliadora. Vamos adicionar a função em um novo arquivo chamado tests/test_helper.js que está no mesmo diretório do arquivo de teste.
const Note = require('../models/note')
const initialNotes = [
{
content: 'HTML is easy',
important: false
},
{
content: 'Browser can execute only JavaScript',
important: true
}
]
const nonExistingId = async () => {
const note = new Note({ content: 'willremovethissoon' })
await note.save()
await note.deleteOne()
return note._id.toString()
}
const notesInDb = async () => {
const notes = await Note.find({})
return notes.map(note => note.toJSON())
}
module.exports = {
initialNotes, nonExistingId, notesInDb
}
O módulo define a função notesInDb que pode ser utilizada para checar as notas armazenadas no banco de dados. O array inicialNotes contendo o estado inicial do banco de dados também está no módulo. Além disso, definimos a futura função noExistingId. que pode ser utilizada para criar um objeto ID de banco de dados que não pertence a nenhum objeto nota no banco de dados.
Nossos testes agora podem usar o módulo auxiliador (helper):
const supertest = require('supertest')
const mongoose = require('mongoose')
const helper = require('./test_helper')const app = require('../app')
const api = supertest(app)
const Note = require('../models/note')
beforeEach(async () => {
await Note.deleteMany({})
let noteObject = new Note(helper.initialNotes[0]) await noteObject.save()
noteObject = new Note(helper.initialNotes[1]) await noteObject.save()
})
test('notes are returned as json', async () => {
await api
.get('/api/notes')
.expect(200)
.expect('Content-Type', /application\/json/)
})
test('all notes are returned', async () => {
const response = await api.get('/api/notes')
expect(response.body).toHaveLength(helper.initialNotes.length)})
test('a specific note is within the returned notes', async () => {
const response = await api.get('/api/notes')
const contents = response.body.map(r => r.content)
expect(contents).toContain(
'Browser can execute only JavaScript'
)
})
test('a valid note can be added ', async () => {
const newNote = {
content: 'async/await simplifies making async calls',
important: true,
}
await api
.post('/api/notes')
.send(newNote)
.expect(201)
.expect('Content-Type', /application\/json/)
const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1)
const contents = notesAtEnd.map(n => n.content) expect(contents).toContain(
'async/await simplifies making async calls'
)
})
test('note without content is not added', async () => {
const newNote = {
important: true
}
await api
.post('/api/notes')
.send(newNote)
.expect(400)
const notesAtEnd = await helper.notesInDb()
expect(notesAtEnd).toHaveLength(helper.initialNotes.length)})
afterAll(async () => {
await mongoose.connection.close()
})
O código usando promessas funciona e os testes passam. Estamos prontos para refatorar nosso código para usar a sintaxe async/await
Nós fizemos as seguintes alterações no código que cuida de adicionar uma nova nota (note que a definição do gerenciador de rota está precedida pela palavra-chave async):
notesRouter.post('/', async (request, response, next) => {
const body = request.body
const note = new Note({
content: body.content,
important: body.important || false,
})
const savedNote = await note.save()
response.status(201).json(savedNote)
})
Existe um pequeno problema no código: nós não estamos tratando erros. Como deveríamos lidar com eles?
Tratamento de erro e async/await
Se ocorrer uma exceção durante a requisição POST, enfrentaremos uma situação familiar:
Em outras palavras, acabamos com uma rejeição de promessa que não foi tratada e a solicitação nunca recebe uma resposta.
Com o async/await, o recomendado no tratamento de exceções é o mecanismo familiar try/catch:
notesRouter.post('/', async (request, response, next) => {
const body = request.body
const note = new Note({
content: body.content,
important: body.important || false,
})
try { const savedNote = await note.save() response.status(201).json(savedNote) } catch(exception) { next(exception) }})
O bloco catch simplesmente chama a função next, que passa o tratamento da requisição para o middleware de tratamento de erro.
Depois de fazer essa alteração, todos os nossos testes passarão novamente.
Em seguida, vamos escrever testes para buscar e apagar uma nota individual:
test('a specific note can be viewed', async () => {
const notesAtStart = await helper.notesInDb()
const noteToView = notesAtStart[0]
const resultNote = await api .get(`/api/notes/${noteToView.id}`) .expect(200) .expect('Content-Type', /application\/json/)
expect(resultNote.body).toEqual(noteToView)
})
test('a note can be deleted', async () => {
const notesAtStart = await helper.notesInDb()
const noteToDelete = notesAtStart[0]
await api .delete(`/api/notes/${noteToDelete.id}`) .expect(204)
const notesAtEnd = await helper.notesInDb()
expect(notesAtEnd).toHaveLength(
helper.initialNotes.length - 1
)
const contents = notesAtEnd.map(r => r.content)
expect(contents).not.toContain(noteToDelete.content)
})
Ambos os testes compartilham uma estrutura semelhante. Na fase de inicialização, eles buscam uma nota no banco de dados. Depois disso, os testes chamam a operação que está sendo testada, que é destacada no bloco de código. Por último, os testes verificam se o resultado da operação está de acordo com o esperado.
Os testes passam e podemos seguramente refatorar as rotas testadas para usar async/await:
notesRouter.get('/:id', async (request, response, next) => {
try {
const note = await Note.findById(request.params.id)
if (note) {
response.json(note)
} else {
response.status(404).end()
}
} catch(exception) {
next(exception)
}
})
notesRouter.delete('/:id', async (request, response, next) => {
try {
await Note.findByIdAndDelete(request.params.id)
response.status(204).end()
} catch(exception) {
next(exception)
}
})
Você encontrará o código de nossa aplicação atual na branch part4-4nesse repositório GitHub.
Eliminando o try-catch
Async/await simplifica um pouco o código, mas o 'preço' a se pagar é a estrutura try/catch necessária ao tratamento de exceções. Todos os gerenciadores de rotas seguem a mesma estrutura:
try {
// faça as operações assíncronas aqui
} catch(exception) {
next(exception)
}
Talvez você esteja se perguntando se é possível refatorar o código para eliminar o try/catch dos métodos.
A biblioteca express-async-errors traz uma solução para isso.
Vamos instalar a biblioteca:
npm install express-async-errors
Utilizar essa biblioteca é muito fácil. Você deve usar a biblioteca no arquivo app.js:
const config = require('./utils/config')
const express = require('express')
require('express-async-errors')const app = express()
const cors = require('cors')
const notesRouter = require('./controllers/notes')
const middleware = require('./utils/middleware')
const logger = require('./utils/logger')
const mongoose = require('mongoose')
// ...
module.exports = app
A 'mágica' aqui é eliminar completamente os blocos try-catch. Por exemplo, a rota para deletar notas:
notesRouter.delete('/:id', async (request, response, next) => {
try {
await Note.findByIdAndDelete(request.params.id)
response.status(204).end()
} catch (exception) {
next(exception)
}
})
fica assim:
notesRouter.delete('/:id', async (request, response) => {
await Note.findByIdAndDelete(request.params.id)
response.status(204).end()
})
Por causa da biblioteca, não precisamos mais chamar next(exception). A biblioteca cuida de gerenciar tudo por baixo dos panos. Se uma exceção ocorre em uma rota async, ela é automaticamente passada para o middleware de tratamento de exceção.
As outras rotas ficam assim:
notesRouter.post('/', async (request, response) => {
const body = request.body
const note = new Note({
content: body.content,
important: body.important || false,
})
const savedNote = await note.save()
response.status(201).json(savedNote)
})
notesRouter.get('/:id', async (request, response) => {
const note = await Note.findById(request.params.id)
if (note) {
response.json(note)
} else {
response.status(404).end()
}
})
Otimizando a função beforeEach
Vamos retornar a escrita de nossos testes a dar uma olhada mais de perto na função beforeEach que configura os testes:
beforeEach(async () => {
await Note.deleteMany({})
let noteObject = new Note(helper.initialNotes[0])
await noteObject.save()
noteObject = new Note(helper.initialNotes[1])
await noteObject.save()
})
A função armazena no banco de dados as primeiras duas notas do array helper.initialNotes em duas operações separadas. A solução está correta, mas há uma forma melhor de salvar múltiplos objetos no banco de dados:
beforeEach(async () => {
await Note.deleteMany({})
console.log('cleared')
helper.initialNotes.forEach(async (note) => {
let noteObject = new Note(note)
await noteObject.save()
console.log('saved')
})
console.log('done')
})
test('notes are returned as json', async () => {
console.log('entered test')
// ...
}
Nós salvamos no banco dedados as notas armazenadas no array por meio de um loop forEach. No entanto, os testes não parecem funcionar, então precisamos adicionar alguns logs no console para nos ajudar a encontrar o problema.
O console mostra a seguinte saída:
cleared done entered test saved saved
Apesar de usarmos a sintaxe async/await, nossa solução não funciona como esperávamos. A execução do teste começa antes que o banco de dados seja inicializado!
O problema é que cada iteração do loop forEach gera uma operação assíncrona e o beforeEach não aguardará até que elas terminem de ser executadas. Em outras palavras, os comandos await definidos dentro do loop forEach não estão na função beforeEach, mas em funções separadas cujas execuções beforeEach não aguardará chegar ao fim.
Como a execução dos testes começa imediatamente após o beforeEach ter sido concluído, a execução dos testes começa antes que o estado do banco de dados seja inicializado.
Uma maneira de corrigir isso é aguardar que todas as operações assíncronas terminem de ser executadas com o método Promise.all:
beforeEach(async () => {
await Note.deleteMany({})
const noteObjects = helper.initialNotes
.map(note => new Note(note))
const promiseArray = noteObjects.map(note => note.save())
await Promise.all(promiseArray)
})
A solução é bastante avançada, apesar de sua aparência compacta. A variável noteObjects é atribuída a um array de objetos Mongoose que são criados com o construtor Note para cada uma das notas no array helper.initialNotes. A próxima linha de código cria um novo array de promessas, que são criadas chamando o método save de cada item no array noteObjects. Em outras palavras, é um array de promessas para salvar cada um dos itens no banco de dados.
O método Promise.all pode ser usado para transformar um array de promessas em uma única promessa, que será cumprida (fulfilled) assim que todas as promessas no array passado como parâmetro forem resolvidas. A última linha de código await Promise.all(promiseArray) espera até que todas as promessas para salvar uma nota sejam concluídas, o que significa que o banco de dados foi inicializado.
Os valores retornados por cada promessa do array ainda podem ser acessados ao usar o método Promise.all. Se esperarmos pelas promessas serem resolvidas com a sintaxe await const results = await Promise.all(promiseArray), a operação retornará um array que contém os valores resultantes de cada promessa em promiseArray, e elas aparecem na mesma ordem das promessas no array.
O método Promise.all executa as promessas que recebe em paralelo. Se as promessas precisam ser executadas em uma ordem específica, isso será um problema. Nestas situações, as operações podem ser executadas dentro de um bloco for...of que garante uma ordem específica de execução:
beforeEach(async () => {
await Note.deleteMany({})
for (let note of helper.initialNotes) {
let noteObject = new Note(note)
await noteObject.save()
}
})
A natureza assíncrona do JavaScript pode conduzir em comportamentos surpreendentes, por esta razão é importante prestar atenção na utilização da sintaxe async/await. Mesmo que a sintaxe facilite lidar com promessas, ainda é necessário entender como as promessas funcionam!
O código de nossa aplicação pode ser encontrado no GitHub, na branch part4-5.
O juramento de um verdadeiro desenvolvedor full stack
Incluir testes traz mais um desafio à programação. Precisamos atualizar nosso juramento de desenvolvedor full stack para lembrar que a sistemática também é fundamental ao desenvolver testes.
Então devemos estender nosso juramento novamente:
O desenvolvimento full stack é extremamente difícil, por isso usarei todos os meios possíveis para torná-lo mais fácil.
- Vou manter sempre aberto o console do desenvolvedor do meu navegador
- Usarei a guia de rede das ferramentas de desenvolvimento do navegador para garantir que o frontend e o backend estejam se comunicando como esperado
- Vou constantemente monitorar o estado do servidor para ter certeza que o dado enviado pelo frontend foi salvo conforme esperado
- Vou monitorar o banco de dados: o backend está salvando os dados no formato adequado?
- Eu progredirei em pequenos passos
- Vou escrever muitos comandos console.log para entender o comportamento do código e dos testes e auxiliar na identificação de problemas
- Se meu código não funcionar, não escreverei mais código. Em vez disso, voltarei a apagar o código até que funcione ou simplesmente retorne a um estado anterior em que tudo ainda estava funcionando.
- Se um teste falhar, eu vou me certificar de que a funcionalidade testada realmente funcione na aplicação
- Quando pedir ajuda no canal Discord do curso ou em outro lugar, formularei corretamente minhas perguntas. Veja aqui como pedir ajuda.
Refatorando os testes
Atualmente, nossa cobertura de testes é insuficiente. Algumas requisições como GET /api/notes/:id e DELETE /api/notes/:id não foram testadas quando uma requisição é feita com um id inválido. O agrupamento e a organização dos testes também podem melhorar, já que todos os testes estão "no mesmo nível" no arquivo de testes. A legibilidade dos testes aumentaria se agrupássemos os testes relacionados com blocos describe.
Abaixo está um exemplo do arquivo de teste após pequenas melhorias:
const supertest = require('supertest')
const mongoose = require('mongoose')
const helper = require('./test_helper')
const app = require('../app')
const api = supertest(app)
const Note = require('../models/note')
beforeEach(async () => {
await Note.deleteMany({})
await Note.insertMany(helper.initialNotes)
})
describe('when there is initially some notes saved', () => {
test('notes are returned as json', async () => {
await api
.get('/api/notes')
.expect(200)
.expect('Content-Type', /application\/json/)
})
test('all notes are returned', async () => {
const response = await api.get('/api/notes')
expect(response.body).toHaveLength(helper.initialNotes.length)
})
test('a specific note is within the returned notes', async () => {
const response = await api.get('/api/notes')
const contents = response.body.map(r => r.content)
expect(contents).toContain(
'Browser can execute only JavaScript'
)
})
})
describe('viewing a specific note', () => {
test('succeeds with a valid id', async () => {
const notesAtStart = await helper.notesInDb()
const noteToView = notesAtStart[0]
const resultNote = await api
.get(`/api/notes/${noteToView.id}`)
.expect(200)
.expect('Content-Type', /application\/json/)
expect(resultNote.body).toEqual(noteToView)
})
test('fails with statuscode 404 if note does not exist', async () => {
const validNonexistingId = await helper.nonExistingId()
await api
.get(`/api/notes/${validNonexistingId}`)
.expect(404)
})
test('fails with statuscode 400 if id is invalid', async () => {
const invalidId = '5a3d5da59070081a82a3445'
await api
.get(`/api/notes/${invalidId}`)
.expect(400)
})
})
describe('addition of a new note', () => {
test('succeeds with valid data', async () => {
const newNote = {
content: 'async/await simplifies making async calls',
important: true,
}
await api
.post('/api/notes')
.send(newNote)
.expect(201)
.expect('Content-Type', /application\/json/)
const notesAtEnd = await helper.notesInDb()
expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1)
const contents = notesAtEnd.map(n => n.content)
expect(contents).toContain(
'async/await simplifies making async calls'
)
})
test('fails with status code 400 if data invalid', async () => {
const newNote = {
important: true
}
await api
.post('/api/notes')
.send(newNote)
.expect(400)
const notesAtEnd = await helper.notesInDb()
expect(notesAtEnd).toHaveLength(helper.initialNotes.length)
})
})
describe('deletion of a note', () => {
test('succeeds with status code 204 if id is valid', async () => {
const notesAtStart = await helper.notesInDb()
const noteToDelete = notesAtStart[0]
await api
.delete(`/api/notes/${noteToDelete.id}`)
.expect(204)
const notesAtEnd = await helper.notesInDb()
expect(notesAtEnd).toHaveLength(
helper.initialNotes.length - 1
)
const contents = notesAtEnd.map(r => r.content)
expect(contents).not.toContain(noteToDelete.content)
})
})
afterAll(async () => {
await mongoose.connection.close()
})
A saída é agrupada de acordo com os blocos describe:
Ainda há espaço para melhorias, mas precisamos seguir adiante.
Essa forma de testar a API, fazendo solicitações HTTP e inspecionando o banco de dados com o Mongoose, não é de forma alguma a única nem a melhor maneira de conduzir testes de integração em nível de API para aplicações de servidor. Não há uma melhor maneira universal de escrever testes, pois tudo depende da aplicação sendo testada e dos recursos disponíveis.
Você encontrará o código atual de nossa aplicação na branch part4-6 do repositório GitHub.