b

Porbando el backend

Ahora comenzaremos a escribir pruebas para el backend. Dado que el backend no contiene ninguna lógica complicada, no tiene sentido escribir pruebas unitarias para él. Lo único que podríamos probar unitariamente es el método toJSON que se utiliza para formatear notas.

En algunas situaciones, puede ser beneficioso implementar algunas de las pruebas de backend simulando la base de datos en lugar de usar una base de datos real. Una biblioteca que podría usarse para esto es mongo-mock.

Dado que el backend de nuestra aplicación todavía es relativamente simple, tomaremos la decisión de probar toda la aplicación a través de su API REST, de modo que la base de datos también esté incluida. Este tipo de prueba, en la que se prueban varios componentes del sistema como un grupo, se denomina prueba de integración.

Entorno de prueba

En uno de los capítulos anteriores del material del curso, mencionamos que cuando su servidor backend se ejecuta en Heroku, está en modo producción.

La convención en Node es definir el modo de ejecución de la aplicación con la variable de entorno NODE_ENV. En nuestra aplicación actual, solo cargamos las variables de entorno definidas en el archivo .env si la aplicación no esta en modo producción.

Es una práctica común definir modos separados para desarrollo y prueba.

A continuación, cambiemos los scripts en nuestro package.json para que cuando se ejecuten las pruebas, NODE_ENV obtenga el valor test:

{
  // ...
  "scripts": {
    "start": "NODE_ENV=production node index.js",    "dev": "NODE_ENV=development nodemon index.js",    "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend",
    "deploy": "git push heroku master",
    "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy",
    "logs:prod": "heroku logs --tail",
    "lint": "eslint .",
    "test": "NODE_ENV=test jest --verbose --runInBand"  },
  // ...
}

También agregamos runInBand al script npm que ejecuta las pruebas. Esta opción evitará que Jest ejecute pruebas en paralelo; discutiremos su importancia una vez que nuestras pruebas comiencen a usar la base de datos.

Especificamos el modo de la aplicación para que sea development en el script npm run dev que usa nodemon. También especificamos que el comando predeterminado npm start definirá el modo como production.

Hay un pequeño problema en la forma en que hemos especificado el modo de la aplicación en nuestros scripts: no funcionará en Windows. Podemos corregir esto instalando el paquete cross-env como una dependencia de desarrollo con el comando:

npm install --save-dev cross-env

Entonces podemos lograr la compatibilidad multiplataforma utilizando la biblioteca cross-env en nuestros scripts npm definidos en 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",
  },
  // ...
}

Ahora podemos modificar la forma en que se ejecuta nuestra aplicación en diferentes modos. Como ejemplo de esto, podríamos definir la aplicación para usar una base de datos de prueba separada cuando esté ejecutando pruebas.

Podemos crear nuestra base de datos de prueba separada en Mongo DB Atlas. Esta no es una solución óptima en situaciones en las que muchas personas desarrollan la misma aplicación. La ejecución de pruebas, en particular, generalmente requiere que las pruebas que se ejecutan simultáneamente no utilicen una sola instancia de base de datos.

Sería mejor ejecutar nuestras pruebas usando una base de datos que esté instalada y ejecutándose en la máquina local del desarrollador. La solución óptima sería que cada ejecución de prueba use su propia base de datos separada. Esto es "relativamente simple" de lograr ejecutando Mongo en memoria o usando contenedores Docker. No complicaremos las cosas y en su lugar continuaremos usando la base de datos MongoDB Atlas.

Hagamos algunos cambios en el módulo que define la configuración de la aplicación:

require('dotenv').config()

const PORT = process.env.PORT
let MONGODB_URI = process.env.MONGODB_URI

if (process.env.NODE_ENV === 'test') {  MONGODB_URI = process.env.TEST_MONGODB_URI}
module.exports = {
  MONGODB_URI,
  PORT
}

El archivo .env tiene variables independientes para las direcciones de la base de datos de desarrollo y prueba:

MONGODB_URI=mongodb+srv://fullstack:secred@cluster0-ostce.mongodb.net/note-app?retryWrites=true
PORT=3001

TEST_MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app-test?retryWrites=true

El módulo config que hemos implementado se parece ligeramente al paquete node-config. Escribir nuestra propia implementación está justificado porque nuestra aplicación es simple, y también porque nos enseña lecciones valiosas.

Estos son los únicos cambios que debemos realizar en el código de nuestra aplicación.

Puede encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-2 de este repositorio de github.

supertest

