Saltar al contenido

b

Probando 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 librería 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 Fly.io o Render, 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",    "test": "NODE_ENV=test node --test",    "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 .",
  },
  // ...
}

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 librería 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 node --test",
  },
  // ...
}

Nota: Si estás desplegando esta aplicación en Fly.io/Render, ten en cuenta que si cross-env se guarda como una dependencia de desarrollo, podría causar un error en tu servidor web. Para solucionarlo, cambia cross-env a una dependencia de producción ejecutando lo siguiente en la línea de comandos:

npm install cross-env

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 MongoDB 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 en utils/config.js:

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
}

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

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

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.

Puedes 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 { test, after } = require('node:test')
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/)
})

after(async () => {
  await 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 verificación del valor en el encabezado usa una sintaxis un poco extraña:

.expect('Content-Type', /application\/json/)

El valor lo definimos como una expresión regular o en palabras cortas: regex. Las expresiones regulares en JavaScript inician y finalizan con un slash /. Dado que la cadena deseada application/json también contiene el mismo slash en el medio, entonces se precede por un \ de tal manera que no se interprete como un caracter de terminación.

En principio, el test podría también ser definido simplemente como una cadena:

.expect('Content-Type', 'application/json')

El problema, es que si usamos cadenas el valor del encabezado debe ser exactamente el mismo. Para la expresión que definimos, es suficiente que el encabezado contenga la cadena en cuestión. Por ejemplo, el valor actual del encabezado puede ser application/json; charset=utf-8 ya que también tiene información de la codificación de caracteres (utf-8). Sin embargo, nuestra prueba no está interesada en esto y, por lo tanto, es mejor definir la prueba como una expresión regular en lugar verificar una cadena exacta.

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 de async/await. No te preocupes por esto por ahora, solo ten 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 asíncrona. La sintaxis async/await se puede utilizar para escribir código asíncrono 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 after:

after(() => {
  await mongoose.connection.close()
})

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 a través de app.listen:

const app = require('./app') // la aplicación Express
const config = require('./utils/config')
const logger = require('./utils/logger')

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 aún no está escuchando conexiones, entonces se vincula a un puerto efímero automáticamente, por lo que no es necesario hacer 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.

Agreguemos dos notas a la base de datos de prueba utilizando el programa mongo.js (aquí debemos recordar cambiar a la URL correcta de la base de datos).

Escribamos algunas pruebas más:

test('there are two notes', async () => {
  const response = await api.get('/api/notes')

  assert.strictEqual(response.body.length, 2)
})

test('the first note is about HTTP methods', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(e => e.content)
  assert.strictEqual(contents.includes('HTML is easy'), true)
})

Ambas pruebas almacenan la respuesta de la solicitud en 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 strictEqual de la librería assert.

Podríamos simplificar un poco la segunda prueba, utilizando solo a assert para verificar que la nota esta entre las que fueron devueltas por el backend:

test('the first note is about HTTP methods', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(e => e.content)
  // es el argumento truthy
  assert(contents.includes('HTML is easy'))
})

El beneficio de usar la sintaxis async/await está comenzando a ser evidente. Normalmente tendríamos que usar funciones callback 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 la variable response
expect(response.body).toHaveLength(2)
assert.strictEqual(response.body.length, 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) => {
  if (process.env.NODE_ENV !== 'test') {     console.error(...params)  }}

module.exports = {
  info, error
}

Inicializando la base de datos antes de las pruebas

Testing parece ser fácil y actualmente nuestras pruebas están pasando. Sin embargo, nuestras pruebas son malas ya que dependen del estado de la base de datos, que ahora tiene dos notas. Para hacerlas más robustas, debemos resetear 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 after para cerrar la conexión a la base de datos después de que las pruebas hayan terminado de ejecutarse. La librería node:test 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 { test, after, beforeEach } = require('node:test')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()})// ...

La base de datos se borra al principio, y luego guardamos las dos notas almacenadas en el array 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('there are two notes', async () => {
  const response = await api.get('/api/notes')

  assert.strictEqual(response.body.length, initialNotes.length)
})

test('the first note is about HTTP methods', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(e => e.content)
  assert(contents.includes('HTML is easy'))
})

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.

