logocloudstic

Resumen

El proceso de testing es una etapa fundamental en el desarrollo de cualquier proyecto serio. Permite dar un feedback inmediato al equipo de desarrollo y hace tener más confianza en la aplicación y durante los procesos de refactorización. NestJS hace el proceso de desarrollo de tests sea más sencillo integrando Jest y Supertest sin configuración al crear un proyecto nuevo. Además, el uso del sistema de inyección de dependencias de NestJS facilita el uso de mocks. En este tutorial se presenta el desarrollo de tests unitarios para controladores y servicios, y el desarrollo de tests de integración, todo sobre ejemplos en NestJS de una API de reserva de espacios.

Objetivos
  • Introducir el proceso de testing en backend.

  • Introducir el uso de Jest y Supertest como frameworks de testing.

  • Crear tests unitarios y de integración en aplicaciones NestJS.

  • Conocer la importancia de la cobertura de tests.

  • Realizar los tests unitarios de controladores y servicios en una API REST.

  • Utilizar mocks de servicios y de repositorios para la realización de tests unitarios.

  • Usar mocks de función y mocks de clases.

  • Realizar tests de integración.

1. Introducción

El testing es un proceso fundamental y necesario en cualquier proyecto software serio. Esto obedece a que cualquier proyecto en producción necesita de un proceso de testing antes de pasar a producción. En este sentido, los tests permiten que se tenga más confianza en la aplicación y previenen que al extender el código, o al modificarlo para corregir un fallo, se provoque un error en otra parte del proyecto. Además, en los procesos de refactorización el testing juega un papel fundamental comprobando que el código refactorizado sigue funcionando correctamente.

Los tests se ejecutarán de forma automatizada en forma de suites ofreciendo un feedback continuo y sencillo durante todo el proceso de desarrollo. Sin esa ejecución automatizada de tests, tendríamos que contar con grupos de personas dedicados al tedioso proceso, y no exento de errores humanos, de volver a probar todo el código tras una modificación. Disponer de este proceso automatizado de testing y que genere sus resultados en cuestión de segundos es algo más que interesante.

Si a todo esto le añadimos la posibilidad de reproducción de casos complejos (fallos a ciertas horas, desde ciertos lugares, …​) y su uso en sistemas de CI/CD, nuestro proyecto se verá enriquecido con un proceso en el que los tests se ejecutan tras cada actualización del repositorio. So los tests pasan correctamente se generaría una nueva versión en producción y se podría desplegar de forma automática. Como se puede observar, este proceso encaja perfectamente y va de la mano de las prácticas de desarrollo ágil.

A la hora de automatizar el proceso de testing hay un concepto clave a tener en cuenta, conocido como la pirámide de tests. Se trata de una metáfora gráfica para ayudarnos a entender que hay diferentes capas o niveles de testing.

  • Tests unitarios. Son código que ayuda a asegurar que las partes de las aplicaciones funcionan de la forma esperada. La unidad testeada puede ser una función, una clase, un módulo. Deben ser independientes unos de otros. Para una entrada, el test unitario comprueba el resultado. No contactan con el mundo exterior

  • Tests de integración. Aquí comprobamos la integración de los controladores con los servicios, la integración de nuestro sistema con algo de infraestructura (p.e. bases de datos, archivos, E/S, …​). Este tipo de tests pueden hacer llamadas a servicios externos. Las pruebas de integración normalmente cubren la prueba de un sistema (p.e. backend) aislándolo del resto.

  • Tests end to end. También conocidas como pruebas funcionales, simulan condiciones reales. Se ejecutarían en un navegador (o similar) y cubren todos los sistemas funcionando juntos (p.e. frontend y backend). Simulan a un usuario en la aplicación (escribiendo, haciendo clics, …​)

piramide testing

La pirámide de tests, además de representar el coste y la velocidad de ejecucuión de los tests, también refleja que deberíamos escribir más tests de los simples (unitarios) y menos tests de los complejos (end-to-end).

Para más información sobre el proceso de testing y sus tipos, consulta The Practical Test Pyramid en martinFowler.com

Nuestras herramientas de Testing

En UAL STIC, para nuestro trabajo de testing de aplicaciones con tecnología JavaScript/TypeScript se proponen las herramientas de testing siguientes:

  • Jest para tests unitarios en backend (pruebas de objetos de dominio, controladores y servicios) así como en frontend.

  • Supertest para tests de integración en backend y frontend. Permite hacer las pruebas de llamadas HTTP.

  • Cypress para pruebas e2e que simulen las acciones de los usuarios.

El código de los tests tiene que ser fácil de mantener y tiene que centrarse en el resultado del método probado. Después de crear un test nos debemos preguntar lo siguiente: si un día se refactoriza el método probado (sin cambiar su resultado), ¿tendré que cambiar el test? Si la respuesta es sí, hay que modificar el test. Posiblemente en el test nos estemos centrando en detalles del proceso que no deberían de estar en el test.

2. Introducción al testing con la kata Fizz Buzz

Para introducirnos al mundo de testing lo haremos de la mano de la kata Fizz Buzz. Se trata de un ejemplo sencillo en que para números comprendidos entre 1 y 100:

  • Se devolverá Fizz si el número es múltiplo de 3.

  • Se devolverá Buzz si es múltiplo de 5.

  • Se devolverá Fizzbuzz si es múltiplo de 15.

  • En cualquier otro caso, se devolverá el propio número.

Comencemos creando el proyecto NestJS.

$ nest new fizzbuzz

Al crear un proyecto nuevo, NestJS instala las dependencias para testing y crea una suite de pruebas con un test de ejemplo para probar que la llamada a / devuelve Hello World!.

Comenzamos probando el código de ejemplo creado por NestJS.

$ cd fizzbuzz
$ npm run test
El resultado es el siguiente y nos informa que se han pasado los tests con éxito.

 PASS  src/app.controller.spec.ts
  AppController
    root
      ✓ should return "Hello World!" (14 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.162 s
Ran all test suites.

2.1. Pruebas en NestJS

Como acabamos de comentar, el CLI de NestJS instala las dependencias de testing y crea una suite de pruebas a modo de ejemplo al crear un proyecto nuevo. Y es que NestJS ayuda a que el proceso sea menos tedioso ofreciendo lo siguiente:

  • Realiza un scaffolding para tests en la aplicación. Cuando creamos controladores y servicios, el CLI de NestJS también crea su correspondiente archivo de testing para dichos controladores y servicios. Al crear el proyecto, el CLI de NestJS también crea un archivo de testing e2e para probar la llamada a la ruta raíz de la aplicación.

  • Integracion con Jest (desarrollado por Facebook y se usa con "cero configuración") y Supertest (para testing de peticiones HTTP). No obstante, se puede usar cualquier otro framework de testing.

  • Uso del sistema de inyección de dependencias de NestJS para facilitar el uso de mocks. Por ejemplo, esto nos va a poder permitir proporcionar un servicio mockeado al probar un controlador.

  • Jest se configura a través del archivo package.json determinando mediante expresiones regulares los archivos que se consideran tests (p.e. para que las pruebas estuviesen en cualquier archivo .spec.ts usaríamos "testRegex": ".*\\.spec\\.ts$").

2.2. El archivo de tests app.controller.spec.ts

Al crear un proyecto nuevo, el CLI de NestJS crea el archivo de testing app.controller.spec.ts como el siguiente.

EstructuraArchivoTest

En la figura vemos una característica muy interesante del testing en NestJS y es que permite la creación de módulos para el testing sobre la marcha (gracias a la inyección de dependencias). Esto permitirá configurar ad-hoc las dependencias necesarias para la ejecución de los tests. Y no se trata sólo de importar o usar módulos o providers previamente creados en la aplicación, sino que a la hora de configurar el módulo para la ejecución de los tests podemos mockear lo que queramos (servicios, repositorios para bases de datos, …​) sustituyendo la implementación original por un mock para el desarrollo de los tests. Esto lo veremos más adelante en Pruebas unitarias del controlador.

A continuación presentaremos las partes más significativas de ese archivo.

2.2.1. La función it

En Jest, los tests se implementan mediante funciones it (realmente, it es un alias de una función denominada test, y se pueden usar de forma indistinta). La función it toma 3 argumentos:

  • Nombre del test

  • Función con las expectativas

  • Timeout (opcional). El timeout predeterminado es de 5 segundos.

A continuación se muestra el test generado por NestJS en app.controller.spec.ts al crear el proyecto.

it( (1)
  'should return "Hello World!"', (2)
  () => { (3)
    expect(appController.getHello()).toBe('Hello World!'); (4)
  }
);
1 Definición del caso de prueba
2 Nombre del caso de prueba
3 Función de evaluación del test con la definición de las expectativas
4 expect se usa para comprobar un valor obtenido por una función matcher, como toBe.

El usar it en lugar de test, sumado a usar el nombre del test en condicional, hace que el test sea más legible: it should return "Hello World!.

2.2.2. Agrupación de tests mediante describe

Para tener un código de testing más limpio y organizado, los tests (it) se pueden incluir en una función describe. Esto da lugar a un código de testing agrupado en bloques describe, los cuales están formados por tests it. Además, los bloques describe se pueden anidar. A continuación se muestra una estructura de agrupación de tests en bloques describe.

  describe('root', () => { (1)
    it('should return "Hello World!"', () => { (2)
      expect(appController.getHello()).toBe('Hello World!');
    });

    it('should ....' () => { (3)
      // test code
    });
  });
1 describe como agregador de tests
2 Primer test del bloque
3 Un segundo test del bloque

2.2.3. La función beforeEach

La función beforeEach se ejecuta antes de que se ejecute cada test de la suite. Normalmente prepara una configuración que los tests necesitan para ejecutarse de forma independiente al resto de tests (p.e. prepara el contenido de la base de datos, configura un servicio para que devuelva unos datos concretos a los tests, …​)

Esta función forma parte del conjunto de funciones de preparación o limpieza del entorno de testing:

  • beforeAll se ejecuta una sola vez antes de todos los tests del bloque.

  • beforeEach se ejecuta antes de cada test del bloque.

  • afterEach se ejecuta después de cada test del bloque.

  • afterAll se ejecuta una sola vez después de todos los tests del bloque.

2.3. Implementación de la kata Fizz Buzz

Comenzamos creando un nuevo módulo, un servicio y un controlador para la kata.

nest g module fizzbuzz
nest g service fizzbuzz
nest g controller fizzbuzz

En el servicio fizzbuzz/fizzbuzz.service.ts crearemos un nuevo método denominado fizzbuzz que aceptará un argumento de tipo number. El servicio tiene la lógica siguiente para implementar la kata Fizz Buzz.

import { Injectable } from '@nestjs/common';

@Injectable()
export class FizzbuzzService { (1)
  fizzbuzz(number): any {
    if (number < 1 || number > 100) {
      return;
    }

    if (number % 15 === 0) {
      return 'FizzBuzz';
    }

    if (number % 3 === 0) {
      return 'Fizz';
    }

    if (number % 5 === 0) {
      return 'Buzz';
    }

    return number;
  }
}
1 Método que implementa la kata Fizz Buzz

Para el controlador fizzbuzz/fizzbuzz.controller.ts crearemos un endpoint que acepte un número como parámetro. Este endpoint llamará al método del servicio del paso anterior.

import { Controller, Get, Param } from '@nestjs/common';
import { FizzbuzzService } from './fizzbuzz.service';

@Controller('fizzbuzz')
export class FizzbuzzController {
  constructor(private fizzbuzzService: FizzbuzzService) {}

  @Get(':number') (1)
  fizzbuzz(@Param('number') number): any {
    return this.fizzbuzzService.fizzbuzz(number);
  }
}
1 Nueva ruta para la kata Fizz Buzz

Ahora podemos probar la kata con cualuier número:

2.4. Pruebas unitarias del servicio

El CLI de NestJS ha creado el archivo fizzbuzz/fizzbuzz.service.spec.ts para los tests del servicio generado. Los tests los añadiremos en el grupo describe existente. Se trata de definir los casos de prueba para los casos de testing de la kata (3, 5, 15, ninguno de ellos, fuera del rango 1-100)

import { Test, TestingModule } from '@nestjs/testing';
import { FizzbuzzService } from './fizzbuzz.service';

describe('FizzbuzzService', () => { (1)
  let service: FizzbuzzService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [FizzbuzzService],
    }).compile();

    service = module.get<FizzbuzzService>(FizzbuzzService);
  });

  it('should be defined', () => { (2)
    expect(service).toBeDefined();
  });

  it('should return Fizz when the number is multiple of 3', () => { (3)
    expect(service.fizzbuzz(3)).toBe('Fizz'); (4)
  });

  it('should return Buzz when the number is multiple of 5', () => {
    expect(service.fizzbuzz(5)).toBe('Buzz');
  });

  it('should return FizzBuzz when the number is multiple of 15', () => {
    expect(service.fizzbuzz(15)).toBe('FizzBuzz');
  });

  it('should return the number when then number is neither multiple of 3, 5 nor 15', () => {
    expect(service.fizzbuzz(2)).toBe(2);
  });

  it('should return nothing when the number is not between 1 and 100', () => { (5)
    expect(service.fizzbuzz(0)).toBe(undefined);
    expect(service.fizzbuzz(101)).toBe(undefined);
  });
});
1 Grupo de tests creados inicialmente por NestJS a modo de ejemplo para el servicio Fizzbuzz
2 Test inicial creado por NestJS
3 Cada test va en su función it (o test) y contiene un texto (realmente es el nombre del test) que permite entender claramente la intención del test.
4 Con expect indicamos lo que queremos probar y con toBe indicamos el valor esperado.
5 En este caso, quizá sería más apropiado crear dos tests separados para probar cada uno los de límites del rango no permitido (i.e. un test para comprobar que no se aceptan números menores que 1 y otro test para comprobar que no se aceptan números mayores que 100).
Estructura de un archivo de tests