Usemos el paquete supertest para ayudarnos a escribir nuestras pruebas para probar la API.

Instalaremos el paquete como una dependencia de desarrollo:

npm install --save-dev supertest

Escribamos nuestra primera prueba en el archivo 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(() => {
  mongoose.connection.close()
})

La prueba importa la aplicación Express del módulo app.js y la envuelve con la función supertest en un objeto llamado superagent. Este objeto se asigna a la variable api y las pruebas pueden usarlo para realizar solicitudes HTTP al backend.

Nuestra prueba realiza una solicitud HTTP GET a la URL api/notes y verifica que se responda a la solicitud con el código de estado 200. La prueba también verifica que el encabezado Content-Type se establece en application/json, lo que indica que los datos están en el formato deseado.

La prueba contiene algunos detalles que exploraremos un poco más adelante. La función de flecha que define la prueba está precedida por la palabra clave async y la llamada al método para el objeto api está precedida por la palabra clave await. Escribiremos algunas pruebas y luego echaremos un vistazo más de cerca a esta magia asyn/await. No se preocupe por ellos por ahora, solo tenga la seguridad de que las pruebas de ejemplo funcionan correctamente. La sintaxis async/await está relacionada con el hecho de que hacer una solicitud a la API es una operación asincrónica. La sintaxis async/await se puede utilizar para escribir código asincrónico con la apariencia de código síncrono.

Una vez que todas las pruebas (actualmente solo hay una) hayan terminado de ejecutarse, tenemos que cerrar la conexión a la base de datos utilizada por Mongoose. Esto se puede lograr fácilmente con el método afterAll:

afterAll(() => {
  mongoose.connection.close()
})

Al ejecutar las pruebas, es posible que se encuentre con la siguiente advertencia de consola:

fullstack content

Si esto ocurre, sigamos las instrucciones y agregue un archivo jest.config.js en la raíz del proyecto con el siguiente contenido:

module.exports = {
  testEnvironment: 'node'
}

Un pequeño pero importante detalle: al principio de esta parte extrajimos la aplicación Express en el archivo app.js, y el rol del archivo index.js se cambió para iniciar la aplicación en el puerto especificado con el objeto http incorporado de Node:

const app = require('./app') // the actual Express app
const http = require('http')
const config = require('./utils/config')
const logger = require('./utils/logger')

const server = http.createServer(app)

server.listen(config.PORT, () => {
  logger.info(`Server running on port ${config.PORT}`)
})

Las pruebas solo usan la aplicación express definida en el archivo app.js:

const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
// ...

La documentación de supertest dice lo siguiente:

si el servidor no está escuchando para las conexiones, entonces está vinculado a un puerto efímero para usted, por lo que no es necesario realizar un seguimiento de los puertos.

En otras palabras, supertest se encarga de que la aplicación que se está probando se inicie en el puerto que utiliza internamente.

Escribamos algunas pruebas más:

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')
})

Ambas pruebas almacenan la respuesta de la solicitud a la variable response, y a diferencia de la prueba anterior que utilizó los métodos proporcionados por supertest para verificar el código de estado y los encabezados, esta vez estamos inspeccionando los datos de respuesta almacenados en la propiedad response.body. Nuestras pruebas verifican el formato y el contenido de los datos de respuesta con el método expect de Jest.

El beneficio de usar la sintaxis async/await está comenzando a ser evidente. Normalmente tendríamos que usar funciones de devolución de llamada para acceder a los datos devueltos por las promesas, pero con la nueva sintaxis las cosas son mucho más cómodas:

const response = await api.get('/api/notes')

// la ejecución llega aquí solo después de que se completa la solicitud HTTP
// el resultado de la solicitud HTTP se guarda en respuesta variable
expect(response.body).toHaveLength(2)

El middleware que genera información sobre las solicitudes HTTP está obstruyendo la salida de ejecución de la prueba. Modifiquemos el logger para que no imprima en la consola en modo de prueba:

const info = (...params) => {
  if (process.env.NODE_ENV !== 'test') {     console.log(...params)  }}

const error = (...params) => {
  console.error(...params)
}

module.exports = {
  info, error
}

Inicializando la base de datos antes de las pruebas

Las pruebas parecen ser fáciles y nuestras pruebas están pasando. Sin embargo, nuestras pruebas son malas, ya que dependen del estado de la base de datos (que resulta ser correcto en mi base de datos de prueba). Para hacer nuestras pruebas más robustas, tenemos que restablecer la base de datos y generar los datos de prueba necesarios de manera controlada antes de ejecutar las pruebas.