Hay diferentes formas de lograr esto, una de las cuales es el método only. Con este método podemos definir en el código que pruebas deben ser ejecutadas:

test.only('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

test.only('there are two notes', async () => {
  const response = await api.get('/api/notes')

  assert.strictEqual(response.body.length, 2)
})

Cuando las pruebas son ejecutadas con la opción --test-only, eso es, con el comando:

npm test -- --test-only

solo los tests marcados con only son ejecutados.

El peligro de only es que uno olvida quitarlos del código.

Otra opción es especificar las pruebas que necesitan ser ejecutadas como argumentos del comando npm test.

El siguiente comando solo ejecuta los tests encontrados en el archivo tests/note_api.test.js:

npm test -- tests/note_api.test.js

La opción --tests-by-name-pattern puede usarse para ejecutar pruebas con un nombre especifico:

npm test -- --test-name-pattern="the first note is about HTTP methods"

El argumento dado puede referirse al nombre del test o del bloque describe. También puede contener solo una parte del nombre. El siguiente comando va a ejecutar todos los tests que contengan notes en su nombre:

npm run test -- --test-name-pattern="notes"

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 asíncronas que devuelven una promesa de una manera que hace que el código parezca síncrono.

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 callback 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 callback. Si quisiéramos realizar varias llamadas a funciones asíncronas en secuencia, la situación pronto se volvería dolorosa. Las llamadas asíncronas deberían realizarse en el callback. Esto probablemente conduciría a un código complicado y podría potencialmente dar lugar a un llamado infierno de callbacks.

Al encadenar promesas podríamos mantener la situación un poco bajo control y evitar el infierno de callbacks creando una cadena bastante limpia de llamadas a métodos then. Hemos visto algunos de estos durante el curso. Para ilustrar esto, puedes 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')
    // más código aquí
  })

La cadena de then está bien, pero podemos hacerlo mejor. Las funciones de generadores introducidas en ES6 proporcionaron una forma inteligente de escribir código asíncrono de una manera que "parezca síncrona". 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 un operador await cómo 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 que era un poco complicado presentado anteriormente podría implementarse usando await así:

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 asíncronas, estas deben devolver una promesa. Esto no es un problema como tal, ya que las funciones asíncronas regulares que utilizan callbacks 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. Observa 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 asíncronas se realizan actualmente dentro de una función, es suficiente cambiar las funciones de los controladores de ruta a funciones asíncronas.

La ruta para obtener todas las notas se cambia a lo 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.

Puedes 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 aumenta 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(201)
    .expect('Content-Type', /application\/json/)

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

  const contents = response.body.map(r => r.content)

  assert.strictEqual(response.body.length, initialNotes.length + 1)

  assert(contents.includes('async/await simplifies making async calls'))
})

La prueba falla porque accidentalmente estamos devolviendo el código de estado 200 OK cuando se crea una nueva nota. Cambiémoslo para que devuelva 201 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))
})

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

  assert.strictEqual(response.body.length, 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',
    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
}

El módulo define la función notesInDb que se puede usar para verificar las notas almacenadas en la base de datos. El array 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 { test, after, beforeEach } = require('node:test')
const assert = require('node:assert')
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')

   assert.strictEqual(response.body.length, 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)

  assert(contents.includes('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()  assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1)
  const contents = notesAtEnd.map(n => n.content)  assert(contents.includes('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()
  assert.strictEqual(notesAtEnd.length, helper.initialNotes.length)})

after(async () => {
  await 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 (observa 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,
  })

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

terminal mostrando advertencia de promesa rechazada sin gestionar

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,
  })
  try {    const savedNote = await note.save()    response.status(201).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/)
  assert.deepStrictEqual(resultNote.body, 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()

  const contents = notesAtEnd.map(r => r.content)
  assert(!contents.includes(noteToDelete.content))

  assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1)
})

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 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.

Hay un punto que vale la pena mencionar en la primera prueba. En lugar del método previamente utilizado strictEqual, se utiliza el método deepStrictEqual:

assert.deepStrictEqual(resultNote.body, noteToView)

