Node JS TDD (Test Driven Development) Tutorial con MongoDB

En este tutorial vas a aprender a cómo aumentar tus conocimientos y habilidades de código creando una Api REST usando Node JS y Express aplicando TDD (Test Driven Development) paso a paso y desde cero como lo hace un desarrollador Senior.

Esto incluye habilidades de cómo crear Unit Tests (pruebas unitarias) e Integration Tests (pruebas de integración) usando Jest como principal herramienta de testing.

Partamos con lo más cercano a como sería en un proyecto del mundo real.

Vamos a trabajar con la siguiente historia de usuario:

Como comerciante, quiero registrar la información de mis productos en el sistema para que pueda consultarlo después.

Criterios de aceptación:

  • Guardar correctamente en una base de datos la información de un producto: Nombre, descripción y precio.
  • Validar que los datos del producto están completos antes de guardar. En caso de faltar un dato, retornar el mensaje "el valor [campo] es requerido".

Tener estos requisitos bien definidos antes de comenzar es crucial para trabajar con TDD de manera más efectiva.

Setup inicial del proyecto

Comenzamos creando un nuevo proyecto de Node yendo a la terminal y nos posicionamos donde queramos tener el proyecto.

En mi caso, he creado una carpeta products-backend y me he posicionado ahí.

Ahora ejecutamos npm init --yes.

El flag --yes le indica a npm que asigne los valores por default al package.json generado.

En seguida creamos un archivo .gitignore en la raíz de nuestro proyecto y dentro colocamos node_modules para que cuando usemos git, no haga track de esa carpeta.

Ahora a instalar las dependencias:

npm i express mongoose dotenv -S
  • Express es el mini framework a usar para la API.
  • Dotenv nos ayuda a declarar variables de entorno por medio de archivos con extensión .env.

Después instalamos las dependencias de desarrollo, aquellas que sólo usaremos en nuestra máquina local pero que no requerimos en un ambiente productivo:

npm i mongodb-memory-server jest supertest eslint prettier eslint-config-prettier eslint-plugin-jest eslint-plugin-prettier -D
  • Mongodb-memory-server lo vamos a usar para las pruebas de integración como veremos más adelante.
  • Jest como test runner, para crear mocks y assertions.
  • Supertest como herramienta para probar los endpoints de la api.
  • Eslint para hacer análisis estático de código y capturar los principales causantes de bugs: errores de typos, uso de variables no definidas, etc.
  • Prettier como herramienta para aplicar una guía de estilos en el código.
  • Eslint-config-prettier y eslint-plugin-prettier es para integrar la configuración de eslint y prettier de modo que no hayan conflictos entre ellos.

Usar eslint y prettier es esencial para tener un código de mayor calidad.

Te recomiendo que sigas los siguientes pasos si quieres tener un proyecto de Node más profesional.

En raíz del proyecto creamos dos archivos, un .eslintrc y un .prettierrc.

En mi caso, quedan como /products-backend/.estlintrc y /products-backend/.prettierrc.

Dentro de .eslintrc colocamos la siguiente configuración.

{
  "env": {
    "node": true,
    "commonjs": true,
    "es2021": true,
    "jest/globals": true
  },
  "extends": ["eslint:recommended", "plugin:prettier/recommended"],
  "plugins": ["prettier", "jest"],
  "parserOptions": {
    "ecmaVersion": 12
  },
  "rules": {}
}

Con esta configuración le indicamos a eslint que nuestro código va a ser del tipo commonjs, extendemos las reglas recomendadas de eslint y configuramos el plugin de prettier.

Ahora dentro de .prettierrc colocamos:

{
  "arrowParens": "always",
  "bracketSpacing": true,
  "embeddedLanguageFormatting": "auto",
  "insertPragma": false,
  "printWidth": 80,
  "proseWrap": "preserve",
  "requirePragma": false,
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "useTabs": false
}

Con esta configuración le indicamos a prettier cómo debe formatear nuestro código.