Nuestras pruebas ya están usando la función afterAll de Jest para cerrar la conexión a la base de datos después de que las pruebas hayan terminado de ejecutarse . Jest ofrece muchas otras funciones que se pueden usar para ejecutar operaciones una vez antes de que se ejecute cualquier prueba, o cada vez antes de que se ejecuta una prueba.

Inicialicemos la base de datos antes de cada prueba con la función 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',    date: new Date(),    important: false,  },  {    content: 'Browser can execute only Javascript',    date: new Date(),    important: true,  },]beforeEach(async () => {  await Note.deleteMany({})  let noteObject = new Note(initialNotes[0])  await noteObject.save()  noteObject = new Note(initialNotes[1])  await noteObject.save()})// ...

La base de datos se borra al principio, y luego guardamos las dos notas almacenadas en la matriz initialNotes en la base de datos. Al hacer esto, nos aseguramos de que la base de datos esté en el mismo estado antes de ejecutar cada prueba.

También hagamos los siguientes cambios en las dos últimas pruebas:

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'  )})

Preste especial atención al expect en la última prueba. El comando response.body.map (r => r.content) se usa para crear una matriz que contiene el contenido de cada nota devuelta por la API. El método toContain se utiliza para comprobar que la nota que se le ha asignado como parámetro está en la lista de notas devueltas por la API.

Ejecución de pruebas una por una

El comando npm test ejecuta todas las pruebas de la aplicación. Cuando escribimos pruebas, generalmente es aconsejable ejecutar solo una o dos pruebas. Jest ofrece algunas formas diferentes de lograr esto, una de las cuales es el método only. Si las pruebas se escriben en muchos archivos, este método no es excelente.

Una mejor opción es especificar las pruebas que deben ejecutarse como parámetro del comando npm test.

El siguiente comando solo ejecuta las pruebas que se encuentran en el archivo tests/ note_api.test.js:

npm test -- tests/note_api.test.js

La opción -t se puede utilizar para ejecutar pruebas con un nombre específico:

npm test -- -t 'a specific note is within the returned notes'

El parámetro proporcionado puede hacer referencia al nombre de la prueba o al bloque de descripción. El parámetro también puede contener solo una parte del nombre. El siguiente comando ejecutará todas las pruebas que contengan notes en su nombre:

npm test -- -t 'notes'

NB: Cuando se ejecuta una sola prueba, la conexión de mongoose puede permanecer abierta si no se ejecuta ninguna prueba con la conexión. El problema puede deberse al hecho de que supertest prepara la conexión, pero jest no ejecuta la parte afterAll del código.

async/await

Antes de escribir más pruebas, echemos un vistazo a las palabras clave async y await.

La sintaxis async/await que se introdujo en ES7 hace posible el uso de funciones asincrónicas que devuelven una promesa de una manera que hace que el código parezca sincrónico.

Como ejemplo, la obtención de notas de la base de datos con promesas se ve así:

Note.find({}).then(notes => {
  console.log('operation returned the following notes', notes)
})

El método Note.find() devuelve una promesa y podemos acceder al resultado de la operación registrando una función de devolución de llamada con el método then.

Todo el código que queremos ejecutar una vez que finalice la operación está escrito en la función de devolución de llamada. Si quisiéramos realizar varias llamadas a funciones asincrónicas en secuencia, la situación pronto se volvería dolorosa. Las llamadas asincrónicas deberían realizarse en la devolución de llamada. Esto probablemente conduciría a un código complicado y podría potencialmente dar lugar a un llamado infierno de devolución de llamada.

Al encadenar promesas podríamos mantener la situación un poco bajo control y evitar el infierno de devolución de llamada creando una cadena bastante limpia de llamadas a métodos then. Hemos visto algunos de estos durante el curso. Para ilustrar esto, puede ver un ejemplo artificial de una función que recupera todas las notas y luego elimina la primera:

Note.find({})
  .then(notes => {
    return notes[0].remove()
  })
  .then(response => {
    console.log('the first note is removed')
    // more code here
  })

La cadena de then está bien, pero podemos hacerlo mejor. Las funciones del generador introducidas en ES6 proporcionaron una forma inteligente de escribir código asincrónico de una manera que "parezca sincrónica". La sintaxis es un poco torpe y no se usa mucho.

Las palabras clave async y await introducidas en ES7 traen la misma funcionalidad que los generadores, pero de una manera comprensible y sintácticamente más limpia a las manos de todos los ciudadanos del mundo JavaScript.