Los tests pueden hacer 3 cosas:

  • Preparar el entorno (setup).

  • Llamar a algo (actuar) y verificar el comportamiento (assert o verificar).

  • Destruir lo construido.

En Jest esto lo vemos en los bloques:

  • beforeAll prepara el entorno antes de ejecutar las pruebas. Se ejecuta una vez al principio de los tests.

  • beforeEach prepara el entorno antes de ejecutar cada prueba. Se ejecuta una vez antes de cada test.

  • it o test definen un caso de test para cada prueba. En expect llamamos a la operación (proceso de actuación) y con los matchers (toBe, toEqual, toBeGreaterThan, toMatch, toContain, toThrow, …​) se verifica el test. Más información en la página de Comparadores (matchers) de Jest.

  • afterEach realiza una operación de destrucción o desmontaje del entorno después de ejecutar cada prueba. Se ejecuta una vez después de cada test.

  • afterAll destruye o desmonta el entorno tras finalizar todas las pruebas. Se ejecuta una vez al final de los tests.

Para ejecutar sólo los tests del servicio y no los de todo el proyecto, lanzaremos los tests en modo watch:

$ npm run test:watch

Se nos indicará el modo de uso para que elijamos uno:

Watch Usage
 › Press a to run all tests.
 › Press f to run only failed tests.
 › Press p to filter by a filename regex pattern. (1)
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.
1 Opción elegida para pasar los tests a los archivos indicados

Elegiremos p para indicar el nombre de archivo del servicio. No hace falta introducir el nombre entero. Basta con una parte del nombre que permita seleccionarlo (p.e. fizzbuzz.se)

Pattern Mode Usage
 › Press Esc to exit pattern mode.
 › Press Enter to filter by a filenames regex pattern.

 pattern › fizzbuzz.se (1)
1 Expresión que permite seleccionar al servicio a probar

Y este sería el resultado del proceso de testing:

 PASS  src/fizzbuzz/fizzbuzz.service.spec.ts
  FizzbuzzService
    ✓ should be defined (25 ms)
    ✓ should return Fizz when the number is multiple of 3 (6 ms)
    ✓ should return Buzz when the number is multiple of 5 (4 ms)
    ✓ should return FizzBuzz when the number is multiple of 15 (21 ms)
    ✓ should return the number when then number is neither multiple of 3, 5 nor 15 (3 ms)
    ✓ should return nothing when the number is not between 1 and 100 (5 ms)

Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        2.925 s, estimated 5 s
Mostrar los datos de cada test

De forma predeterminada, los resultados de ejecución de los tests se muestran de forma agregada si hay varias suites de tests, perdiéndose los datos de cada test individual. En ocasiones, esta información detallada de cada test puede ser útil. Para activarlo, basta con cambiar en package.json la entrada en scripts sustituyendo "test": "jest", por "test": "jest --verbose",.

....
   "scripts": {
    ....
    "test": "jest --verbose", (1)
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
....
1 Cambio realizado para mostrar los datos de cada test.

De esta forma, ahora el resultado al ejecutar npm run test será más detallado como se muestra a continuación:

 PASS  src/app.controller.spec.ts
  AppController
    root
      ✓ should return "Hello World!" (14 ms)

 PASS  src/fizzbuzz/fizzbuzz.controller.spec.ts
  FizzbuzzController
    ✓ should be defined (26 ms)

 PASS  src/fizzbuzz/fizzbuzz.service.spec.ts
  FizzbuzzService
    ✓ should be defined (21 ms)
    ✓ should return Fizz when the number is multiple of 3 (2 ms)
    ✓ should return Buzz when the number is multiple of 5 (2 ms)
    ✓ should return FizzBuzz when the number is multiple of 15 (2 ms)
    ✓ should return the number when then number is neither multiple of 3, 5 nor 15 (2 ms)
    ✓ should return nothing when the number is not between 1 and 100 (2 ms)

Test Suites: 3 passed, 3 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        2.575 s, estimated 3 s

2.5. Cobertura de tests

En el proceso de testing la cobertura de tests proporciona una medida muy interesante. Ofrece el porcentaje de código que está incluido en los tests, es decir, el porcentaje de código que se está probando. Esto es muy útil porque nos ayuda a dirigir los esfuerzos para crear tests para el código que aún está oculto a los tests y que puede ser una potencial fuente de errores.

Podemos conocer la cobertura de nuestros tests con:

$ npm run test:cov

Esto ejecutará los tests nos dará el porcentaje de código testado para cada archivo y a nivel global.

 PASS  src/fizzbuzz/fizzbuzz.controller.spec.ts
 PASS  src/app.controller.spec.ts
 PASS  src/fizzbuzz/fizzbuzz.service.spec.ts
-------------------------|---------|----------|---------|---------|-------------------
File                     | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------------|---------|----------|---------|---------|-------------------
All files                |   63.46 |      100 |   71.42 |    62.5 |
 src                     |      52 |      100 |      75 |   47.36 |
  app.controller.ts      |     100 |      100 |     100 |     100 |
  app.module.ts          |       0 |      100 |     100 |       0 | 1-11
  app.service.ts         |     100 |      100 |     100 |     100 |
  main.ts                |       0 |      100 |       0 |       0 | 1-8
 src/fizzbuzz            |   74.07 |      100 |   66.66 |   76.19 |
  fizzbuzz.controller.ts |    87.5 |      100 |      50 |   83.33 | 10
  fizzbuzz.module.ts     |       0 |      100 |     100 |       0 | 1-9
  fizzbuzz.service.ts    |     100 |      100 |     100 |     100 |
-------------------------|---------|----------|---------|---------|-------------------

Test Suites: 3 passed, 3 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        7.747 s, estimated 8 s
Ran all test suites.

Como resultado también se genera una carpeta coverage/lcov-report con ese mismo informe, pero en HTML. Aparece organizado de acuerdo con los carpetas que tengamos en la carpeta src.

coverage100

Si hace clic sobre src/fizzbuzz veremos su informe de cobertura. Vemos que está probado el 100% del código del servicio.

coverage100Fizzbuzz

Si ahora modificamos los tests de fizzbuzz/fizzbuzz.service.spec.ts y comentamos uno de ellos, por ejemplo el que probaba los múltiplos de 15, y volvemos a ejecutar la cobertura de tests con npm run test:cov, veremos que la cobertura de fizzbuzz/fizzbuzz.service.ts ha bajado de 100% a 92.3%.

coverageParcial

Si ahora hacemos clic sobre fizzbuzz/fizzbuzz.service.ts en el informe, nos llevará al archivo y nos marcará en rojo las líneas de código que no están tratadas (cubiertas) en ningún test. Como hemos comentado anteriormente, este resultado es muy importante porque nos puede guiar en el proceso de priorización de los próximos tests a desarrollar.

codigoNoProbado

Si anulamos los comentarios del test y volvemos a ejecutar la cobertura de tests todo volverá a estar como antes y ese código ya estará de nuevo cubierto por los tests.

¿Hace falta probarlo todo?

En el proceso de testing decidimos qué probar. Alguien podría decir de probarlo todo con una cobertura cercana al 100%. Sin embargo, no es necesario. Sólo hay que probar las partes más críticas. Puede que esté entre el 70%-90%. Normalmente probaremos

  • Servicios (si hay app.service.ts también)

  • Controladores (si hay app.controller.ts también)

  • No hace falta probar DTOs, constantes, entidades y módulos (los podemos excluir de la cobertura -ver Exclusión de archivos de la cobertura de tests)

2.5.1. Exclusión de archivos de la cobertura de tests

El porcentaje de cobertura de tests que devuelve el informe se obtiene teniendo en cuenta todos los archivos de código del proyecto. Sin embargo, es posible ignorar o excluir archivos del proceso de obtención de la cobertura. Esto se realiza indicando nombres de archivo o indicando un patrón en el elemento coveragePathIgnorePatterns del elemento jest en el archivo package.json.

Por ejemplo, si decidimos excluir del proceso de análisis de cobertura de tests los archivos de los módulos (p.e. app.module.ts, fizzbuzz.module.ts y otros módulos), así quedaría el elemento jest en package.json para excluir los archivos de módulo:

  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "coveragePathIgnorePatterns": [".module.ts"], (1)
    "testEnvironment": "node"
  }
1 Ignorar del proceso de cobertura los archivos cuyo nombre termine en .module.ts

Esto mejoraría el porcentaje de cobertura ya que se han retirado los archivos de módulo del proceso de cómputo de la cobertura, ya que bajaban la cobertura porque no tenían tests asociados. La figura siguiente ilustra la cobertura total. Se ha pasado de un 63.46% a un 84.61%. Esto en sí no es ni bueno ni malo, ni un objetivo en sí mismo. Es sólo estar informado que hay ciertos archivos que aceptamos no probar y que de no ser excluidos pueden estar datos erróneos de cobertura.

coverageAfterExcludingModules

En Anexo I. Plugin Coverage Gutters se presenta un plugin interesante para VSCode que permite monitorizar la cobertura de tests de cada archivo mientras se desarrolla.

2.6. Pruebas unitarias del controlador

La cobertura de tests realizada en el apartado anterior nos ha servido para determinar el grado de código que tenemos testado. Hemos visto que tenemos tests para el servicio que prueban el 100% del código de sus métodos. Sin embargo, si vemos la cobertura del controlador, vemos que el código del endpoint (método fizzbuzz) aún está sin probar, tal y como muestra la figura siguiente.

FizzBuzzControllerSinProbar

Esto nos sugiere que debemos introducir más tests unitarios en el controlador. Para ello, y como las pruebas unitarias han de ser eso, unitarias, y ejecutarse de forma aislada, la prueba del controlador no deberá apoyarse en el método ya implementado en su servicio. Esto nos lleva a la introducción de la técnica de mocking para el desarrollo de pruebas unitarias. Aquí veremos cómo mockear el servicio de Fizz Buzz para que la prueba del controlador sea independiente.

La técnica de mocking en un controlador básicamente va a consistir en dar una nueva implementación (el mock) de los servicios que usa, y usar dicha nueva implementación o mock para probar el controlador. Esto lo podemos llevar a cabo de dos formas: mockeando un método concreto del servicio mediante jest.spyOn o mockeando el servicio completo.

2.6.1. Mockeo mediante jest.spyOn

jest.spyOn nos permite crear una nueva implementación (mock) sobre un método existente de un objeto. Seguiremos este patrón

jest.spyOn(<<objeto>>, '<<metodo-existente>>')
    .mockImplementation(<<nueva-implementacion>>);

De esta forma, cada vez que se llame en el test al método mockeado, el método se ejecutará con la nueva implementación proporcionada en mockImplementation.

A continuación mockearemos para la prueba del controlador el método fizzbuzz del servicio de forma que devuelva siempre Fizz.

import { Test, TestingModule } from '@nestjs/testing';
import { FizzbuzzController } from './fizzbuzz.controller';
import { FizzbuzzService } from './fizzbuzz.service';

describe('FizzbuzzController', () => {
  let controller: FizzbuzzController;
  let service: FizzbuzzService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [FizzbuzzController],
      providers: [FizzbuzzService], (1)
    }).compile();

    controller = module.get<FizzbuzzController>(FizzbuzzController);
    service = module.get<FizzbuzzService>(FizzbuzzService); (2)
  });

  it('should return the correct Fizz Buzz word according the introduced number (Using spyOn)', () => { (3)
    const result = 'Fizz'; (4)

    jest.spyOn(service, 'fizzbuzz').mockImplementation(() => result); (5)

    expect(controller.fizzbuzz(3)).toBe(result); (6)
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});
1 Incorporación del servicio para poder usarlo desde el controlador
2 Creación de un objeto para el servicio
3 Declaración del test
4 Configuración del valor que esperamos
5 Mockear el método fizzbuzz del servicio creado para que siempre devuelva lo configurado en result
6 Ejecutar el método fizzbuzz del controlador y comprobar que el resultado es correcto