Por ejemplo, no usaremos punto y coma, esto es una preferencial personal pero en caso de que a ti te guste usarlos, está muy bien.

Lo que resta es agregar los scripts en el package.json para ejecutarlos ("test" es el que viene por default al iniciar el proyecto con npm).

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "lint": "eslint --ignore-path .gitignore .",
    "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|)\""
  },

En ambos scripts está el flag --ignore-path que sirve para indicar que ignoren los archivos que estén declarados en el archivo .gitignore.

El script lint va a aplicar las reglas configuradas de eslint y nos indicará si está todo bien en el código mientras que format va a aplicar las guías de estilos en los archivos .js.

Por último, lo mínimo necesario que requerimos para comenzar nuestra API es levantar el server.

Creo un nuevo archivo server.js en raíz del proyecto. Dentro inicializo una instancia de Express y levanto el server.

const express = require('express')

const app = express()

const port = 8080

app.listen(() => console.log(`listening on port ${port}`))

Ahora ejecuto en la terminal node server.js estando en la raíz del proyecto y tengo como output listening on port 8080.

¡Con esto ya podemos comenzar a desarrollar la api!

Cómo hacer la primera prueba con TDD

Recuerda que TDD consta de 3 fases:

  • RED: Comenzar con una prueba que falle.
  • GREEN: Hacer que la prueba pase con el mínimo esfuerzo.
  • REFACTOR: Aplicar buenas prácticas de desarrollo (SOLID, Clean Code, DRY, etc).

Te voy a compartir un tip de cómo puedes iniciar tu primera prueba en TDD con Node y Express.

En base a este criterio de aceptación:

Guardar correctamente en una base de datos la información de un producto: Nombre, descripción y precio.

Vamos a hacer la prueba más sencilla posible en la que ejecutaremos un endpoint y esperaremos una respuesta de 201 (stored) y mensaje con el nuevo producto registrado.

Ya que Node y Express son flexibles en cuanto a la estructura de archivos, nosotros somos libres de estructurar como deseemos.

De momento, voy a ir creando el código más simple posible y conforme avance en las pruebas y refactors, lo evolucionaré a un patrón MVC.

En cuanto a las pruebas, voy a crear una carpeta __tests__ que jest reconoce y dentro colocaré un products.test.js.

Dentro voy a importar la dependencia supertest que usaremos para probar nuestra API.

Al ejecutar supertest necesitamos pasarle por parámetro la instancia de express que contiene los endpoints definidos, por lo que también voy a importar la constante app que definí en el archivo server.js.

const request = require('supertest')

const { app } = require('../server')

// aqui colocaremos las pruebas...

Para que pueda funcionar el require de app, necesito exportarlo de server.js, por lo que lo modifico como sigue:

const express = require('express')

const app = express()

const port = 8080

if (require.main === module) {
  app.listen(() => console.log(`listening on port ${port}`))
}

module.exports.app = app

Le he agregado lógica para que inicialice el server sólo si este archivo lo estoy ejecutando con Node del modo node server.js, de lo contrario, no entra al if y por lo tanto, no inicializa el server.

Esto es importante porque en mis pruebas quien va a inicializar el server es supertest.

Al final exporto la propiedad app igual a la constante app que es la instancia de express.

Ahora regresamos a nuestra prueba y definimos la estructura general como:

describe('POST /products', () => {
  test('should store a new product', async () => {
    // aqui haremos la primera prueba
  })
})

Ahora dentro del bloque test, ejecutaré supertest.

Ya que supertest soporta ejecutarlo en modo Promise (promesa), usaré async await para resolverla.

describe('POST /products', () => {
  test('should store a new product', async () => {
    await request(app)
      .post('/products')
      .set('Accept', 'application/json')
      .expect('Content-Type', /json/)
      .expect(201)
  })
})

Esta es mi primera versión de prueba para el endpoint /users que aún no he definido en mi api.

Para ejecutar la prueba, voy al package.json de nuevo y modifico el script de test como "test": "jest, quedando como:

"scripts": {
    "test": "jest",
    "lint": "eslint --ignore-path .gitignore .",
    "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|)\""
  },

En la terminal ejecuto npm run test -- --watch para que se quede en modo observación y compruebo que mi prueba falle (fase red).

Nota 1: los guiones medios sirven para pasar parámetros al script, entonces -- --watch significa que le paso --watch a jest, por lo que al final ejecuta: jest "--watch".

Nota 2: para que funcione --watch, debemos tener inicializado git (lo puedes inicializar con git init en la terminal).

Obtenemos como resultado:

 FAIL  __tests__/products.test.js
  POST /products
    ✕ should store a new product (63 ms)

  ● POST /products › should store a new product

    expected "Content-Type" matching /json/, got "text/html; charset=utf-8"

       8 |       .post('/products')
       9 |       .set('Accept', 'application/json')
    > 10 |       .expect('Content-Type', /json/)
         |        ^
      11 |       .expect(201)
      12 |   })
      13 | })

Nos dice que falla porque esperaba una respuesta de tipo JSON y estamos retornando una con HTML que es la respuesta default de express al ejecutar un endpoint no definido.

Ahora pasamos a hacer lo mínimo para resolver este error.

En server.js, agregamos la definición del endpoint /products y retornamos una respuesta tipo JSON que es lo mínimo necesario para que la prueba continue.

app.post('/products', (req, res) => {
  res.json()
})

Quedando como:

const express = require('express')

const app = express()

const port = 8080

app.post('/products', (req, res) => {
  res.json()
})

if (require.main === module) {
  app.listen(() => console.log(`listening on port ${port}`))
}

module.exports.app = app

Recuerda que necesitamos hacer lo MÍNIMO necesario para que las pruebas pasen, más adelante haremos los refactors necesarios como separación de código.

Ahora la prueba falla por el estatus code:

expected 201 "Created", got 200 "OK"

       9 |       .set('Accept', 'application/json')
      10 |       .expect('Content-Type', /json/)
    > 11 |       .expect(201)
         |        ^
      12 |   })
      13 | })
      14 |

Entonces modifico la respuesta de mi endpoint:

app.post('/products', (req, res) => {
  res.status(201).json()
})

Y mi prueba ya pasa:

 PASS  __tests__/products.test.js
  POST /products
    ✓ should store a new product (25 ms)

Pero apenas iniciamos.

Ahora evoluciono mi prueba mandando parámetros en el body del POST y valido que la respuesta es la del nuevo producto guardado.

Ya que guardar el producto involucra hacer la conexión con MongoDB, voy a aplicar un fake, es decir, de momento voy a retornar un objeto falso como si fuera el guardado realmente.

¿Por qué? porque hago el MÍNIMO esfuerzo para que mis pruebas pasen y conforme agregue más pruebas, me irán guiando en el diseño de mi solución con lo que realmente necesito.

Entonces mi prueba queda ahora como:

test('should store a new product', async () => {
  const response = await request(app)
    .post('/products')
    .send({
      name: 'my product',
      description: 'this is a test',
      price: '100',
    })
    .set('Accept', 'application/json')
    .expect('Content-Type', /json/)
    .expect(201)

  expect(response.body).toEqual({
    name: 'my product',
    description: 'this is a test',
    price: '100',
    _id: 'abc',
  })
})

Mi prueba falla naturalmente:

 FAIL  __tests__/products.test.js
  POST /products
    ✕ should store a new product (11 ms)

  ● POST /products › should store a new product

    expect(received).toEqual(expected) // deep equality

    Expected: {"_id": "abc", "description": "this is a test", "name": "my product", "price": "100"}

Para que pase, entonces retornaré un response de acuerdo al body de mi request y un _id extra, lo que incuye que mi API soporte recibir parámetros de tipo JSON en el body.

app.use(express.json())

app.post('/products', (req, res) => {
  const { name, description, price } = req.body

  const _id = 'abc'

  res.status(201).json({
    name,
    description,
    price,
    _id,
  })
})