Podríamos obtener todas las notas en la base de datos utilizando el operador await como este:

const notes = await Note.find({})

console.log('operation returned the following notes', notes)

El código se ve exactamente como el código síncrono. La ejecución del código se detiene en const notes = await Note.find({}) y espera hasta que se cumpla la promesa relacionada, y luego continúa su ejecución a la siguiente línea. Cuando la ejecución continúa, el resultado de la operación que devolvió una promesa se asigna a la variable notes.

El ejemplo un poco complicado presentado anteriormente podría implementarse usando await como este:

const notes = await Note.find({})
const response = await notes[0].remove()

console.log('the first note is removed')

Gracias a la nueva sintaxis, el código es mucho más simple que la cadena then anterior.

Hay algunos detalles importantes a los que se debe prestar atención cuando se usa la sintaxis async/await. Para utilizar el operador await con operaciones asincrónicas, deben devolver una promesa. Esto no es un problema como tal, ya que las funciones asincrónicas regulares que utilizan devoluciones de llamada son fáciles de envolver en promesas.

La palabra clave await no se puede usar en cualquier parte del código JavaScript. El uso de await solo es posible dentro de una función async.

Esto significa que para que los ejemplos anteriores funcionen, deben utilizar funciones asíncronas. Observe la primera línea en la definición de la función de flecha:

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()

El código declara que la función asignada a main es asíncrona. Después de esto, el código llama a la función con main().

async/await en el backend

Cambiemos el backend a async y await. Como todas las operaciones asincrónicas se realizan actualmente dentro de una función, es suficiente cambiar las funciones del controlador de ruta en funciones asincrónicas.

La ruta para obtener todas las notas se cambia a la siguiente:

notesRouter.get('/', async (request, response) => { 
  const notes = await Note.find({})
  response.json(notes)
})

Podemos verificar que nuestra refactorización fue exitosa probando el endpoint a través del navegador y ejecutando las pruebas que escribimos anteriormente.

Puede encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-3 de este repositorio de Github.

Más pruebas y refactorización del backend

Cuando el código se refactoriza, siempre existe el riesgo de regresión, lo que significa que la funcionalidad existente puede romperse. Refactoricemos las operaciones restantes escribiendo primero una prueba para cada ruta de la API.

Comencemos con la operación para agregar una nueva nota. Escribamos una prueba que agregue una nueva nota y verifique que la cantidad de notas devueltas por la API aumente y que la nota recién agregada esté en la 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(200)
    .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'
  )
})

La prueba pasa justo como nosotros esperabamos que lo hiciera.

Escribamos también una prueba que verifique que una nota sin contenido no se guardará en la base de datos.

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)
})

Ambas pruebas verifican el estado almacenado en la base de datos después de la operación de guardado, obteniendo todas las notas de la aplicación.

const response = await api.get('/api/notes')

Los mismos pasos de verificación se repetirán en otras pruebas más adelante, y es una buena idea extraer estos pasos en funciones auxiliares. Agreguemos la función en un nuevo archivo llamado tests/test_helper.js que está en el mismo directorio que el archivo de prueba.

const Note = require('../models/note')

const initialNotes = [
  {
    content: 'HTML is easy',
    date: new Date(),
    important: false
  },
  {
    content: 'Browser can execute only Javascript',
    date: new Date(),
    important: true
  }
]

const nonExistingId = async () => {
  const note = new Note({ content: 'willremovethissoon', date: new Date() })
  await note.save()
  await note.remove()

  return note._id.toString()
}

const notesInDb = async () => {
  const notes = await Note.find({})
  return notes.map(note => note.toJSON())
}

module.exports = {
  initialNotes, nonExistingId, notesInDb
}

El módulo define la función notesInDb que se puede usar para verificar las notas almacenadas en la base de datos. La matriz initialNotes que contiene el estado inicial de la base de datos también está en el módulo. También definimos la función nonExistingId con anticipación, que se puede usar para crear un ID de objeto de base de datos que no pertenezca a ningún objeto de nota en la base de datos.

Nuestras pruebas ahora pueden usar el módulo auxiliar y cambiarse así:

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(200)
    .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(() => {
  mongoose.connection.close()
}) 

El código que usa promesas funciona y las pruebas pasan. Estamos listos para refactorizar nuestro código para usar la sintaxis async/await.

Realizamos los siguientes cambios en el código que se encarga de agregar una nueva nota (observe que la definición del controlador de ruta está precedida por la palabra clave async):

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
    date: new Date(),
  })

  const savedNote = await note.save()
  response.json(savedNote)
})