Como el método fizzbuzz ahora está mockeado en el test, la implementación que se usará es la propocionada. En este caso, siempre devuelve lo que hemos configurado en result (Fizz para este ejemplo).

A continuación se muestra el resultado de pasar los tests al controlador con npm run test:watch y pasándole fizzbuzz.co como patrón de archivo.

 PASS  src/fizzbuzz/fizzbuzz.controller.spec.ts
  FizzbuzzController
    ✓ should return the correct Fizz Buzz word according the introduced number (Using spyOn) (14 ms) (1)
    ✓ should be defined (3 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        4.621 s
Ran all test suites matching /fizzbuzz.co/i.

Watch Usage: Press w to show more.
1 Test pasado con éxito

Con el testing unitario del controlador se trata de probar si los métodos del controlador tienen algún tipo de error. Damos por hecho que el servicio funciona correctamente. Y la opción de hacer la prueba como una petición GET HTTP no procede porque cae en el ámbito de las Pruebas de integración.

2.6.2. Mockeado del servicio completo

Otra alternativa al mockeado de un método concreto de un servicio es el mockeado del servicio completo. Se trata entonces de mockear todos los métodos del servicio. Podremos hacerlo mockeando el servicio en la misma clase en la que se va a usar, o bien, mockearlo en una clase aparte, lo que permitirá su reutilización. Por sencillez, aquí lo mockearemos in situ y no en una clase aparte.

La forma de proceder se podría resumir así:

  1. Crear un objeto para el mock del servicio y que dicho objeto contenga la nueva implementación de cada uno de sus métodos. El mockeo se realizará mediante un objeto JSON formado por pares método-valor devuelto.

  2. Sustituir el servicio en la definición del módulo del test (normalmente en el Test.createTestingModule dentro del beforeEach) por el servicio mockeado.

Veamos cómo hacerlo.

import { Test, TestingModule } from '@nestjs/testing';
import { FizzbuzzController } from './fizzbuzz.controller';
import { FizzbuzzService } from './fizzbuzz.service';

describe('FizzbuzzController', () => {
  let controller: FizzbuzzController;
  let service: FizzbuzzService;

  let mockedFizzBuzzValue = 'Buzz'; (1)
  let mockFizzBuzzService = { (2)
    fizzbuzz: () => mockedFizzBuzzValue, (3)
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [FizzbuzzController],
      providers: [FizzbuzzService],
    })
      .overrideProvider(FizzbuzzService) (4)
      .useValue(mockFizzBuzzService) (5)
      .compile();

    controller = module.get<FizzbuzzController>(FizzbuzzController);
    service = module.get<FizzbuzzService>(FizzbuzzService);
  });

  it('should return the correct Fizz Buzz word according the introduced number (Using spyOn)', () => {
    const result = 'Fizz';

    const fizzbuzzSpy = jest.spyOn(service, 'fizzbuzz');
    fizzbuzzSpy.mockImplementation(() => result);

    expect(controller.fizzbuzz(3)).toBe(result);

    fizzbuzzSpy.mockRestore();
  });

  it('should return the correct Fizz Buzz word according the introduced number (Using mocking de servicios)', () => { (6)
    expect(controller.fizzbuzz(5)).toBe(mockedFizzBuzzValue); (7)
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});
1 Configuración del valor devuelto por el servicio mockeado
2 Objeto que va a representar al servicio mockeado
3 Mock del método fizzbuzz y su respuesta mockeada. Es un par método-valor devuelto
4 Servicio a mockear
5 Reemplazar el servicio por el objeto que tiene el mock del servicio
6 Definición del caso de prueba
7 Lanzar el método fizzbuzz del controlador y comprobar que devuelve el valor mockeado

Al igual que antes, cuando el controlador llama a su método fizzbuzz, éste llama al método del servicio, pero el controlador no sabe que el método está mockeado. Un engaño en toda regla.

trileros

Por tanto, con esta implementación, cada vez que se llame al método fizzbuzz éste devolverá la respuesta mockeada (Buzz) en este caso. Con esto habremos comprobado el funcionamiento del controlador en sí y de forma independiente del servicio. Sólo hacemos la prueba con un valor del servicio puesto que la validez del servicio con distintos valores cae en el ámbito de las pruebas unitarias del servicio, no en las del controlador.