Con estos cambios mi prueba vuelve a pasar:

 PASS  __tests__/products.test.js
  POST /products
    ✓ should store a new product (28 ms)

Test Suites: 1 passed, 1 total

Ya hemos logrado pasar de RED a GREEN con una primera implementación donde usamos un Fake.

Ahora viene el refactor de código.

Detecto que mi archivo server.js tiene mucha lógica: la inicialización de express, definición y lógica de manejo del endpoint, iniciar el server.

Primero voy a separar la inicialzación del server y toda la demás lógica de express.

Creo un nuevo archivo app.js y dentro coloco:

const express = require('express')

const app = express()

app.use(express.json())

app.post('/products', (req, res) => {
  const { name, description, price } = req.body

  const _id = 'abc'

  res.status(201).json({
    name,
    description,
    price,
    _id,
  })
})

module.exports.app = app

Ahora el archivo server.js queda como:

const { app } = require('./app')

const port = 8080

if (require.main === module) {
  app.listen(() => console.log(`listening on port ${port}`))
}

module.exports.app = app

Nota que ahora importamos app de app.js.

Guardo y valido que mis pruebas sigan pasando.

El siguente refactor es separar la definición de endpoints y la inicialización de express.

Entonces creo un nuevo archivo routes.js y dentro coloco:

const express = require('express')

const router = express.Router()

router.post('/products', (req, res) => {
  const { name, description, price } = req.body

  const _id = 'abc'

  res.status(201).json({
    name,
    description,
    price,
    _id,
  })
})

module.exports.router = router

Nota que creamos una instancia de express.Router la cuál nos ayuda a definir endpoints.

Importo router en app.js y le indico a express que usaremos los endpoints que tenga definidos:

const express = require('express')

const { router } = require('./routes')

const app = express()

app.use(express.json())

app.use(router)

module.exports.app = app

Verifico que las pruebas sigan pasando.

Un último refactor de momento va a ser en la misma prueba.

Nota que estamos repitiendo los datos del producto cuando mandamos el request y cuando hacemos el expect.

Lo malo de esto es que si mi producto evoluciona a tener diferentes propiedades, van a haber dos lugares distintos donde tenga que actualizar mi prueba.

Para que mi prueba sea más fácil de mantener, voy a aplicar el patrón builder.

El patrón builder es un patrón de diseño de tipo creacional que resuelve el problema de cómo crear instancias de objetos.

Originalmente, este patrón es usado con POO (Programación Orientada a Objetos), pero nosotros haremos una versión funcional:

const Builder = {
  product: ({ name = 'my product', description = 'this is a test', price = '100' } = {}) => ({
    name,
    description,
    price,
  }),
}

No es más que un objeto con la propiedad product que es una función que nos retorna a su vez un objeto con las propiedades esperadas de un producto.

Recibe por parámetro un objeto con propiedades por default, de modo que si queremos especificar un nombre en particular, lo podemos hacer.

Mi prueba queda como:

const Builder = {
  product: ({ name = 'my product', description = 'this is a test', price = '100' } = {}) => ({
    name,
    description,
    price,
  }),
}

describe('POST /products', () => {
  test('should store a new product', async () => {
    const product = Builder.product()

    const response = await request(app)
      .post('/products')
      .send(product)
      .set('Accept', 'application/json')
      .expect('Content-Type', /json/)
      .expect(201)

    expect(response.body).toEqual({
      ...product,
      _id: 'abc',
    })
  })
})

Ahora voy a hacer un refactor para separar el Builder en un archivo independiente de manera que pueda exportar el builder en donde quiera.

En mi caso, en raíz creo una carpeta builders y dentro un archivo product-builder.js.

// builders/product-builder.js
module.exports.Builder = {
  product: ({ name = 'my product', description = 'this is a test', price = '100' } = {}) => ({
    name,
    description,
    price,
  }),
}