La razón de esto es que strictEqual utiliza el método Object.is para comparar similitud, es decir, compara si los objetos son los mismos. En nuestro caso, es suficiente comprobar que los contenidos de los objetos, es decir, los valores de sus campos, son iguales. Para este propósito, deepStrictEqual es adecuado.

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.findByIdAndDelete(request.params.id)
    response.status(204).end()
  } catch(exception) {
    next(exception)
  }
})

Puedes 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 controladores de ruta siguen la misma estructura

try {
  // realiza las operaciones asíncronas aquí
} catch(exception) {
  next(exception)
}

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

La librería express-async-errors tiene una solución para esto.

Vamos a instalarla

npm install express-async-errors

Usarla es muy fácil. Introduce la librería en app.js, antes de que importes tus rutas:

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 esta librería 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.findByIdAndDelete(request.params.id)
    response.status(204).end()
  } catch (exception) {
    next(exception)
  }
})

se convierte en

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

Debido a la librería, ya no necesitamos la llamada a next(exception). La librería 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,
  })

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

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 las configura:

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 del array 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 dentro del array 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 asíncrona, 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 asíncronas 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 avanzada a pesar de su apariencia compacta. La variable noteObjects se asigna a un array de objetos Mongoose que se crean con el constructor Note para cada una de las notas en el array helper.initialNotes. La siguiente línea de código crea un nuevo array que consiste en promesas, que se crean llamando al método save de cada elemento en el array 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 el array que se le pasa como argumento. La última línea de código await Promise.all(promiseArray) espera a 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 el array 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á un array que contiene los valores resueltos para cada promesa en promiseArray, y aparecen en el mismo orden que las promesas en el array.

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 un orden de ejecución especifico.

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

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

La naturaleza asíncrona de JavaScript puede llevar a un comportamiento sorprendente y, 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!

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

El juramento de un verdadero desarrollador full stack

Realizar pruebas añade otro nivel de desafío a la programación. Debemos actualizar nuestro juramento como desarrolladores full stack para recordar que la sistematicidad también es clave al desarrollar pruebas.

Por lo tanto, debemos extender nuestro juramento una vez más:

El desarrollo full stack es extremadamente difícil , por eso usaré todos los medios posibles para hacerlo más fácil:

  • Mantendré la consola de desarrollador del navegador abierta todo el tiempo
  • Usaré la pestaña "Network" dentro de las herramientas de desarrollo del navegador, para asegurarme que el frontend y el backend se comuniquen como espero
  • Mantendré constantemente un ojo en el estado del servidor, para asegurarme de que los datos enviados allí por el frontend se guarden como espero
  • Vigilaré la base de datos para confirmar que los datos enviados por el backend se guarden en el formato correcto
  • Progresaré en pequeños pasos
  • Escribiré muchos console.log para asegurarme de que entiendo cómo se comporta el código y las pruebas; y para ayudarme a identificar problemas
  • Si mi código no funciona, no escribiré más código. En su lugar, comenzaré a eliminar código hasta que funcione o simplemente volveré a un estado en el que todo aún funciona
  • Si una prueba no pasa, me aseguraré de que la funcionalidad probada funcione correctamente en la aplicación
  • Cuando pido ayuda en el canal Discord o Telegram del curso, o en otro lugar, formularé mis preguntas correctamente, ve aquí cómo pedir ayuda

Refactorizando pruebas

Actualmente, a nuestras pruebas les falta 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 en bloques describe.

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

const { test, after, beforeEach, describe } = require('node:test')
const assert = require('node:assert')
const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)

const helper = require('./test_helper')

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

describe('when there is initially some notes saved', () => {
  beforeEach(async () => {
    await Note.deleteMany({})
    await Note.insertMany(helper.initialNotes)
  })

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

    assert.strictEqual(response.body.length, 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)
    assert(contents.includes('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/)

      assert.deepStrictEqual(resultNote.body, 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 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()
      assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1)

      const contents = notesAtEnd.map(n => n.content)
      assert(contents.includes('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()

      assert.strictEqual(notesAtEnd.length, 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()

      assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1)

      const contents = notesAtEnd.map(r => r.content)
      assert(!contents.includes(noteToDelete.content))
    })
  })
})

after(async () => {
  await mongoose.connection.close()
})

La salida de las pruebas en la consola se agrupa de acuerdo con los bloques describe:

salida de node:test mostrando bloques describe agrupados

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.

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