En nuestro caso no vamos a notar la diferencia entre el mockeo con jest.spyOn y el mockeo del servicio completo porque el servicio de Fizz Buzz cuenta sólo con un método. En servicios con más métodos, el mockeo del servicio completo exige mockear todos los métodos, mientras que el mockeo con `jest.spyOn_ permite ser mñás finos y mockear un sólo método y dejar el resto del servicio inalterado.

Tras los cambios, se vuelven a pasar los tests y este es su resultado:

 PASS  src/fizzbuzz/fizzbuzz.controller.spec.ts
  FizzbuzzController
    ✓ should return the correct Fizz Buzz word according the introduced number (Using spyOn) (14 ms)
    ✓ should return the correct Fizz Buzz word according the introduced number (Using mocking de servicios) (3 ms) (1)
    ✓ should be defined (3 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        3.202 s, estimated 5 s
Ran all test suites matching /fizzbuzz.co/i.

Watch Usage: Press w to show more.
1 Test con el servicio mockeado al completo

El test con jest.spyOn sigue funcionando porque recordemos que él tiene su propia implementación del mock del método, independientemente de que se haya mockeado el servicio por completo.

Para finalizar, si ahora volvemos a hacer la cobertura de tests, el controlador ya aparece testado y la cobertura habrá subido. Las figuras siguientes lo ilustran.

ContollerCoverageTesting100
FullCoverageOnController

2.7. Pruebas de integración

En las pruebas unitarias comprobamos que partes pequeñas y aisladas del software funcionan según lo esperado. Se encargan de probar unidades sin dependencias o bien mockeando las dependencias para llevar a cabo los tests.

Sin embargo, las pruebas de integración verifican que varias unidades funcionan correctamente de forma conjunta (p.e. controladores con servicios). Las pruebas de integración prueban su comportamiento de forma conjunta y tratan de reducir al máximo el uso de mocks.

Veamos el caso de prueba de integración que genera el CLI de NestJS al crear el proyecto (tests/app.e2e-spec.ts).

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => { (1)
  let app: INestApplication;

  beforeEach(async () => { (2)
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/ (GET)', () => { (3)
    return request(app.getHttpServer()) (4)
      .get('/') (5)
      .expect(200) (6)
      .expect('Hello World!'); (7)
  });
});
1 Bloque de tests
2 Función de preparación del entorno de cada test creando de nuevo la aplicación
3 Test de un endpoint
4 Realización de llamada a la API
5 Ir a la ruta indicada
6 Código de estado HTTP esperado
7 Valor esperado

Ejecutamos los tests con

$ npm run test:e2e

Esto pasará los tests y devolverá lo siguiente:

PASS  test/app.e2e-spec.ts
  AppController (e2e)
    ✓ / (GET) (392 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.438 s, estimated 3 s
Ran all test suites.

Este test ha atacado directamente a la API a través del controlador de Fizz Buzz. Este ha usado el servicio y ha devuelto la respuesta a la petición realizada. Es decir, han intervenido tanto la aplicación, como el controlador de Fizz Buzz, como su servicio. Por eso es que recibe el nombre de prueba de integración, porque combina/integra a varias partes de la aplicación en un solo test.

NestJS usa Supertest para simular las llamadas HTTP.

En el archivo tests/jest-e2e.json se definen las opciones de Jest para las pruebas de integración.

{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$", (1)
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}
1 testRegex define una expresión regular para indicar los archivos que se considerarán sujetos a las pruebas de integración.

Una vez visto el ejemplo de base, veamos cómo hacer las pruebas del endpoint de la API de Fizz Buzz. Con esto automatizaremos la prueba de cada endpoint de la API. Para probarlo sobre Fizz Buzz, lo haremos creando un archivo test/fizzbuzz.e2e-spec.ts para los tests de integración de llamada al endpoint con los diferentes valores. Crearemos este archivo copiándolo desde test/app.e2e-spec.ts introduciendo los cambios siguientes:

Archivo test/app.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('FizzBuzz (e2e)', () => { (1)
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/fizzbuzz/3 (GET) should return Fizz', () => { (2)
    return request(app.getHttpServer()) (3)
      .get('/fizzbuzz/3') (4)
      .expect(200) (5)
      .expect('Fizz'); (6)
  });
});
1 Cambiamos la descripción del bloque describe
2 Caso de prueba de llamada al endpoint
3 Creación de un objeto HTTP para hacer las peticiones
4 Acceso a la ruta del endpoint
5 Código de estado HTTP esperado
6 Respuesta esperada

Si tuviéramos más endpoints crearíamos más funciones it, una para cada endopoint.

Si ahora volvemos a pasar los tests con npm run test:e2e vemos que se pasan las pruebas de app y de fizzbuzz, pero el resultado se muestra agregado y no incluye el resultado de cada uno de los casos de prueba

 PASS  test/app.e2e-spec.ts
 PASS  test/fizzbuzz.e2e-spec.ts

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        4.325 s
Ran all test suites.

Si queremos ver el resultado de cada uno de los casos de prueba dentro de cada suite, haremos el cambio siguiente sobre la configuración de Jest en el archivo package.json incluyendo la opción de --verbose en los tests de integración.

...
  "scripts": {
    ...
    "test:e2e": "jest --config ./test/jest-e2e.json --verbose" (1)
  },
...
<1> Incluimos la opción `--verbose` para que muestre los resultados individuales de los tests.

Si ahora volvemos a ejecutar los tests de integración con npm run test:e2e vemos que ya sí aparecen los tests de cada suite.

 PASS  test/fizzbuzz.e2e-spec.ts
  FizzBuzz (e2e)
    ✓ /fizzbuzz/3 (GET) should return Fizz (380 ms) (1)

 PASS  test/app.e2e-spec.ts
  AppController (e2e)
    ✓ / (GET) (377 ms) (2)

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.663 s, estimated 4 s
Ran all test suites.
1 Test de integración de FizzBuzz
2 Test de integración de app

3. Caso de uso. Reserva de espacios

Para ilustrar en este tutorial los tests unitarios y de integración sobre una API REST que interactúe con una bases de datos, así como el testing de controladores, servicios y uso de mocks, vamos a desarrollar un caso de uso sobre una API de solicitud de espacios. La API ofrecerá los endpoints para las operaciones básicas de crear una solicitud, obtener el listado de solicitudes, obtener una solicitud a partir de su id, modificar y eliminar una solicitud.

Para no complicar demasiado el ejemplo pero que también dé juego, de cada solicitud se guarda:

  • id: numérico

  • nombre: cadena

  • cargo: cadena

  • unidad: cadena

  • telefono: cadena

  • email: cadena

  • tipo: cadena

  • nombreActividad: cadena

  • start: fecha

  • end: fecha

  • dia: cadena (día de la semana)

  • horaInicio: numérico (sólo guardaremos las horas sin los minutos)

  • horaFin: numérico (sólo guardaremos las horas sin los minutos)

Partimos de un repositorio base disponible en GitHub (rama base) con el código incial de base para poder seguir este tutorial.

El proyecto clonado ya tiene definidos los controladores, servicios, DTOs, entidades así como los archivos de testing .

Para clonar la rama base, clonar el repositorio con este comando

$ git clone -b base https://github.com/ualmtorres/nestjs-espacios/tree/base

El proyecto utiliza SQLite como base de datos, incorpora autenticación JWT para los endpoints y usa Swagger OpenAPI. Tiene la estructura siguiente.

├── LICENSE.md
├── README.md
├── dev.sqlite
├── nest-cli.json
├── package-lock.json
├── package.json (1)
├── src
│   ├── app.controller.spec.ts (2)
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   ├── auth (3)
│   │   ├── auth.module.ts
│   │   └── jwt.strategy.ts
│   ├── config (4)
│   │   ├── configuration.ts
│   │   └── database-config.service.ts
│   ├── espacio (5)
│   │   ├── dto
│   │   │   ├── create-espacio.dto.ts
│   │   │   └── update-espacio.dto.ts
│   │   ├── entities
│   │   │   └── espacio.entity.ts
│   │   ├── espacio.controller.spec.ts (6)
│   │   ├── espacio.controller.ts
│   │   ├── espacio.module.ts
│   │   ├── espacio.service.spec.ts (7)
│   │   ├── espacio.service.ts
│   │   └── ponicode
│   ├── main.ts
│   ├── reserva (8)
│   │   ├── dto
│   │   │   ├── create-reserva.dto.ts
│   │   │   └── update-reserva.dto.ts
│   │   ├── entities
│   │   │   └── reserva.entity.ts
│   │   ├── reserva.controller.spec.ts (9)
│   │   ├── reserva.controller.ts
│   │   ├── reserva.module.ts
│   │   ├── reserva.service.spec.ts (10)
│   │   └── reserva.service.ts
│   └── solicitud (11)
│       ├── dto
│       │   ├── create-solicitud.dto.ts
│       │   └── update-solicitud.dto.ts
│       ├── entities
│       │   └── solicitud.entity.ts
│       ├── solicitud.controller.spec.ts (12)
│       ├── solicitud.controller.ts
│       ├── solicitud.module.ts
│       ├── solicitud.service.spec.ts (13)
│       └── solicitud.service.ts
├── test (14)
│   ├── app.e2e-spec.ts  (15)
│   └── jest-e2e.json (16)
├── tsconfig.build.json
└── tsconfig.json
1 Aquí se realiza la configuración de Jest
2 Archivo de pruebas de app.controller
3 Carpeta de configuración del módulo de autenticación JWT
4 Carpeta de configuración de la aplicación y de la base de datos
5 Carpeta de los objetos relativos a los espacios
6 Archivo de pruebas del controlador de espacios
7 Archivo de pruebas del servicio de espacios
8 Carpeta de los objetos relativos a las reservas
9 Archivo de pruebas del controlador de reservas
10 Archivo de pruebas del servicio de reservas
11 Carpeta de los objetos relativos a las solicitudes
12 Archivo de pruebas del controlador de solicitudes
13 Archivo de pruebas del servicio de solicitudes
14 Carpeta de configuración de los tests de integración
15 Archivo de test de integración inicial generado por el CLI de Nest
16 Archivo de connfiguración de Jest para los tests de integración

3.1. Primeros tests

Comenzamos lanzando los tests sobre el proyecto creado con el comando siguiente

$ npm run test

Tras unos instantes comprobamos que se han ejecutado 7 suites de tests, pero sólo una se ha ejecutado con éxito, la de src/app.controller.spec.ts. Sin embargo, ningún test de controlador:

  • solicitud.controller.spec.ts

  • espacio.controller.spec.ts

  • reserva.controller.spec.ts

ni de servicio:

  • solicitud.service.spec.ts

  • espacio.service.spec.ts

  • reserva.service.spec.ts

ha tenido éxito. En todos los casos nos indica que no está definido su provider.

A continuación veremos cómo resolver estos problemas y lo haremos desde el controlador hacia adentro. Es decir, primero haremos los tests unitarios del controlador y después los tests unitarios del servicio. Explicaremos este proceso sobre las solicitudes, dejando la parte de espacios y reservas para mostrar únicamente los tests, pero ya sin explicaciones, ya que serán análogas a las descritas para solicitudes.

Finalmente, dedicaremos una sección a realizar los tests de integración.

3.2. Tests del controlador de solicitudes

Los tests del controlador fallan porque mientras que en el arranque de la aplicación se cargan los módulos correctamente, al ejecutar los tests se utilizan módulos diferentes de los del entorno de ejecución/desarrollo. Y lo importante, en el entorno de testing inicialmente esos módulos no pueden resolver sus dependencias. Concretamente, lo que está ocurriendo es que el controlador no puede resolver en el entorno de pruebas su dependencia de SolicitudService

Extracto del archivo solicitud/solicitud.controller.ts:

...
@Controller('solicitud')
...
export class SolicitudController {
  constructor(private readonly solicitudService: SolicitudService) {}
 (1)
...
1 Dependencia del controlador respecto a SolicitudService

En el código siguiente del test del controlador, generado por el CLI de NestJS al generar el controlador, vemos que dentro de beforeEach se usa la clase Test y un método createTestingModule. Este método toma los mismos argumentos que se usan para crear un módulo (p.e. imports, providers, controllers …​). Tras definir el nuevo módulo (el de testing) y llamar al método compile se crea el módulo para testing con sus dependencias similar a los módulos creados para el entorno de ejecución.

Archivo src/solicitud/solicitud.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { SolicitudController } from './solicitud.controller';
import { SolicitudService } from './solicitud.service';

describe('SolicitudController', () => {
  let controller: SolicitudController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({ (1)
      controllers: [SolicitudController],
      providers: [SolicitudService], (2)
    }).compile();

    controller = module.get<SolicitudController>(SolicitudController); (3)
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});
1 Definición del módulo para el testing del controlador
2 Servicio a utilizar
3 Creación de una instancia del controller

3.2.1. Primer paso. Evitar que falle el test

Seguiremos un enfoque progresivo para conseguir que nuestros tests funcionen. Se trata de ayudar a que en primer lugar desaparezcan los errores de las pruebas del controlador. Posteriormente, se irán refinando los tests.

El test del controlador falla porque el controlador no es capaz de resolver sus dependencias. Lo que haremos es sustituir el servicio original por un servicio de uso exclusivo en testing. Con esto, conseguiremos probar únicamente el controlador, aislándolo del servicio, que es la premisa de los tests unitarios: probar sólo una cosa en cada test.

Pasos:

  1. Crearemos un objeto mockSolicitudService que sustituya (mockee) al servicio. Inicialmente mockSolicitudService estará vacío. Posteriormente le iremos añadiendo los métodos falseados (mockeados).

  2. Construir un módulo de testing que reemplace el servicio original de la solicitud por el mockeado que hemos creado en el paso anterior.

import { Test, TestingModule } from '@nestjs/testing';
import { SolicitudController } from './solicitud.controller';
import { SolicitudService } from './solicitud.service';

describe('SolicitudController', () => {
  let controller: SolicitudController;
  let mockSolicitudService = {}; (1)

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [SolicitudController],
      providers: [SolicitudService],
    })
      .overrideProvider(SolicitudService) (2)
      .useValue(mockSolicitudService) (3)
      .compile(); (4)

    controller = module.get<SolicitudController>(SolicitudController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});
1 Mock del servicio. Inicialmente vacío para pasar el test
2 Servicio que se va a sustituir (mockear)
3 Servicio que sustituye (mockea) al original. Usamos el creado en paso 1.
4 Construcción del módulo para testing

Lanzaremos ahora los tests unitarios, pero no los lanzaremos todos como hacíamos antes al ejecutar npm run test. En este proceso paulatino de creación de los tests unitarios nos ceñiremos sólo a los tests del controlador y además lo haremos en modo watch. Así, cada vez que hagamos cambios sobre el código se volverán a ejecutar los tests.

$ npm run test:watch

Watch Usage
 › Press a to run all tests.
 › Press f to run only failed tests.
 › Press p to filter by a filename regex pattern. (1)
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.
1 Para ejecutar los tests de los nombres de archivo de acuerdo a una expresión regular

Pulsaremos p para indicar que sólo se pasen los tests a los archivos que sigan un patrón concreto de nombre de archivo. Introduciremos solicitud.co como patrón. Con esto, se pasarán los tests sólo al controlador de la solicitudes y obtendremos un resultado como el siguiente:

 PASS  src/solicitud/solicitud.controller.spec.ts
  SolicitudController
    ✓ should be defined (13 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.714 s, estimated 4 s
Ran all test suites matching /solicitud.co/i.

Watch Usage: Press w to show more.

Objetivo cumplido!! Hemos conseguido hacer que desaparezca el error al ejecutar el test del controlador. A continuación, comenzaremos a añadirle tests.

3.2.2. Segundo paso. Añadir tests

Una vez que hemos configurado el módulo para que el test no falle mediante el mockeo del servicio, vamos a ir creando tests del controlador. Comenzaremos por el de creación de solicitudes añadiendo el test siguiente después del test should be defined. Con este nuevo test definimos un nuevo DTO para crear una solicitud y esperamos que nos devuelva un objeto con un id (da igual el que sea. En el código de producción sería el id que generaría la base de datos) y el resto de campos coincidirán con los del DTO de creación de solicitudes.

Archivo src/solicitud/solicitud.controller.spec.ts

...
  it('should create a solicitud', () => {
    const createSolicitudDto: CreateSolicitudDto = { (1)
      nombre: 'John Doe',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };

    expect(controller.create(createSolicitudDto)).toEqual({ (2)
      id: expect.any(Number),
      ...createSolicitudDto,
    });
  });
...
1 DTO de la solicitud a crear
2 Probamos que la solicitud creada consiste en un id junto a los datos proporcionados en el DTO para crear la solicitud

Tras guardar los cambios, como estamos en modo watch se volverán a pasar los tests y nos da un fallo: el método create no existe en el mock del servicio, tal y como se muestra a continuación:

 FAIL  src/solicitud/solicitud.controller.spec.ts (1)
  SolicitudController
    ✓ should be defined (13 ms)
    ✕ should create a solicitud (5 ms)

  ● SolicitudController › should create a solicitud

    TypeError: this.solicitudService.create is not a function (2)

      25 |   @Post()
      26 |   create(@Body() createSolicitudDto: CreateSolicitudDto): Promise<Solicitud> {
    > 27 |     return this.solicitudService.create(createSolicitudDto); (3)
         |                                  ^
      28 |   }
      29 |
      30 |   @Get()

      at SolicitudController.create (solicitud/solicitud.controller.ts:27:34)
      at Object.<anonymous> (solicitud/solicitud.controller.spec.ts:42:23)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        3.907 s, estimated 5 s
Ran all test suites matching /solicitud.co/i.

Watch Usage: Press w to show more.
1 El test no pasa
2 El método create no existe en el mock del servicio (recordamos que estamos en el mockeado)
3 Línea en la que se provoca el error en el test

El error se debe a que en la sección anterior creamos el mock del servicio de la solicitud, pero lo creamos vacío, sin ningún método, tal y como se muestra a continuación.

...
describe('SolicitudController', () => {
  let controller: SolicitudController;
  let mockSolicitudService = {}; (1)
...
1 Mock del servicio creado vacío inicialmente

A continuación crearemos la implementación que mockea al método create del servicio. Se limitará a tomar un DTO y devolver un objeto con un id aleatorio (simulando lo que haría la base de datos) y el DTO.

Archivo src/solicitud/solicitud.controller.spec.ts

...
describe('SolicitudController', () => {
  let controller: SolicitudController;
  let mockSolicitudService = {
    create: jest.fn((dto) => { (1)
      return {
        id: Math.random() * (1000 - 1) + 1, (2)
        ...dto, (3)
      };
    }),
  };
...
1 Método create mockeado.
2 id aleatorio
3 Incorporar el DTO del objeto a crear

Una vez realizados estos cambios, el test de crear una solicitud pasa correctamente.

 PASS  src/solicitud/solicitud.controller.spec.ts
  SolicitudController
    ✓ should be defined (13 ms)
    ✓ should create a solicitud (4 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.941 s, estimated 4 s
Ran all test suites matching /solicitud.co/i.

Watch Usage: Press w to show more.
Funciones de mock con jest.fn()

Las funciones de mock se usan para inyectar o falsear código durante los tests. jest.fn() crea una función de mock y opcionalmente puede tomar una implementación como parámetro.

Las funciones de mock tienen la propiedad mock que permite, entre otros, conocer los argumentos con los que fue llamada, obtener la cantidad de veces que fue llamada, y ver el valor de los argumentos en una llamada concreta, por ejemplo, en la tercera vez que fue llamada.

También tiene métodos interesantes como los siguientes:

  • mockReturnValue(): Devuelve el valor que se pase como argumento

  • mockResolvedValue(): Devuelve el valor resuelto por una promesa

  • mockImplementation(): Acepta una función que es usada como implementación del mock

  • …​

A continuación añadiremos otro test. Por ejemplo, añadiremos el test para actualizar una solicitud. Comenzaremos creando el test en src/solicitud/solicitud.controller.spec.ts. Lo añadiremos a continuación de los otros tests definidos.

...
  it('should update a solicitud', () => {
    const updateSolicitudDto: UpdateSolicitudDto = { (1)
      nombre: 'John Smith', (2)
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };
    const solicitudId = 2; (3)

    expect(controller.update(solicitudId, updateSolicitudDto)).toEqual(
      {
        id: solicitudId,
        ...updateSolicitudDto,
      },
    );
  });
...
1 DTO con los cambios de la solicitud
2 Nombre modificado
3 id de la solicitud a modificar
4 Se espera que resultado de actualizar la solicitud sea la solicitud con el id y los datos actualizados.

Tras guardar los cambios se volverán a pasar los tests y no pasará este test porque no está definido el método update en el mock del servicio.

 FAIL  src/solicitud/solicitud.controller.spec.ts (1)
  SolicitudController
    ✓ should be defined (14 ms)
    ✓ should create a solicitud (4 ms)
    ✕ should update a solicitud (3 ms)

  ● SolicitudController › should update a solicitud

    TypeError: this.solicitudService.update is not a function (2)

      43 |     @Body() updateSolicitudDto: UpdateSolicitudDto,
      44 |   ): Promise<Solicitud> {
    > 45 |     return this.solicitudService.update(+id, updateSolicitudDto); (3)
         |                                  ^
      46 |   }
      47 |
      48 |   @Delete(':id')

      at SolicitudController.update (solicitud/solicitud.controller.ts:45:34)
      at Object.<anonymous> (solicitud/solicitud.controller.spec.ts:74:23)
  1. El test no pasa

  2. El método update no existe en el mock del servicio (recordamos que estamos en el mockeado)

  3. Línea en la que se provoca el error en el test

Para solucionar este problema añadiremos la función update a mockSolicitudService. Con los cambios, quedará así

...
describe('SolicitudController', () => {
  let controller: SolicitudController;
  let mockSolicitudService = {
    create: jest.fn((dto) => {
      return {
        id: Math.random() * (1000 - 1) + 1,
        ...dto,
      };
    }),
    update: jest.fn((id, dto) => { (1)
      return {
        id: id,
        ...dto,
      };
    }),
  };
...
1 update devolverá el nuevo objeto modificado

Tras los cambios, los tests volverán a pasar.

 PASS  src/solicitud/solicitud.controller.spec.ts
  SolicitudController
    ✓ should be defined (19 ms)
    ✓ should create a solicitud (6 ms)
    ✓ should update a solicitud (3 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        4.209 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.

Por último, es posible introducir una mejora al test para comprobar que el servicio fue llamado con los argumentos correctos. Esta comprobación va dirigida a conocer si el controlador introduce alguna anomalía al llamar al servicio. Con esto, no sólo nos aseguramos que el controlador hace su trabajo y devuelve los datos correctos, sino que también comprobamos que internamente hace bien su trabajo.

Tras los cambios el test quedaría así:

...
  it('should update a solicitud', () => {
    const updateSolicitudDto: UpdateSolicitudDto = {
      nombre: 'John Smith',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };
    const solicitudId = 1;

    expect(controller.update(solicitudId, updateSolicitudDto)).toEqual({
      id: solicitudId,
      ...updateSolicitudDto,
    });

    expect(mockSolicitudService.update).toHaveBeenCalledWith( (1)
      solicitudId,
      updateSolicitudDto,
    );
  });
...
1 Comprobación de que el servicio ha sido llamado con los argumentos correctos por parte del controlador

Al guardar, se volverán a pasar los tests y el cambio introducido funcionará correctamente, lo que permitirá validar que el controlador hace bien su trabajo.

Ahora, y de acuerdo con el informe de cobertura de tests, se trataría de ir añadiendo los tests que faltan (mostrar solicitudes, mostrar una solicitud y eliminar una solicitud). Los dejaremos para más adelante y ahora pasaremos a ver cómo llevar el mock del servicio a una clase aparte para no tenerlo mezclado con el test del controlador.

3.2.3. Tercer paso. Llevar el mock del servicio a una clase

Hasta ahora hemos mockeado el servicio en la misma clase de testing. Aquí veremos cómo refactorizar el archivo de testing sacando el mock a una clase aparte. Concretamente, se trata de llevar el contenido de los métodos de mockSolicitudService a métodos en una clase nueva.

Partimos del servicio mockeado en la propia clase, que recordamos que tenía esta forma:

...
  let mockSolicitudService = {
    create: jest.fn((dto) => { (1)
      return {
        id: Math.random() * (1000 - 1) + 1,
        ...dto,
      };
    }),
    update: jest.fn((id, dto) => { (2)
      return {
        id: id,
        ...dto,
      };
    }),
  };
...
1 Función de mock para crear solicitudes
2 Función de mock para modificar solicitudes

Comenzamos generando la clase que actuará como mock del servicio con el CLI de NestJS. La situaremos en la misma carpeta que el resto de componentes de la solicitud.

$ nest g class solicitud/SolicitudServiceMock --no-spec (1)
1 Incluimos el parámetro --no-spec para que no cree el archivo de testing

Esta clase estará inicialmente vacía:

export class SolicitudServiceMock {}

Ahora se trata de traer a esta nueva clase de mock el código que había en los métodos create y update del objeto mockSolicitudService en el archivo de testing del controlador. Para ello, crearemos en la clase dos métodos create y update en los que incluiremos el código de mocking que ya teníamos. No obstante, renombraremos los DTO para darle una mayor semántica. Además, haremos que los métodos devuelvan promesas, tal y como lo hacen en el servicio real.

La clase que mockea al servicio ahora quedará así:

import { CreateSolicitudDto } from './dto/create-solicitud.dto';
import { Solicitud } from './entities/solicitud.entity';
import { UpdateSolicitudDto } from './dto/update-solicitud.dto';
export class SolicitudServiceMock {
  async create(createSolicitudDto: CreateSolicitudDto): Promise<Solicitud> { (1)
    return Promise.resolve({ (2)
      id: Math.random() * (1000 - 1) + 1,
      ...createSolicitudDto,
    });
  }

  async update(
    id: number,
    updateSolicitudDto: UpdateSolicitudDto,
  ): Promise<Solicitud> { (3)
    return Promise.resolve({ (4)
      id: id,
      ...updateSolicitudDto,
    }) as Promise<Solicitud>(5)
  }
}
1 Método create mockeado
2 Código traído desde mockSolicitudService
3 Método update mockeado
4 Código traído desde mockSolicitudService
5 Forzamos el casting de la respuesta porque no pueden inferir que el tipo que devolvemos es correcto.

Una vez que disponemos de la clase que mockea el servicio, haremos los cambios en el archivo de tests del controlador para que use esta clase mockeada en lugar de la variable mockSolicitudService, que es la que contenía la implementación de los mocks.

La inyección de dependencias de NestJS permite que podamos sustituir el servicio que se usa para ejecutar los tests. El uso de mocks permite probar sólo una parte del código haciendo que el resto ofrezca valores falseados/generados. Esto, además de permitirnos un mayor control en el proceso de testing, acelera la ejecución de los tests, ya que el servicio ya no tiene que usar la base de datos (que siempre ofrece mayor latencia) para realizar su trabajo en el testing del controlador.

Hay que hacer varios cambios:

  1. Declarar una variable service de tipo SolicitudService

  2. Definir un SolicitudServiceProvider que mockee el provider SolicitudService

  3. Incorporar el SolicitudServiceProvider a la lista de providers del módulo de testing

  4. Usar la clase de mock para construir el módulo de testing

  5. Inicializar la variable service al servicio de la solicitud. Como SolicitudService está mockeado realmente no usará la implementación original

  6. Cambiar los tests a asíncronos

  7. Añadir await a las llamadas a los métodos del controlador

  8. Usar espías de métodos si usamos métodos como toHaveBeenCalledWith

import { Test, TestingModule } from '@nestjs/testing';
import { SolicitudController } from './solicitud.controller';
import { SolicitudService } from './solicitud.service';
import { CreateSolicitudDto } from './dto/create-solicitud.dto';
import { UpdateSolicitudDto } from './dto/update-solicitud.dto';
import { of } from 'rxjs';
import { SolicitudServiceMock } from './solicitud-service-mock';

describe('SolicitudController', () => {
  let controller: SolicitudController;
  let service: SolicitudService; (1)

  beforeEach(async () => {
    const SolicitudServiceProvider = { (2)
      provide: SolicitudService,
      useClass: SolicitudServiceMock,
    };

    const module: TestingModule = await Test.createTestingModule({
      controllers: [SolicitudController],
      providers: [SolicitudService, SolicitudServiceProvider], (3)
    })
      .overrideProvider(SolicitudService)
      .useClass(SolicitudServiceMock) (4)
      .compile();

    controller = module.get<SolicitudController>(SolicitudController);
    service = module.get<SolicitudService>(SolicitudService); (5)
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  it('should create a solicitud', async () => { (6)
    const createSolicitudDto: CreateSolicitudDto = {
      nombre: 'John Doe',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };

    expect(await controller.create(createSolicitudDto)).toEqual({ (7)
      id: expect.any(Number),
      ...createSolicitudDto,
    });
  });

  it('should update a solicitud', async () => { (8)
    const updateSolicitudDto: UpdateSolicitudDto = {
      nombre: 'John Smith',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };
    const solicitudId = 1;

    expect(await controller.update(solicitudId, updateSolicitudDto)).toEqual({ (9)
      id: solicitudId,
      ...updateSolicitudDto,
    });

    const updateSpy = jest.spyOn(service, 'update'); (10)
    controller.update(solicitudId, updateSolicitudDto); (11)

    expect(updateSpy).toHaveBeenCalledWith(solicitudId, updateSolicitudDto); (12)
  });
});
1 Declaración del servicio
2 SolicitudServiceProvider mockea el provider SolicitudService
3 Se añade SolicitudServiceProvider como otro provider
4 Inicialización del mock a la clase del mock del servicio (Inyección de dependencias)
5 Inicialización del servicio al servicio de la solicitud, que está mockeado
6 Caso de prueba asíncrono por el await en métodos dentrol del caso de prueba
7 Probamos que la solicitud se crea correctamente y devuelve los valores esperados. La ejecución se hace con await
8 Caso de prueba asíncrono por el await en métodos dentro del caso de prueba
9 Probamos que la actualización de una solicitud se realiza correctamente y devuelve los valores esperados. La ejecución se hace con await
10 Crear un espía para el método update en service
11 Hacer una actualización de solicitud
12 Probamos que el servicio espiado ha sido llamado por el controlador con los parámetros adecuados
jest.spyOn()

jest.spyOn() crea una función de mock similar a jest.fn() pero además, monitoriza/fisgonea las llamadas al método que se le proporcione.

jest.spyOn(objeto, nombre-de-método-a-espiar) devuelve una función que se comporta como espía monitorizando las llamadas que se realicen al método del objeto que se pasen como argumentos.

...
let service: SolicitudService; (1)
...
service = module.get<SolicitudService>(SolicitudService); (2)
...

const updateSpy = jest.spyOn(service, 'update'); (3)
controller.update(solicitudId, updateSolicitudDto); (4)

expect(updateSpy).toHaveBeenCalledWith(solicitudId, updateSolicitudDto); (5)
...
1 Declaración de un objeto service
2 Inicialización del objeto service (a la clase del servicio)
3 Espiar el método update del objeto service. Ahora, updateSpy monitoriza cada una de las llamadas que se hagan al método update del objeto service.
4 Llamar al método espiado (update)
5 Comprobar a través del espía (updateSpy) los argumentos con los que ha sido llamada la función espiada.

La función espía intercepta/espía las llamadas que se hacen a un método de un objeto. Haciendo la analogía, el método update del objeto service está pinchado, como se pinchan los teléfonos en espionaje.

3.3. Tests del servicio

Una vez creados los tests del controlador procederemos a realizar los tests del servicio. De forma análoga a como hicimos con el controlador, que mockeaba el servicio del que dependía, en los tests del servicio también mockearamos sus dependencias. En el caso del servicio se mockea el repositorio, que es su dependencia.

Comenzamos lanzando los tests en modo watch, pero limitados al patrón solicitud.service

$ npm run test:watch

El resultado de los tests nos devolverá que no se pueden resolver las dependencias de SolicitudService. Esto se debe a que SolicitudService tiene una dependencia con el repositorio SolicitudRepository y no se puede resolver en el entorno de pruebas.

Recordemos la definición del servicio en solicitud/solicitud.service.ts

...
@Injectable()
export class SolicitudService {
  constructor(
    @InjectRepository(Solicitud)
    private solicitudRepository: Repository<Solicitud>, (1)
  ) {}
...
1 Dependencia del servicio respecto del repositorio

De foma análoga a los tests del controlador, en el código siguiente del test del servicio, generado por el CLI de NestJS al generar el servicio, vemos que dentro de beforeEach se usa la clase Test y un método createTestingModule. Este método toma los mismos argumentos que se usan para crear un módulo (p.e. imports, providers, controllers, …​). Tras definir el nuevo módulo (el de testing) y llamar al método compile se crea el módulo con sus dependencias similar a los módulos creados para el entorno de ejecución.

Archivo src/solicitud/solicitud.service.spec.ts

...
describe('SolicitudService', () => {
  let service: SolicitudService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({ (1)
      providers: [SolicitudService], (2)
    }).compile();

    service = module.get<SolicitudService>(SolicitudService); (3)
  });
...
1 Definición del módulo para el testing del servicio
2 Provider del servicio
3 Creación de una instancia del servicio

3.3.1. Primer paso. Evitar que falle el test

Al igual que hicimos con el controlador, seguiremos un enfoque progresivo para conseguir que nuestros tests funcionen. Se trata de ayudar a que en primer lugar desaparezcan los errores de las pruebas del servicio. Posteriormente, se irán refinando los tests.

Inicialmente, el test del servicio falla porque el servicio no es capaz de resolver sus dependencias. Lo que haremos es sustituir el repositorio original por un repositorio de uso exclusivo en testing. Con esto, conseguiremos probar únicamente el servicio, aislándolo del repositorio, que es la premisa de los tests unitarios: probar sólo una cosa en cada test.

Pasos:

  1. Crearemos un objeto mockSolicitudRepository que sustituya (mockee) al repositorio. Inicialmente mockSolicitudRepository estará vacío. Posteriormente le iremos añadiendo los métodos falseados (mockeados).

  2. Construir un módulo de testing que reemplace el repositorio original de solicitudes por el mockeado que hemos creado en el paso anterior.

import { Test, TestingModule } from '@nestjs/testing';
import { SolicitudService } from './solicitud.service';
import { Solicitud } from './entities/solicitud.entity';
import { getRepositoryToken } from '@nestjs/typeorm';

describe('SolicitudService', () => {
  let service: SolicitudService;
  let mockSolicitudRepository = {}; (1)

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        SolicitudService,
        { (2)
          provide: getRepositoryToken(Solicitud), (3)
          useValue: mockSolicitudRepository, (4)
        },
      ],
    }).compile(); (5)

    service = module.get<SolicitudService>(SolicitudService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });
});
1 Mock del repositorio. Inicialmente vacío para pasar el test
2 Nuevo provider
3 Repositorio que se va a sustituir (mockear)
4 Repositorio que sustituye (mockea) al original. Usamos el creado en el paso 1
5 Construcción del módulo para testing

Tras guardar los cambios ahora vemos que ya pasan los tests.

 PASS  src/solicitud/solicitud.service.spec.ts
  SolicitudService
    ✓ should be defined (13 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.758 s, estimated 6 s
Ran all test suites matching /solicitud.service/i.

Watch Usage: Press w to show more.

3.3.2. Segundo paso. Añadir tests

Una vez que hemos configurado el módulo para que el test no falle mediante el mockeo del repositorio, vamos a ir creando tests del servicio. Comenzaremos por el de creación de solicitudes añadiendo este test después del test should be defined. Con este nuevo test definimos un nuevo DTO para crear una solicitud y esperamos que nos devuelva un objeto con un id (da igual el que sea. En el código de producción sería el id que generaría la base de datos) y el resto de campos coincidirán con los del DTO de creación de solicitud.

...
  it('should create a solicitud', async () => {
    const createSolicitudDto: CreateSolicitudDto = { (1)
      nombre: 'John Doe',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };

    expect(await service.create(createSolicitudDto)).toEqual({ (2)
      id: expect.any(Number),
      ...createSolicitudDto,
    });
  });
...
1 DTO de la solicitud a crear
2 Probamos que la solicitud creada consiste en un id junto a los datos proporcionados en el DTO para crear la solicitud

Tras guardar los cambios, como estamos en modo watch se volverán a pasar los tests y nos da un fallo: el método create no existe en el mock del repositorio, tal y como se muestra a continuación:

 FAIL  src/solicitud/solicitud.service.spec.ts
  SolicitudService
    ✓ should be defined (12 ms)
    ✕ should create a solicitud (3 ms)

  ● SolicitudService › should create a solicitud

    TypeError: this.solicitudRepository.save is not a function

      13 |   ) {}
      14 |   async create(createSolicitudDto: CreateSolicitudDto): Promise<Solicitud> {
    > 15 |     return await this.solicitudRepository.save(createSolicitudDto);
         |                                           ^
      16 |   }
      17 |
      18 |   async findAll(): Promise<Solicitud[]> {

      at SolicitudService.create (solicitud/solicitud.service.ts:15:43)
      at Object.<anonymous> (solicitud/solicitud.service.spec.ts:45:26)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        3.966 s, estimated 5 s
Ran all test suites matching /solicitud.service/i.

Watch Usage: Press w to show more.
1 El test no pasa
2 El método save no existe en el mock del repositorio (recordamos que estamos en el mockeado)
3 Línea en la que se provoca el error en el test

El error se debe a que en la sección anterior creamos el mock del repositorio de la solicitud pero lo creamos vacío, sin ningún método, tal y como se recuerda a continuación.

...
describe('SolicitudService', () => {
  let service: SolicitudService;
  let mockSolicitudRepository = {}; (1)
...
1 Mock del repositorio creado vacío inicialmente

A continuación crearemos la implementación que mockea al método save del repositorio. Se limitará a tomar un DTO y devolver un objeto con un id aleatorio (simulando lo que haría la base de datos) y el DTO.

Archivo src/solicitud/solicitud.service.spec.ts

...
describe('SolicitudService', () => {
  let service: SolicitudService;
  let mockSolicitudRepository = {
    save: jest.fn().mockImplementation((dto) => { (1)
      return {
        id: Math.random() * (1000 - 1) + 1, (2)
        ...dto, (3)
      };
    }),
  };
...
1 Método save mockeado.
2 id aleatorio
3 Incorporar el DTO del objeto a crear

Una vez realizados estos cambios, el test de crear una solicitud pasa correctamente.

 PASS  src/solicitud/solicitud.service.spec.ts
  SolicitudService
    ✓ should be defined (12 ms)
    ✓ should create a solicitud (4 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.673 s
Ran all test suites matching /solicitud.service/i.

Watch Usage: Press w to show more.

A continuación añadiremos otro test. Por ejemplo, añadiremos el test para actualizar una solicitud. Comenzaremos creando el test en src/solicitud/solicitud.service.spec.ts. Lo añadiremos a continuación de los otros tests definidos.

  it('should update a solicitud', async () => {
    const updateSolicitudDto: UpdateSolicitudDto = { (1)
      nombre: 'John Smith', (2)
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };
    const solicitudId = 1; (3)

    expect(await service.update(solicitudId, updateSolicitudDto)).toEqual({ (4)
      id: solicitudId,
      ...updateSolicitudDto,
    });
  });
1 DTO con los cambios de la solicitud
2 Cambio introducido
3 id de la solicitud a modificar
4 Se espera que resultado de actualizar la solicitud sea la solicitud con el id y los datos actualizados

Tras guardar los cambios se volverán a pasar los tests y no pasará este test porque no está definido el método findOne en el mock del repositorio.

 FAIL  src/solicitud/solicitud.service.spec.ts
  SolicitudService
    ✓ should be defined (13 ms)
    ✓ should create a solicitud (4 ms)
    ✕ should update a solicitud (3 ms)

  ● SolicitudService › should update a solicitud

    TypeError: this.solicitudRepository.findOne is not a function (1)

      28 |     updateSolicitudDto: UpdateSolicitudDto,
      29 |   ): Promise<Solicitud> {
    > 30 |     let toUpdate = await this.solicitudRepository.findOne(id);
         |                                                   ^
      31 |
      32 |     let updated = Object.assign(toUpdate, updateSolicitudDto);
      33 |

Para solucionar este problema añadiremos la función findOne a solicitudRepository que devuelva un objeto Solicitud completo. Con los cambios, quedará así:

Archivo solicitud/solicitud.service.spec.ts:

...
  let mockSolicitudRepository = {
    save: jest.fn().mockImplementation((dto) => {
      return {
        id: Math.random() * (1000 - 1) + 1,
        ...dto,
      };
    }),
    findOne: jest.fn().mockImplementation((id) => { (1)
      return {
        id: id, (2)
        nombre: 'John Doe',
        cargo: 'Assistant Professor',
        unidad: 'Informatics Department',
        telefono: '1234',
        email: 'john.doe@gmail.com',
        tipo: '',
        nombreActividad: '',
        start: undefined,
        end: undefined,
        dia: '',
        horaInicio: '',
        horaFin: '',
      };
    }),
  };
1 findOne devolverá un objeto Solicitud completo como lo devolvería la base de datos
2 Simulamos que devolvemos el mismo id con el que hacemos la consulta

Tras los cambios, los tests volverán a pasar.

 PASS  src/solicitud/solicitud.service.spec.ts
  SolicitudService
    ✓ should be defined (13 ms)
    ✓ should create a solicitud (4 ms)
    ✓ should update a solicitud (2 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        4.159 s
Ran all test suites matching /solicitud.service/i.

Ahora, y de acuerdo con el informe de cobertura de tests, se trataría de ir añadiendo los tests que faltan (mostrar solicitudes, mostrar una solicitud y eliminar una solicitud). Los dejaremos para más adelante y ahora pasaremos a ver cómo llevar el mock del repositorios a una clase aparte para no tenerlo mezclado con el test del servicio.

3.3.3. Tercer paso. Llevar el mock del repositorio a una clase

Hasta ahora hemos mockeado el repositorio en la misma clase de testing. Aquí veremos como refactorizar el archivo de testing sacando el mock a una clase aparte. Concretamente se trata de llevar el contenido de los métodos de mockSolicitudRepository a métodos en una clase nueva.

Partimos del repositorio mockeado en la propia clase y tenía esta forma:

...
 let mockSolicitudRepository = {
    save: jest.fn().mockImplementation((dto) => { (1)
      return {
        id: Math.random() * (1000 - 1) + 1,
        ...dto,
      };
    }),
    findOne: jest.fn().mockImplementation((id) => { (2)
      return {
        id: id,
        nombre: 'John Doe',
        cargo: 'Assistant Professor',
        unidad: 'Informatics Department',
        telefono: '1234',
        email: 'john.doe@gmail.com',
        tipo: '',
        nombreActividad: '',
        start: undefined,
        end: undefined,
        dia: '',
        horaInicio: '',
        horaFin: '',
      };
    }),
  };
...
1 Método para guardar solicitudes
2 Método para buscar una solicitud

Comenzamos generando la clase con el CLI de NestJS

$ nest g class solicitud/SolicitudRepositoryMock --no-spec (1)
1 Incluimos el parámetro --no-spec para que no cree el archivo de testing

Esta clase estará inicialmente vacía:

$ export class SolicitudRepositoryMock {}

Ahora se trata de traer a esta nueva clase de mock el código que había en los métodos save y findOne del objeto SolicitudServiceMock en el archivo de testing del servicio. Para ello, crearemos en la clase dos métodos save y findOne en los que incluiremos el código de mocking que ya teníamos. No obstante, renombraremos los DTO para darle una mayor semántica. Además, haremos que los métodos devuelvan promesas, tal y como lo hacen en el repositorio real.

La clase que mockea al repositorio ahora quedará así:

import { Solicitud } from './entities/solicitud.entity';
export class SolicitudRepositoryMock {
  save(solicitud: Solicitud): Promise<Solicitud> { (1)
    return Promise.resolve({
      id: Math.random() * (1000 - 1) + 1, (2)
      ...solicitud,
    });
  }

  findOne(id: number): Promise<Solicitud> { (3)
    return Promise.resolve({ (4)
      id: id,
      nombre: 'John Doe',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    });
  }
}
1 Método save mockeado
2 Código traído desde mockSolicitudRepository pero envuelto en una promesa
3 Método findOne mockeado
4 Código traído desde mockSolicitudRepository pero envuelto en una promesa

Una vez que disponemos de la clase que mockea el repositorio, haremos los cambios en el archivo de tests del servicio para que use esta clase mockeada en lugar de la variable mockSolicitudRepository, que es la que contenía la implementación de los mocks. Basta con:

  1. Eliminar la variable mockSolicitudRepository

  2. Usar la clase de mock para construir el módulo de testing

La clase de testing quedaría así

import { Test, TestingModule } from '@nestjs/testing';
import { SolicitudService } from './solicitud.service';
import { Solicitud } from './entities/solicitud.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CreateSolicitudDto } from './dto/create-solicitud.dto';
import { UpdateSolicitudDto } from './dto/update-solicitud.dto';
import { SolicitudRepositoryMock } from './solicitud-repository-mock';

describe('SolicitudService', () => {
  let service: SolicitudService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        SolicitudService,
        {
          provide: getRepositoryToken(Solicitud),
          useClass: SolicitudRepositoryMock, (1)
        },
      ],
    }).compile();

    service = module.get<SolicitudService>(SolicitudService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should create a solicitud', async () => {
    const createSolicitudDto: CreateSolicitudDto = {
      nombre: 'John Doe',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };

    expect(await service.create(createSolicitudDto)).toEqual({
      id: expect.any(Number),
      ...createSolicitudDto,
    });
  });

  it('should update a solicitud', async () => {
    const updateSolicitudDto: UpdateSolicitudDto = {
      nombre: 'John Smith',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };
    const solicitudId = 1;

    expect(await service.update(solicitudId, updateSolicitudDto)).toEqual({
      id: solicitudId,
      ...updateSolicitudDto,
    });
  });
});
1 Inicialización del mock a la clase del mock del repositorio

La inyección de dependencias de NestJS permite que podamos sustituir el repositorio que se usa para ejecutar los tests. El uso de mocks permite probar sólo una parte del código haciendo que el resto ofrezca valores falseados/generados. Esto, además de permitirnos un mayor control en el proceso de testing, acelera la ejecución de los tests, ya que el servicio ya no tiene que usar la base de datos (que siempre ofrece mayor latencia) para realizar su trabajo.

3.4. Tests end to end

Este tipo de tests se centra más en la interacción entre clases y módulos a un nivel más alto, en la línea de cómo interactuarían los usuarios con la aplicación. Con esto podremos realizar la prueba de cada endpoint de la API. Para simular las llamadas HTTP NestJS usa Supertest .

Para el desarrollo de nuestros tests seguiremos apoyándonos en que NestJS permite la inyección de dependencias de forma que podremos mockear o sustituir componentes fácilmente en el entorno de pruebas.

3.4.1. Añadir script para tests e2e en modo watch

Podemos lanzar las pruebas e2e generadas al crear el proyecto con el CLI de NestJS. En package.json hay un script para ello: test:e2e. Pero antes de lanzar los tests vamos a introducir un script en package.json para que los tests e2e también se ejecuten en modo watch. Añadiremos los cambios al final del elemento scripts:

Archivo package.json

...
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json",
    "test:e2e:watch": "jest --config ./test/jest-e2e.json --watch" (1)
  },
...
1 Nueva etiqueta test:e2e:watch para tests e2e en modo watch.

3.4.2. Primer paso. Evitar que falle el test

Comenzaremos haciendo una copia de test/app.e2e-spec.ts. La nueva copia se denominará test/solicitud.e2e-spec.ts. Responderemos que Sí a que se actualicen todas las importaciones en el archivo copiado.

De forma predeterminada se ejecutarán como tests e2e todos los que incluyan e2e-spec.ts en su nombre de archivo. Esto se configura en el archivo test/jest-e2e.json y queda configurado automáticamente al crear el proyecto con el CLI de NestJS.

Si ejecutamos los tests e2e con el script test:e2e:watch

$ npm run test:e2e:watch

indicando como patrón solicitud.e2e todo funcionará correctamente por ahora ya que es una copia exacta de app.e2e-spec.ts

 PASS  test/solicitud.e2e-spec.ts
  AppController (e2e)
    ✓ / (GET) (295 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.954 s
Ran all test suites matching /solicitud.e2e/i.

Sin embargo, una vez que comencemos a personalizar el archivo de testing para que funcione con el módulo de solicitudes en lugar del módulo de la aplicación empezarán los problemas.

Comencemos introduciendo los cambios siguientes en `test/solicitud.e2e-spec.ts:

  1. Cambiar la descripción de describe para que sea para solicitudes

  2. Sustituir el módulo