Y mi prueba hago el require

const { Builder } = require('../builders/product-builder.js')

Con esto hemos concuido la primera iteración del ciclo RED - GREEN - REFACTOr de TDD.

Unit Testing aplicando TDD en Node

Hasta ahora mi implementación devuelve una respuesta hardcodeada.

Me interesa guardar el producto realmente en una base de datos de Mongo.

¿Cómo puedo hacerlo con TDD?

Pienso que la mejor manera de iniciar es hacer un mock de la lógica para manejar las operaciones a la base de datos.

Es decir, tener las operaciones CRUD necesarias encapsuladas en funciones que pueda exportar de un archivo, de modo que en mis pruebas pueda hacerles un mock y manejarlas como neceite.

En mi prueba voy a asumir que va a existir un archivo service/product-service.js donde tenga de inicio una función llamada store (para hacer el Create del CRUD) y con Jest la usaré como mock.

Para crear un mock de una implementación en Jest se usa jest.mock(url/to/my/implementation), en mi caso queda como: jest.mock('../services/product-service.js') en mi prueba.

Con sólo este cambio, mi prueba ahora falla.

 FAIL  __tests__/products.test.js
  ● Test suite failed to run

    Cannot find module '../services/product-service.js' from '__tests__/products.test.js'

Entonces paso a crear el archivo y de una vez exporto la función.

// services/product-service.js
module.exports.store = async () => {}

Estoy asumiendo que la función store va a guardar mi producto en base de datos.

Por lo tanto, agrego una prueba nueva donde voy a validar que esta función se esté ejecutando.

const request = require('supertest')

const { app } = require('../server')
const { Builder } = require('../builders/product-builder.js')
const { store } = require('../services/product-service')

jest.mock('../services/product-service.js')

beforeEach(() => {
  store.mockReset()
})

describe('POST /products', () => {
  test('should store a new product', async () => {
    // code...
  })

  test('should execute store function', async () => {
    await request(app)
      .post('/products')
      .send(Builder.product())
      .set('Accept', 'application/json')
      .expect('Content-Type', /json/)
      .expect(201)

    expect(store).toHaveBeenCalled()
  })
})

Nota: en beforeEach estoy reseteando el estado del mock store para garantizar que en cada test es un mock con un estado limpio.

Mi prueba sigue fallando:

 FAIL  __tests__/products.test.js
  POST /products
    ✓ should store a new product (53 ms)
    ✕ should execute store function (6 ms)

  ● POST /products › should execute store function

    expect(jest.fn()).toHaveBeenCalled()

    Expected number of calls: >= 1
    Received number of calls:    0

Para hacer que pase, en mi implementación voy a importar la función store y la voy a ejecutar.

const express = require('express')

const { store } = require('./services/product-service')

const router = express.Router()

router.post('/products', async (req, res) => {
  const { name, description, price } = req.body

  const _id = 'abc'

  await store()

  res.status(201).json({
    name,
    description,
    price,
    _id,
  })
})

module.exports.router = router

Mi prueba ya pasa exitosamente.

Pero el hecho de ejecutar la función store no me dice si realmente estoy guardando un producto.

Por ello, modifico mi prueba para validar que esté siendo ejecutada con un parámetro igual a un objeto con las propiedades del producto.

En mi test ya tengo la constante product, por lo que lo puedo utilizar como sigue.

test('should execute store function', async () => {
  const product = Builder.product()

  await request(app)
    .post('/products')
    .send(product)
    .set('Accept', 'application/json')
    .expect('Content-Type', /json/)
    .expect(201)

  expect(store).toHaveBeenCalledWith(product)
})

Mi prueba falla:

 FAIL  __tests__/products.test.js
  POST /products
    ✓ should store a new product (50 ms)
    ✕ should execute store function (13 ms)

  ● POST /products › should execute store function

    expect(jest.fn()).toHaveBeenCalledWith(...expected)

    Expected: {"description": "this is a test", "name": "my product", "price": "100"}
    Received: called with 0 arguments

    Number of calls: 1