Hay un pequeño problema con nuestro código: no manejamos situaciones de error. ¿Cómo debemos lidiar con ellos?

Manejo de errores y async/await

Si hay una excepción al manejar la solicitud POST terminamos en una situación familiar:

fullstack content

En otras palabras, terminamos con un rechazo de promesa no gestionado, y la solicitud nunca recibe una respuesta.

Con async/await, la forma recomendada de lidiar con las excepciones es el viejo y familiar mecanismo try/catch:

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
    date: new Date(),
  })
  try {     const savedNote = await note.save()    response.json(savedNote)  } catch(exception) {    next(exception)  }})

El bloque catch simplemente llama la función next, que pasa el manejo de solicitudes al middleware de manejo de errores.

Después de realizar el cambio, todas nuestras pruebas pasarán una vez más.

A continuación, escribamos pruebas para obtener y eliminar una 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/)
  const processedNoteToView = JSON.parse(JSON.stringify(noteToView))

  expect(resultNote.body).toEqual(processedNoteToView)
})

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)
})

En la primera prueba, el objeto de nota que recibimos como que el cuerpo de la respuesta pasa por la serialización y el análisis de JSON. Este procesamiento convertirá el tipo de valor de propiedad date del objeto de nota del objeto Date en una cadena. Debido a esto, no podemos comparar directamente la igualdad de resultNote.body y noteToView. En su lugar, primero debemos realizar una serialización JSON y un análisis similares para noteToView como lo hace el servidor para el objeto note.

Ambas pruebas comparten una estructura similar. En la fase de inicialización, obtienen una nota de la base de datos. Después de esto, las pruebas llaman a la operación real que se está probando, que se resalta en el bloque de código. Por último, las pruebas verifican que el resultado de la operación sea el esperado.

Las pruebas pasan y podemos refactorizar con seguridad las rutas probadas 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.findByIdAndRemove(request.params.id)
    response.status(204).end()
  } catch (exception) {
    next(exception)
  }
})

Puede encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-4 de este repositorio de Github.

Eliminando el try-catch

Async/await despeja un poco el código, pero el 'precio' es la estructura try/catch necesaria para detectar excepciones. Todos los manejadores de ruta siguen la misma estructura

try {
  // do the async operations here
} catch(exception) {
  next(exception)
}

Uno comienza a preguntarse, ¿sería posible refactorizar el código para eliminar el catch de los métodos?

La biblioteca express-async-errors tiene una solución para esto.

Instalemos la biblioteca

npm install express-async-errors

Usar la biblioteca es muy fácil. Introduce la biblioteca en 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

La 'magia' de la biblioteca nos permite eliminar por completo los bloques try-catch. Por ejemplo, la ruta para eliminar una nota

notesRouter.delete('/:id', async (request, response, next) => {
  try {
    await Note.findByIdAndRemove(request.params.id)
    response.status(204).end()
  } catch (exception) {
    next(exception)
  }
})

se convierte en

notesRouter.delete('/:id', async (request, response) => {
  await Note.findByIdAndRemove(request.params.id)
  response.status(204).end()
})

Debido a la biblioteca, ya no necesitamos la llamada next(exception). La biblioteca se encarga de todo lo que hay debajo del capó. Si ocurre una excepción en una ruta async, la ejecución se pasa automáticamente al middleware de manejo de errores.

Las otras rutas se convierten en:

notesRouter.post('/', async (request, response) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
    date: new Date(),
  })

  const savedNote = await note.save()
  response.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()
  }
})

El código de nuestra aplicación se puede encontrar en github, rama part4-5.

Optimización de la función beforeEach

Volvamos a escribir nuestras pruebas y echemos un vistazo más de cerca a la función beforeEach que configura las pruebas:

beforeEach(async () => {
  await Note.deleteMany({})

  let noteObject = new Note(helper.initialNotes[0])
  await noteObject.save()

  noteObject = new Note(helper.initialNotes[1])
  await noteObject.save()
})

La función guarda las dos primeras notas de la matriz helper.initialNotes en la base de datos con dos operaciones separadas. La solución está bien, pero hay una mejor manera de guardar varios objetos en la base de datos:

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')
  // ...
}

Guardamos las notas almacenadas en la matriz en la base de datos dentro de un loop forEach. Sin embargo, las pruebas no parecen funcionar del todo, por lo que hemos agregado algunos registros de la consola para ayudarnos a encontrar el problema.

La consola muestra el siguiente resultado:


cleared
done
entered test
saved
saved

A pesar de nuestra uso de la sintaxis async/await, nuestra solución no funciona como esperábamos. ¡La ejecución de la prueba comienza antes de que se inicialice la base de datos!

El problema es que cada iteración del bucle forEach genera su propia operación asincrónica, y beforeEach no esperará a que terminen de ejecutarse. En otras palabras, los comandos await definidos dentro del bucle forEach no están en la función beforeEach, sino en funciones separadas que beforeEach no esperará.

Dado que la ejecución de las pruebas comienza inmediatamente después de que beforeEach haya terminado de ejecutarse, la ejecución de las pruebas comienza antes de que se inicialice el estado de la base de datos.

Una forma de arreglar esto es esperar a que todas las operaciones asincrónicas terminen de ejecutarse con el 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)
})

La solución es bastante avanzado a pesar de su apariencia compacta. La variable noteObjects se asigna a una matriz de objetos Mongoose que se crean con el constructor Note para cada una de las notas en la matriz helper.initialNotes. La siguiente línea de código crea una nueva matriz que consiste en promesas, que se crean llamando al método save de cada elemento en la matriz noteObjects. En otras palabras, es una serie de promesas para guardar cada uno de los elementos en la base de datos.

El método Promise.all se puede utilizar para transformar una serie de promesas en una única promesa, que se cumplirá una vez que se resuelva cada promesa en la matriz que se le pasa como parámetro. La última línea de código await Promise.all(promiseArray) espera que finalice cada promesa de guardar una nota, lo que significa que la base de datos se ha inicializado.

Aún se puede acceder a los valores devueltos de cada promesa en la matriz cuando se usa el método Promise.all. Si esperamos a que se resuelvan las promesas con la sintaxis await const results = await Promise.all (promiseArray), la operación devolverá una matriz que contiene los valores resueltos para cada promesa en promiseArray, y aparecen en el mismo orden que las promesas en la matriz.

Promise.all ejecuta las promesas que recibe en paralelo. Si las promesas deben ejecutarse en un orden particular, esto será problemático. En situaciones como esta, las operaciones se pueden ejecutar dentro de un for ... of, que garantiza una determinada orden de ejecución.

beforeEach(async () => {
  await Note.deleteMany({})

  for (let note of helper.initialNotes) {
    let noteObject = new Note(note)
    await noteObject.save()
  }
})

La naturaleza asincrónica de JavaScript puede llevar a un comportamiento sorprendente por esta razón, es importante prestar mucha atención al usar la sintaxis async/await. Aunque la sintaxis hace que sea más fácil lidiar con las promesas, ¡es necesario entender cómo funcionan las promesas!

Pruebas de refactorización

Actualmente, nuestra prueba esta falto de cobertura. Algunas solicitudes como GET /api/notes/:id y DELETE /api/notes/:id no se prueban cuando la solicitud se envía con una identificación no válida. La agrupación y organización de las pruebas también podría mejorar, ya que todas las pruebas existen en el mismo "nivel superior" en el archivo de prueba. La legibilidad de la prueba mejoraría si agrupamos las pruebas relacionadas con bloques describe.

A continuación se muestra un ejemplo del archivo de prueba después de realizar algunas mejoras menores:

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({})

  const noteObjects = helper.initialNotes
    .map(note => new Note(note))
  const promiseArray = noteObjects.map(note => note.save())
  await Promise.all(promiseArray)
})

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/)
      
    const processedNoteToView = JSON.parse(JSON.stringify(noteToView))

    expect(resultNote.body).toEqual(processedNoteToView)
  })

  test('fails with statuscode 404 if note does not exist', async () => {
    const validNonexistingId = await helper.nonExistingId()

    console.log(validNonexistingId)

    await api
      .get(`/api/notes/${validNonexistingId}`)
      .expect(404)
  })

  test('fails with statuscode 400 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(200)
      .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 invaild', 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(() => {
  mongoose.connection.close()
})

La salida de prueba se agrupa de acuerdo con los bloques describe:

fullstack content

Todavía hay margen de mejora, pero es hora de seguir adelante.

Esta forma de probar la API, al realizar solicitudes HTTP e inspeccionar la base de datos con Mongoose, no es de ninguna manera la única ni la mejor forma de realizar pruebas de integración a nivel de API para aplicaciones de servidor. No existe una mejor forma universal de escribir pruebas, ya que todo depende de la aplicación que se esté probando y de los recursos disponibles.

Puede encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-6 de este repositorio de Github.