...
describe('SolicitudController (e2e)', () => { (1)
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [SolicitudModule], (2)
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });
...
1 Modificación de la descripción del bloque de tests
2 Uso del módulo de solicitudes
 FAIL  test/solicitud.e2e-spec.ts
  SolicitudController (e2e)
    ✕ / (GET) (14 ms)

  ● SolicitudController (e2e) › / (GET)

    Nest can't resolve dependencies of the SolicitudRepository (?). Please make sure that the argument Connection at index [0] is available in the TypeOrmModule context.

El error indica que el módulo de testing creado para la ocasión no es capaz de resolver las dependencias que hay sobre el repositorio de SolicitudRepository. De forma análoga a como hemos hecho con los tests del controlador y del servicio, hay que añadir un mock que permita resolver la dependencia existente. Lo más inmediato es hacer lo mínimo para que el test deje de ejecutarse con errores. A continuación se muestran los cambios realizados.

Archivo test/solicitud.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { SolicitudModule } from '../src/solicitud/solicitud.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Solicitud } from '../src/solicitud/entities/solicitud.entity';

describe('SolicitudController (e2e)', () => {
  let app: INestApplication;
  const mockSolicitudRepository = { (1)
    find: jest.fn(),
  };

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [SolicitudModule], (2)
    })
      .overrideProvider(getRepositoryToken(Solicitud)) (3)
      .useValue(mockSolicitudRepository) (4)
      .compile(); (5)

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/solicitud (GET)', async () => { (6)
    return request(app.getHttpServer()).get('/solicitud').expect(200);
  });
});
1 Mock del repositorio. Inicialmente sólo con una función de mock find vacía para pasar el test
2 Módulo de solicitud. No olvidar cambiarlo. Al haber copiado el archivo de tests desde app.e2e-spec.ts, el valor viene a AppModule
3 Repositorio que se va a sustituir (mockear)
4 Repositorio que sustituye (mockea) al original. Usamos el creado en paso 1.
5 Construcción del módulo para testing
6 Test a ejecutar. Comprueba que en la ruta raiz del controlador (/solicitud) se devuelve un código de estado HTTP de 200