Entonces modifico mi implementación.

router.post('/products', async (req, res) => {
  const { name, description, price } = req.body

  const _id = 'abc'

  await store({ name, description, price })

  res.status(201).json({
    name,
    description,
    price,
    _id,
  })
})

Y mi prueba pasa exitosamente.

Lo que acabo de hacer puede ser considerado un unit test porque estoy probando mi endpoint como si fuese una función aislada de services.

Nota que mi función store aún no hace nada, pero en mi prueba actual estoy asumiendo que el producto se guarda en la base de datos por el hecho de que se está ejecutando dicha función.

Para solucionar lo anterior tenemos al menos dos opciones:

1 - Crear una prueba unitaria nueva para services/product-service.js y validar aisladamente las operaciones de guardado haciendo mock de mongoose.

2 - Crear una prueba de integración para mi endpoint POST /products donde valide el guardado en base de datos como parte de la prueba del endpoint en su totalidad.

Para decidir qué es mejor, vamos a ver los props y contras de cada una.

Unit tests VS Integration tests

Hemos heredado la idea de que los desarrolladores debemos enfocarnos más en hacer unit tests porque son las pruebas más rápidas y que menos esfuerzo implican.

Pero conforme ha ido evolucionando el ecosistema de testing (principalmente en Javascritp), hoy en día podemos crear pruebas de integración e incluso E2E con un tiempo y costo parecidos a que si hiciéramos pruebas unitarias.

Si el propósito de tener pruebas automatizadas es tener seguridad de que nuestro código funciona como esperamos que funcione con el menor esfuerzo posible, probablemente nos convenga hacer más pruebas de integración.

La clave es crear pruebas que no estén acopladas a los detalles de implementación.

Un detalle de implementación es que la prueba conozca un aspecto de cómo funciona internamente aquello que está probando.

En nuestro caso, nuestro unit test recién creado está acoplado al detalle de que se debe ejecutar una función store.

La desventaja de esto es que mi prueba será obsoleta en el momento en que haga un refactor o cambie mi implementación.

Pero si hago una prueba de integración donde pruebe la funcionalidad sin importarme los detalles de implementación, entonces tengo mayor libertad de hacer refacros o cambios sin que mis pruebas se vean afectadas.

Al contrario, mis pruebas de integración me dirán si los cambios que hago rompen o no con lo que esperamos que haga mi api.

Al fin y al cabo, al usuario final no le importa si usamos Mongo o lo que sea, le importa que pueda usar nuestra API.

Por otra parte, al tener la prueba de integración, estaremos cubriendo al mismo tiempo el funcionamiento de operaciones a base de datos (y todo lo que implique) siendo algo redundante tener pruebas unitarias sobre esa parte.

Hay una excepción aquí: Si estuviera desarrollando software que no vaya a tener modificaciones impactantes, entonces podría valer la pena hacer muchos unit tests.

Pero lo natural es que el software siempre evolucione.

Integration Testing aplicando TDD en Node

Ahora necesito crear una prueba de integración donde ejecute un endpoint y me guarde un producto sin usar mocks.

Esta vez quiero validar que realmente estoy guardando un producto en una base de datos real.

Para este caso, tengo la opción de hacer la implementación obvia (real) de iniciar una conexión a base de datos, o un fake y triangulación para poco a poco ir dando con la solución.

Esta parte es interesante porque la idea que tengo es poder crear una función para conectarse a una base de datos que me sirva para las pruebas y para la implementación real.

Aquí es donde entra mongodb-memory-server, esta herramienta nos va a permitir crear una base de datos real de mongo en memoria durante mis pruebas, y al terminarlas, cerraré la conexión y destruiré la base de datos.

La lógica va a ser: Si estoy ejecutando una prueba, crea la base de datos temporal y conéctate a ella por medio de su uri.

Si no es una prueba, conéctate a la base de datos que te indique por medio de su uri también.

Nota: uri es una cadena que se compone mínimamente por el host y el puerto y se usa para conectarse a una base de datos.

En ambos casos necesito la uri, por lo que es buena idea pasar ese valor por parámetro.

Por lo anterior, voy a hacer la implementación obvia porque sé cómo debe de hacerse.

Para comenzar, necesito indicar a mi prueba que debe hacer una conexión a una base de datos antes de ejecutar las pruebas.

De antemano, yo sé que aún no existe esa lógica, pero yo proyecto que tendré una función connect y getUri.

const { connect, getUri } = require('../db')

Y agrego un beforeAll para ejecutar la conexión.

beforeAll(async () => {
  const uri = await getUri()
  await connect({ uri })
})

Naturalmente, me da un error porque ni siquiera he creado el archivo.

Creo el archivo db/index.js y comienzo por importar las dependencias:

const mongoose = require('mongoose')
const { MongoMemoryServer } = require('mongodb-memory-server')

mongoose.Promise = Promise

const mongoServer = new MongoMemoryServer()

En seguida, exporto la función getUri:

module.exports.getUri = async () => {
  if (process.env.NODE_ENV === 'test') {
    return mongoServer.getUri()
  }

  return process.env.DB_URI
}

Aquí está la parte crítica.

Si process.env.NODE_ENV === 'test' es true, significa que estoy ejecutando una prueba en virtud de que Jest actualiza process.env.NODE_ENV igual a test automáticamente.

Dentro obtengo la uri necesaria para crear una conexión por medio de la función mongoServer.getUri().

Si no es una prueba, entonces se brinca el if y retorna lo que tenga la variable de entorno process.env.DB_URI, que más adelante haré que tenga un valor por medio de un archivo .env.

Ahora exporto la función connect:

module.exports.connect = async ({ uri }) => {
  const mongooseOpts = {
    useUnifiedTopology: true,
    useNewUrlParser: true,
  }

  await mongoose.connect(uri, mongooseOpts)

  mongoose.connection.once('open', () => {
    console.log(`MongoDB successfully connected to ${uri}`)
  })
}

A esta función no le importa si estoy en pruebas o no ya que sólo va a recibir la uri a la cuál conectarse.

Perfecto, con estos cambios mis pruebas pasan pero ahora tengo un warning:

 PASS  __tests__/products.test.js
  POST /products
    ✓ should store a new product (42 ms)
    ✓ should execute store function (6 ms)

A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks.

Esto es debido a que ahora necesito cerrar y destruir la base de datos temporal.

En mi prueba importo una nueva función closeDb y la ejecuto en un afterAll:

const { connect, getUri, closeDb } = require('../db')

// resto del código...

afterAll(async () => {
  await closeDb()
})

Naturalmente, mi prueba falla de nuevo porque aún no existe esa función. Procedo a su creación.

// db/index.js
module.exports.closeDb = async () => {
  await mongoose.disconnect()

  if (process.env.NODE_ENV === 'test') {
    await mongoServer.stop()
  }
}

Voy a modificar mi prueba dejando de hacer mock a la función store porque ahora pretendo guardar realmente en una base de datos real.

Por lo tanto, el segundo test queda obsoleto por lo que lo borro también.

Mi prueba final queda como:

const request = require('supertest')

const { app } = require('../server')
const { connect, getUri, closeDb } = require('../db')
const { Builder } = require('../builders/product-builder.js')

beforeAll(async () => {
  const uri = await getUri()
  await connect({ uri })
})

afterAll(async () => {
  await closeDb()
})

describe('POST /products', () => {
  test('should store a new product', async () => {
    const product = Builder.product()

    const response = await request(app)
      .post('/products')
      .send(product)
      .set('Accept', 'application/json')
      .expect('Content-Type', /json/)
      .expect(201)

    expect(response.body).toEqual({
      ...product,
      _id: 'abc',
    })
  })
})

Verifico que siga pasando.