Tras guardar los cambios vemos que aparece un nuevo error, esta vez relacionado con la autenticación. El error se debe a que los endpoints de los controladores incluyen guardas para proteger el acceso a través de JWT. Para los tests, burlaremos la guarda definida (AuthGuard('jwt')). El archivo de testing ahora quedaría así:

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { SolicitudModule } from '../src/solicitud/solicitud.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Solicitud } from '../src/solicitud/entities/solicitud.entity';
import { AuthGuard } from '@nestjs/passport';

describe('SolicitudController (e2e)', () => {
  let app: INestApplication;
  const mockSolicitudRepository = {
    find: jest.fn(),
  };

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [SolicitudModule],
    })
      .overrideGuard(AuthGuard('jwt')) (1)
      .useValue('') (2)
      .overrideProvider(getRepositoryToken(Solicitud))
      .useValue(mockSolicitudRepository)
      .compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/solicitud (GET)', async () => {
    return request(app.getHttpServer()).get('/solicitud').expect(200);
  });
});
1 Guarda que se va a modificar
2 Sustituir la guarda por nada para saltarla

Tras guardar los cambios veremos que pasan los tests.

 PASS  test/solicitud.e2e-spec.ts
  SolicitudController (e2e)
    ✓ /solicitud (GET) (184 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.512 s
Ran all test suites matching /solicitud.e2e/i.

Watch Usage: Press w to show more.

Este test se limita a comprobar que en la ruta /solicitud se devuelve un código de estado HTTP de 200.

3.4.3. Segundo paso. Añadir tests

A continuación añadiremos el resto de tests para el resto de endpoints de solicitudes (recuperar, crear, modificar y eliminar una solicitud). También añadiremos al mock del repositorio el resto de funciones. Sólo teníamos añadida find. Aprovecharemos la ocasión para dar una implementación mínima de las funciones de mockeo del repositorio.

Archivo test/solicitud.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { SolicitudModule } from '../src/solicitud/solicitud.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Solicitud } from '../src/solicitud/entities/solicitud.entity';
import { AuthGuard } from '@nestjs/passport';

describe('SolicitudController (e2e)', () => {
  let app: INestApplication;
  const solicitud: Solicitud = { (1)
    id: 0,
    nombre: '',
    cargo: '',
    unidad: '',
    telefono: '',
    email: '',
    tipo: '',
    nombreActividad: '',
    start: undefined,
    end: undefined,
    dia: '',
    horaInicio: '',
    horaFin: '',
  };
  const mockSolicitudRepository = { (2)
    find: jest.fn((id) => {
      return Promise.resolve([solicitud]);
    }),
    findOne: jest.fn((id) => {
      return Promise.resolve(solicitud);
    }),
    save: jest.fn((id) => {
      return Promise.resolve(solicitud);
    }),
    delete: jest.fn(),
  };

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [SolicitudModule],
    })
      .overrideGuard(AuthGuard('jwt'))
      .useValue('')
      .overrideProvider(getRepositoryToken(Solicitud))
      .useValue(mockSolicitudRepository)
      .compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/solicitud (GET)', async () => {
    return request(app.getHttpServer()).get('/solicitud').expect(200);
  });

  it('/solicitud/1 (GET)', async () => { (3)
    return request(app.getHttpServer()).get('/solicitud/1').expect(200);
  });

  it('/solicitud (POST)', async () => {
    return request(app.getHttpServer()).post('/solicitud').expect(201);
  });

  it('/solicitud/1 (PATCH)', async () => {
    return request(app.getHttpServer()).patch('/solicitud/1').expect(200);
  });

  it('/solicitud/1 (DELETE)', async () => {
    return request(app.getHttpServer()).delete('/solicitud/1').expect(200);
  });
});
1 Objeto de ejemplo que representa a una solicitud
2 Conjunto de funciones para mockeo del repositorio con una implementación mínima
3 Nuevos tests para el resto de endpoints