Ya que usaré la implementación real de guardado en base de datos, el _id será generado por mongo cuando inserte un documento nuevo, por lo que cambio mi prueba como sigue.

test('should store a new product', async () => {
  const product = Builder.product()

  const response = await request(app)
    .post('/products')
    .send(product)
    .set('Accept', 'application/json')
    .expect('Content-Type', /json/)
    .expect(201)

  const { _id, ...productStored } = response.body

  expect(productStored).toEqual(product)
  expect(_id).toBeTruthy()
})

Lo que hago es destructurar el response.body para extraer el _id y el resto de las propiedades (el ...productStored).

Ahora comparo que productStored sea igual a product que es lo que mando por POST al endpoint.

Ahora voy a hacer la implementación real de la función store.

const mongoose = require('mongoose')

const productSchema = mongoose.Schema({
  name: String,
  description: String,
  price: String,
})

const Product = mongoose.model('products', productSchema)

module.exports.store = async ({ name, description, price }) => {
  const product = new Product({
    name,
    description,
    price,
  })
  await product.save()
  return product
}

En este archivo defino el modelo de mongoose y en la función store hago una instancia del modelo y hago el guardado en Mongo.

Verifico que mis pruebas sigan pasando exitosamente.

Ahora mi prueba de integración está funcionando correctamente para un endpoint que hace un guardado en base de datos. Hermoso.

Con esto hemos logrado pasar de una prueba unitaria, a una prueba de integración exitosamente.

En mi caso, decidí reemplazarla porque mi prueba unitaria iba a quedar obsoleta ya que mi nueva prueba ya valida lo mismo pero con base de datos.

Estructura de Node JS con Express aplicando MVC

Ha llegado el momento de experimentar la ventaja de tener pruebas automatizadas haciendo un refactor grande en mi api: cambiar su estructura a MVC.

En el caso de nuestra api, tendremos modelos y controladores únicamente debido a que no manejamos vistas.

En todo momento hay que tener las pruebas corriendo y hacer cambios pequeños para validar que sigan pasando.

Si algo deja de pasar, sabremos que fue por el último cambio realizado.

Primero haré la parte de modelo.

// models/product-model.js
const mongoose = require('mongoose')

const productSchema = mongoose.Schema({
  name: String,
  description: String,
  price: String,
})

module.exports = mongoose.model('products', productSchema)

Y mi service ahora queda como:

// services/product-service.js
const mongoose = require('mongoose')

const productSchema = mongoose.Schema({
  name: String,
  description: String,
  price: String,
})

module.exports = mongoose.model('products', productSchema)

Mis pruebas siguen pasando.

Ahora voy a desacoplar las rutas y sus handlers creando los controladores.

// controllers/product-controller.js

const { store } = require('../services/product-service')

module.exports.createProduct = async (req, res) => {
  const { name, description, price } = req.body

  const _id = 'abc'

  await store({ name, description, price })

  res.status(201).json({
    name,
    description,
    price,
    _id,
  })
}

Y mi archivo de rutas queda como:

const express = require('express')

const { createProduct } = require('./controllers/product-controller')

const router = express.Router()

router.post('/products', createProduct)

module.exports.router = router

Veo que mis tests siguen pasando correctamente.

Y con esto ya tengo mi api funcionando correctamente, con sus pruebas automatizadas y una estructura que me permite escalarla mejor.

Ahora te puedes estar preguntando: ¿Podría crear esta estructura de archivos desde un inicio si uso TDD?

La respuesta es que sí podrías, si tienes claro el diseño.

En este caso yo ya lo tenía claro, pero quise empezar con las pruebas con un diseño no definido para poder ilustrar cómo puede ir evolucionando tu software conforme avances.

En desarrollos donde no tengas muy claro cómo estructurar, puedes proceder como lo hice en este post.

¡Listo! Con esto hemos terminado nuestro ejemplo. Sólo fue un endpoint POST muy sencillo, pero con esto ya tienes una idea de cómo proceder para crear más endpoints.

¡Espero que te haya servido este post!