A la hora de realizar una implementación mínima de cada función mockeada del repositorio lo haremos de acuerdo con su funcionamiento esperado. Esto va dirigido a devolver el código de estado HTTP correspondiente, devolver los datos de su contrato y demás.

Tras estos cambios, ya tenemos los tests pasando correctamente.

 PASS  test/solicitud.e2e-spec.ts
  SolicitudController (e2e)
    ✓ /solicitud (GET) (188 ms)
    ✓ /solicitud/1 (GET) (10 ms)
    ✓ /solicitud (POST) (8 ms)
    ✓ /solicitud/1 (PATCH) (7 ms)
    ✓ /solicitud/1 (DELETE) (7 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        4.228 s, estimated 7 s
Ran all test suites related to changed files.

3.4.4. Tercer paso. Llevar el mock del repositorio a una clase

Hasta ahora hemos mockeado el repositorio en la misma clase de testing. Aquí veremos cómo refactorizar el archivo de testing sacando el mock a una clase aparte. Concretamente se trata de llevar el contenido de los métodos de mockSolicitudRepository de test/solicitud.e2e-spec.ts a métodos en una clase aparte. Recordemos que ya contábamos con una clase SolicitudRepositoryMock que habíamos dejado a medias cuando creamos los tests del servicio. Esa es la clase en la que situaremos todos las funciones mockeadas. A continuación se muestra cómo quedaría la clase y el archivo de testing.

Archivo solicitud/solicitud-repository-mock.ts

import { Solicitud } from './entities/solicitud.entity';
export class SolicitudRepositoryMock {
  mockSolicitud: Solicitud = { (1)
    id: 1,
    nombre: 'John Doe',
    cargo: 'Assistant Professor',
    unidad: 'Informatics Department',
    telefono: '1234',
    email: 'john.doe@gmail.com',
    tipo: '',
    nombreActividad: '',
    start: undefined,
    end: undefined,
    dia: '',
    horaInicio: '',
    horaFin: '',
  };

  save(solicitud: Solicitud): Promise<Solicitud> {
    return Promise.resolve(this.mockSolicitud); (2)
  }

  findOne(id: number): Promise<Solicitud> {
    return Promise.resolve(this.mockSolicitud); (3)
  }

  find(): Promise<Solicitud[]> { (4)
    return Promise.resolve([this.mockSolicitud]);
  }

  delete(id: number): Promise<any> { (5)
    return Promise.resolve();
  }
}
1 Objeto de solicitud de ejemplo
2 Modificación de la implementación para que devuelva la solicitud de ejemplo
3 Modificación de la implementación para que devuelva la solicitud de ejemplo
4 Función que devuelva un array de solicitudes con la solicitud de ejemplo
5 Devolver una promesa. Debería mejorarse su implementación

En el código del archivo de testing se ha eliminado el objeto de ejemplo de solicitud así como el que mockeaba el repositorio, que ahora ha sido sustituido por una clase.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { SolicitudModule } from '../src/solicitud/solicitud.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Solicitud } from '../src/solicitud/entities/solicitud.entity';
import { AuthGuard } from '@nestjs/passport';
import { SolicitudRepositoryMock } from '../src/solicitud/solicitud-repository-mock';

describe('SolicitudController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [SolicitudModule],
    })
      .overrideGuard(AuthGuard('jwt'))
      .useValue('')
      .overrideProvider(getRepositoryToken(Solicitud))
      .useClass(SolicitudRepositoryMock) (1)
      .compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/solicitud (GET)', async () => {
    return request(app.getHttpServer()).get('/solicitud').expect(200);
  });

  it('/solicitud/1 (GET)', async () => {
    return request(app.getHttpServer()).get('/solicitud/1').expect(200);
  });

  it('/solicitud (POST)', async () => {
    return request(app.getHttpServer()).post('/solicitud').expect(201);
  });

  it('/solicitud/1 (PATCH)', async () => {
    return request(app.getHttpServer()).patch('/solicitud/1').expect(200);
  });

  it('/solicitud/1 (DELETE)', async () => {
    return request(app.getHttpServer()).delete('/solicitud/1').expect(200);
  });
});
1 Uso de la clase que mockea los métodos del repositorio

Tras estos cambios los tests siguen pasando correctamente.

 PASS  test/solicitud.e2e-spec.ts
  SolicitudController (e2e)
    ✓ /solicitud (GET) (188 ms)
    ✓ /solicitud/1 (GET) (10 ms)
    ✓ /solicitud (POST) (8 ms)
    ✓ /solicitud/1 (PATCH) (7 ms)
    ✓ /solicitud/1 (DELETE) (7 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        4.228 s, estimated 7 s
Ran all test suites related to changed files.

3.5. Cobertura de tests

Tras la implementación de tests que hemos realizado, tanto los unitarios sobre controladores y servicios, como los de integración, veamos la cobertura de tests que tenemos.

Antes de lanzar el análisis de cobertura configuraremos jest en package.json para excluir los archivos de módulo del proceso de análisis de cobertura.

...
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "coveragePathIgnorePatterns": [".module.ts"], (1)
    "testEnvironment": "node"
  }
...
1 Ignorar del proceso de cobertura los archivos cuyo nombre termine en .module.ts

A continuación lanzamos la cobertura de tests:

$ npm run test:cov

Si abrimos el informe generado en coverage/lcov-report/index.html vemos una cobertura superior al 80%.

CoberturaInicialSolicitud

Si ahora vemos la cobertura del controlador vemos que los métodos que están probados son los de creación y actualización. Sin embargo, los métodos de búsqueda y el de eliminación aún no tienen ningún caso de prueba asociado

CoberturaInicialSolicitudController

3.6. Creación del resto de tests

En esta sección implementaremos el restos de test para búsqueda de una solicitud, búsqueda de todas y eliminación. La forma de proceder sería la misma que hemos seguido a lo largo del tutorial:

  1. Para el controlador

    1. Crear el caso de prueba en el controlador. Al ejecutarlo indicará que no existe método en el servicio.

    2. Implementar el método en el mock del servicio para que de soporte al test del controlador.

  2. Para el servicio

    1. Crear el caso de prueba en el servicio.

    2. Implementar en el repositorio los métodos mockeados para que funcione el servicio

Como el repositorio ya lo tenemos mockeado de cuando hicimos los Tests end to end, ya no habrá que mockear sus funciones y los tests del servicio funcionarán directamente. No darán el fallo de que las funciones del repositorio no están definidas.

Archivo solicitud/solicitud.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { SolicitudController } from './solicitud.controller';
import { SolicitudService } from './solicitud.service';
import { CreateSolicitudDto } from './dto/create-solicitud.dto';
import { UpdateSolicitudDto } from './dto/update-solicitud.dto';
import { of } from 'rxjs';
import { SolicitudServiceMock } from './solicitud-service-mock';
import { Solicitud } from './entities/solicitud.entity';

describe('SolicitudController', () => {
  let controller: SolicitudController;
  let service: SolicitudService;
  const mockSolicitud: Solicitud = { (1)
    id: 1,
    nombre: 'John Doe',
    cargo: 'Assistant Professor',
    unidad: 'Informatics Department',
    telefono: '1234',
    email: 'john.doe@gmail.com',
    tipo: '',
    nombreActividad: '',
    start: undefined,
    end: undefined,
    dia: '',
    horaInicio: '',
    horaFin: '',
  };

  beforeEach(async () => {
    const SolicitudServiceProvider = {
      provide: SolicitudService,
      useClass: SolicitudServiceMock,
    };

    const module: TestingModule = await Test.createTestingModule({
      controllers: [SolicitudController],
      providers: [SolicitudService, SolicitudServiceProvider],
    })
      .overrideProvider(SolicitudService)
      .useClass(SolicitudServiceMock)
      .compile();

    controller = module.get<SolicitudController>(SolicitudController);
    service = module.get<SolicitudService>(SolicitudService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  it('should create a solicitud', async () => {
    const createSolicitudDto: CreateSolicitudDto = {
      nombre: 'John Doe',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };

    expect(await controller.create(createSolicitudDto)).toEqual({
      id: expect.any(Number),
      ...createSolicitudDto,
    });
  });

  it('should update a solicitud', async () => {
    const updateSolicitudDto: UpdateSolicitudDto = {
      nombre: 'John Smith',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };
    const solicitudId = 1;

    expect(await controller.update(solicitudId, updateSolicitudDto)).toEqual({
      id: solicitudId,
      ...updateSolicitudDto,
    });

    const updateSpy = jest.spyOn(service, 'update');
    controller.update(solicitudId, updateSolicitudDto);

    expect(updateSpy).toHaveBeenCalledWith(solicitudId, updateSolicitudDto);
  });

  it('should find a solicitud', async () => { (2)
    const solicitudId = 2;
    expect(await (await controller.findOne(solicitudId)).id).toEqual(
      solicitudId,
    );
  });

  it('should find all solicitudes', async () => { (3)
    expect(await controller.findAll()).toEqual([mockSolicitud]);
  });

  it('should delete a solicitud', async () => { (4)
    expect((await controller.remove(1))['affected']).toEqual(1);
  });
});
1 Objeto de ejemplo de solicitud
2 Test para recuperar una solicitud. Se comprueba que la solicitud recuperada tiene el mismo id que se ha buscado
3 Test para recuperar todas las solicitudes. Se comprueba que vienen en un array
4 En la eliminación se comprueba que devuelve 1 en el campo affected

Archivo solicitud/solicitud-service-mock.ts

import { CreateSolicitudDto } from './dto/create-solicitud.dto';
import { Solicitud } from './entities/solicitud.entity';
import { UpdateSolicitudDto } from './dto/update-solicitud.dto';
export class SolicitudServiceMock {
  mockSolicitud: Solicitud = { (1)
    id: 1,
    nombre: 'John Doe',
    cargo: 'Assistant Professor',
    unidad: 'Informatics Department',
    telefono: '1234',
    email: 'john.doe@gmail.com',
    tipo: '',
    nombreActividad: '',
    start: undefined,
    end: undefined,
    dia: '',
    horaInicio: '',
    horaFin: '',
  };

  async create(createSolicitudDto: CreateSolicitudDto): Promise<Solicitud> {
    return Promise.resolve({
      id: Math.random() * (1000 - 1) + 1,
      ...createSolicitudDto,
    });
  }

  async update(
    id: number,
    updateSolicitudDto: UpdateSolicitudDto,
  ): Promise<Solicitud> {
    return Promise.resolve({
      id: id,
      ...updateSolicitudDto,
    }) as Promise<Solicitud>;
  }

  async findOne(id: number): Promise<Solicitud> { (2)
    this.mockSolicitud.id = id;
    return Promise.resolve(this.mockSolicitud);
  }

  async findAll(): Promise<Solicitud[]> { (3)
    return Promise.resolve([this.mockSolicitud]);
  }

  async remove(id: number): Promise<any> { (4)
    return Promise.resolve({
      raw: [],
      affected: 1,
    });
  }
}
1 Objeto de solicitud de prueba
2 Devolver una solicitud con el id proporcionado
3 Devolver un array de solicitudes
4 Devolver lo que devuelve TypeORM al eliminar un objeto de la base de datos

Si cambiamos el patrón del watch a solicitud.co para que pase los tests al controlador veremos que los tests pasan correctamente.

 PASS  src/solicitud/solicitud.controller.spec.ts
  SolicitudController
    ✓ should be defined (14 ms)
    ✓ should create a solicitud (4 ms)
    ✓ should update a solicitud (5 ms)
    ✓ should find a solicitud (3 ms)
    ✓ should find all solicitudes (3 ms)
    ✓ should delete a solicitud (2 ms)

Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        5.208 s, estimated 7 s
Ran all test suites matching /solicitud.co/i.

A continuación se muestran los tests implementados en el servicio.

Archivo solicitud/solicitud.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { SolicitudService } from './solicitud.service';
import { Solicitud } from './entities/solicitud.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CreateSolicitudDto } from './dto/create-solicitud.dto';
import { UpdateSolicitudDto } from './dto/update-solicitud.dto';
import { SolicitudRepositoryMock } from './solicitud-repository-mock';

describe('SolicitudService', () => {
  let service: SolicitudService;
  const mockSolicitud: Solicitud = { (1)
    id: 1,
    nombre: 'John Doe',
    cargo: 'Assistant Professor',
    unidad: 'Informatics Department',
    telefono: '1234',
    email: 'john.doe@gmail.com',
    tipo: '',
    nombreActividad: '',
    start: undefined,
    end: undefined,
    dia: '',
    horaInicio: '',
    horaFin: '',
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        SolicitudService,
        {
          provide: getRepositoryToken(Solicitud),
          useClass: SolicitudRepositoryMock,
        },
      ],
    }).compile();

    service = module.get<SolicitudService>(SolicitudService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should create a solicitud', async () => {
    const createSolicitudDto: CreateSolicitudDto = {
      nombre: 'John Doe',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };

    expect(await service.create(createSolicitudDto)).toEqual({
      id: expect.any(Number),
      ...createSolicitudDto,
    });
  });

  it('should update a solicitud', async () => {
    const updateSolicitudDto: UpdateSolicitudDto = {
      nombre: 'John Smith',
      cargo: 'Assistant Professor',
      unidad: 'Informatics Department',
      telefono: '1234',
      email: 'john.doe@gmail.com',
      tipo: '',
      nombreActividad: '',
      start: undefined,
      end: undefined,
      dia: '',
      horaInicio: '',
      horaFin: '',
    };
    const solicitudId = 1;

    expect(await service.update(solicitudId, updateSolicitudDto)).toEqual({
      id: solicitudId,
      ...updateSolicitudDto,
    });
  });

  it('should find a solicitud', async () => { (2)
    const solicitudId = 2;
    expect(await (await service.findOne(solicitudId)).id).toEqual(solicitudId);
  });

  it('should find all solicitudes', async () => { (3)
    expect(await service.findAll()).toEqual([mockSolicitud]);
  });

  it('should delete a solicitud', async () => { (4)
    expect(await service.remove(1)).toBeDefined;
  });
});
1 Objeto de ejemplo de solicitud
2 Test para recuperar una solicitud. Se comprueba que la solicitud recuperada tiene el mismo id que se ha buscado
3 Test para recuperar todas las solicitudes. Se comprueba que vienen en un array
4 En la eliminación se comprueba únicamente que ha sido llamado

La implementación del test de eliminación así como su función mockeada en el repositorio son demasiado elementales. Convendría mejorarlas.

Si ahora cambiamos el patrón de archivo el modo watch a solicitud.se se pasarán los tests al servicio y obtendremos los resultados siguientes.

 PASS  src/solicitud/solicitud.service.spec.ts
  SolicitudService
    ✓ should be defined (13 ms)
    ✓ should create a solicitud (4 ms)
    ✓ should update a solicitud (3 ms)
    ✓ should find a solicitud (2 ms)
    ✓ should find all solicitudes (3 ms)
    ✓ should delete a solicitud (2 ms)

Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        4.262 s
Ran all test suites matching /solicitud.se/i.

Si ahora volver a pasar la cobertura de tests con

$ npm run test:cov

obtenemos que las solicitudes ya tienen una cobertura del 100%, tal y como muestra la parte inferior de la figura siguiente.

Cobertura100Solicitud

Anexo I. Plugin Coverage Gutters

Coverage Gutters es un plugin para Visual Studio Code que permite ver la cobertura de cada archivo mostrando su porcentaje en la barra de estado e indicando en verde y rojo sobre el código las líneas probadas por los tests y las no probadas.

Tras instalarlo aparecerá un botón Watch en la barra de estado que podremos activar para que muestre la cobertura del archivo abierto.

CoverageGuttersBoton

Tras pulsar el botón, si abrimos un archivo nos mostrará la cobertura directamente en Visual Studio Code. La figura siguiente muestra en verde las líneas probadas, en rojo las líneas no probadas y el porcentaje de cobertura en la barra de estado (50%).

CoverageGuttersWatch