Una guía de Node.js para realizar pruebas de integración

Publicado: 2022-03-11

Las pruebas de integración no son algo que se deba temer. Son una parte esencial de tener su aplicación completamente probada.

Cuando hablamos de pruebas, generalmente pensamos en pruebas unitarias en las que probamos una pequeña parte del código de forma aislada. Sin embargo, su aplicación es más grande que ese pequeño fragmento de código y casi ninguna parte de su aplicación funciona de forma aislada. Aquí es donde las pruebas de integración demuestran su importancia. Las pruebas de integración se recuperan donde las pruebas unitarias se quedan cortas y cierran la brecha entre las pruebas unitarias y las pruebas de un extremo a otro.

Sabes que necesitas escribir pruebas de integración, entonces, ¿por qué no lo haces?
Pío

En este artículo, aprenderá a escribir pruebas de integración legibles y componibles con ejemplos en aplicaciones basadas en API.

Si bien usaremos JavaScript/Node.js para todos los ejemplos de código en este artículo, la mayoría de las ideas discutidas se pueden adaptar fácilmente a las pruebas de integración en cualquier plataforma.

Pruebas unitarias frente a pruebas de integración: necesita ambas

Las pruebas unitarias se enfocan en una unidad particular de código. A menudo, este es un método específico o una función de un componente más grande.

Estas pruebas se realizan de forma aislada, donde todas las dependencias externas generalmente se bloquean o se burlan.

En otras palabras, las dependencias se reemplazan con un comportamiento preprogramado, lo que garantiza que el resultado de la prueba solo esté determinado por la corrección de la unidad que se está probando.

Puede obtener más información sobre las pruebas unitarias aquí.

Las pruebas unitarias se utilizan para mantener un código de alta calidad con un buen diseño. También nos permiten cubrir fácilmente las esquinas.

Sin embargo, el inconveniente es que las pruebas unitarias no pueden cubrir la interacción entre los componentes. Aquí es donde las pruebas de integración se vuelven útiles.

Pruebas de integración

Si las pruebas unitarias se definen probando las unidades de código más pequeñas de forma aislada, las pruebas de integración son todo lo contrario.

Las pruebas de integración se utilizan para probar varias unidades más grandes (componentes) en interacción y, a veces, incluso pueden abarcar varios sistemas.

El propósito de las pruebas de integración es encontrar errores en las conexiones y dependencias entre varios componentes, tales como:

  • Pasar argumentos inválidos o ordenados incorrectamente
  • Esquema de base de datos roto
  • Integración de caché no válida
  • Defectos en la lógica empresarial o errores en el flujo de datos (porque ahora las pruebas se realizan desde una perspectiva más amplia).

Si los componentes que estamos probando no tienen ninguna lógica complicada (por ejemplo, componentes con una complejidad ciclomática mínima), las pruebas de integración serán mucho más importantes que las pruebas unitarias.

En este caso, las pruebas unitarias se utilizarán principalmente para hacer cumplir un buen diseño de código.

Mientras que las pruebas unitarias ayudan a garantizar que las funciones se escriban correctamente, las pruebas de integración ayudan a garantizar que el sistema funcione correctamente como un todo. Por lo tanto, tanto las pruebas unitarias como las pruebas de integración cumplen cada una su propio propósito complementario, y ambas son esenciales para un enfoque de prueba integral.

Las pruebas unitarias y las pruebas de integración son como dos caras de la misma moneda. La moneda no es válida sin ambos.

Por lo tanto, la prueba no está completa hasta que haya completado tanto la integración como las pruebas unitarias.

Configurar la Suite para Pruebas de Integración

Si bien configurar un conjunto de pruebas para pruebas unitarias es bastante sencillo, configurar un conjunto de pruebas para pruebas de integración suele ser más desafiante.

Por ejemplo, los componentes de las pruebas de integración pueden tener dependencias que están fuera del proyecto, como bases de datos, sistemas de archivos, proveedores de correo electrónico, servicios de pago externos, etc.

Ocasionalmente, las pruebas de integración necesitan usar estos servicios y componentes externos y, a veces, se pueden bloquear.

Cuando se necesitan, puede conducir a varios desafíos.

  • Ejecución de prueba frágil: los servicios externos pueden no estar disponibles, devolver una respuesta no válida o estar en un estado no válido. En algunos casos, esto puede resultar en un falso positivo, otras veces puede resultar en un falso negativo.
  • Ejecución lenta: la preparación y conexión a servicios externos puede ser lenta. Por lo general, las pruebas se ejecutan en un servidor externo como parte de CI.
  • Configuración de prueba compleja: los servicios externos deben estar en el estado deseado para la prueba. Por ejemplo, la base de datos debe estar precargada con los datos de prueba necesarios, etc.

Instrucciones a seguir al escribir pruebas de integración

Las pruebas de integración no tienen reglas estrictas como las pruebas unitarias. A pesar de esto, hay algunas instrucciones generales a seguir cuando se escriben pruebas de integración.

Pruebas repetibles

El orden de prueba o las dependencias no deberían alterar el resultado de la prueba. Ejecutar la misma prueba varias veces siempre debería arrojar el mismo resultado. Esto puede ser difícil de lograr si la prueba utiliza Internet para conectarse a servicios de terceros. Sin embargo, este problema se puede solucionar mediante stubing y burlas.

Para las dependencias externas sobre las que tiene más control, la configuración de pasos antes y después de una prueba de integración ayudará a garantizar que la prueba siempre se ejecute a partir de un estado idéntico.

Prueba de acciones relevantes

Para probar todos los casos posibles, las pruebas unitarias son una opción mucho mejor.

Las pruebas de integración están más orientadas a la conexión entre módulos, por lo tanto, probar escenarios felices suele ser el camino a seguir porque cubrirá las conexiones importantes entre módulos.

Prueba comprensible y aserción

Una vista rápida de la prueba debe informar al lector qué se está probando, cómo se configura el entorno, qué se bloquea, cuándo se ejecuta la prueba y qué se afirma. Las aserciones deben ser simples y hacer uso de ayudantes para una mejor comparación y registro.

Fácil configuración de prueba

Llevar la prueba al estado inicial debe ser lo más simple y comprensible posible.

Evite probar código de terceros

Si bien se pueden usar servicios de terceros en las pruebas, no es necesario probarlos. Y si no confía en ellos, probablemente no debería usarlos.

Deje el código de producción libre de código de prueba

El código de producción debe ser limpio y directo. Mezclar el código de prueba con el código de producción dará como resultado que se acoplen dos dominios no conectables.

Registro relevante

Las pruebas fallidas no son muy valiosas sin un buen registro.

Cuando pasan las pruebas, no se necesita ningún registro adicional. Pero cuando fallan, el registro extensivo es vital.

El registro debe contener todas las consultas de la base de datos, las solicitudes de API y las respuestas, así como una comparación completa de lo que se afirma. Esto puede facilitar significativamente la depuración.

Las buenas pruebas se ven claras y comprensibles

Una prueba simple que sigue las pautas aquí detalladas podría verse así:

 const co = require('co'); const test = require('blue-tape'); const factory = require('factory'); const superTest = require('../utils/super_test'); const testEnvironment = require('../utils/test_environment_preparer'); const path = '/v1/admin/recipes'; test(`API GET ${path}`, co.wrap(function* (t) { yield testEnvironment.prepare(); const recipe1 = yield factory.create('recipe'); const recipe2 = yield factory.create('recipe'); const serverResponse = yield superTest.get(path); t.deepEqual(serverResponse.body, [recipe1, recipe2]); }));

El código anterior está probando una API ( GET /v1/admin/recipes ) que espera que devuelva una serie de recetas guardadas como respuesta.

Puede ver que la prueba, por simple que sea, se basa en muchas utilidades. Esto es común para cualquier buen conjunto de pruebas de integración.

Los componentes auxiliares facilitan la escritura de pruebas de integración comprensibles.

Revisemos qué componentes se necesitan para las pruebas de integración.

Componentes auxiliares

Un conjunto de pruebas integral tiene algunos ingredientes básicos, que incluyen: control de flujo, marco de prueba, controlador de base de datos y una forma de conectarse a las API de back-end.

Control de flujo

Uno de los mayores desafíos en las pruebas de JavaScript es el flujo asíncrono.

Las devoluciones de llamada pueden causar estragos en el código y las promesas simplemente no son suficientes. Aquí es donde los ayudantes de flujo se vuelven útiles.

Mientras se espera que async/await sea totalmente compatible, se pueden usar bibliotecas con un comportamiento similar. El objetivo es escribir código legible, expresivo y robusto con la posibilidad de tener un flujo asíncrono.

Co permite que el código se escriba de una manera agradable mientras lo mantiene sin bloqueos. Esto se hace definiendo una función cogeneradora y luego arrojando resultados.

Otra solución es utilizar Bluebird. Bluebird es una biblioteca prometedora que tiene características muy útiles como el manejo de matrices, errores, tiempo, etc.

Co y Bluebird coroutine se comportan de manera similar a async/await en ES7 (esperando la resolución antes de continuar), la única diferencia es que siempre devolverá una promesa, que es útil para manejar errores.

Marco de prueba

Elegir un marco de prueba se reduce a preferencias personales. Mi preferencia es un marco que sea fácil de usar, no tenga efectos secundarios y cuya salida sea fácil de leer y canalizar.

Hay una amplia gama de marcos de prueba en JavaScript. En nuestros ejemplos, estamos usando Tape. Tape, en mi opinión, no solo cumple con estos requisitos, sino que también es más limpio y simple que otros marcos de prueba como Mocha o Jasmin.

La cinta se basa en el Protocolo Test Anything (TAP).

TAP tiene variaciones para la mayoría de los lenguajes de programación.

La cinta toma las pruebas como entrada, las ejecuta y luego muestra los resultados como un TAP. Luego, el resultado de TAP se puede canalizar al reportero de prueba o se puede enviar a la consola en un formato sin procesar. La cinta se ejecuta desde la línea de comandos.

Tape tiene algunas características interesantes, como definir un módulo para cargar antes de ejecutar todo el conjunto de pruebas, proporcionar una biblioteca de aserciones pequeña y simple y definir la cantidad de aserciones que se deben llamar en una prueba. El uso de un módulo para precargar puede simplificar la preparación de un entorno de prueba y eliminar cualquier código innecesario.

Biblioteca de fábrica

Una biblioteca de fábrica le permite reemplazar sus archivos de dispositivos estáticos con una forma mucho más flexible de generar datos para una prueba. Dicha biblioteca le permite definir modelos y crear entidades para esos modelos sin escribir código complejo y desordenado.

JavaScript tiene factory_girl para esto: una biblioteca inspirada en una gema con un nombre similar, que se desarrolló originalmente para Ruby on Rails.

 const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); const user = factory.build('user');

Para comenzar, se debe definir un nuevo modelo en factory_girl.

Se especifica con un nombre, un modelo de su proyecto y un objeto a partir del cual se genera una nueva instancia.

Alternativamente, en lugar de definir el objeto a partir del cual se genera una nueva instancia, se puede proporcionar una función que devolverá un objeto o una promesa.

Al crear una nueva instancia de un modelo, podemos:

  • Anular cualquier valor en la instancia recién generada
  • Pase valores adicionales a la opción de función de compilación

Veamos un ejemplo.

 const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, (buildOptions) => { return { name: 'Mike', surname: 'Dow', email: buildOptions.email || '[email protected]' } }); const user1 = factory.build('user'); // {"name": "Mike", "surname": "Dow", "email": "[email protected]"} const user2 = factory.build('user', {name: 'John'}, {email: '[email protected]'}); // {"name": "John", "surname": "Dow", "email": "[email protected]"}

Conexión a las API

Iniciar un servidor HTTP completo y realizar una solicitud HTTP real, solo para desmantelarlo unos segundos más tarde, especialmente cuando se realizan varias pruebas, es totalmente ineficiente y puede hacer que las pruebas de integración tarden mucho más de lo necesario.

SuperTest es una biblioteca de JavaScript para llamar a las API sin crear un nuevo servidor activo. Se basa en SuperAgent, una biblioteca para crear solicitudes TCP. Con esta biblioteca, no hay necesidad de crear nuevas conexiones TCP. Las API se llaman casi instantáneamente.

SuperTest, con soporte para promesas, es supertest como se prometió. Cuando una solicitud de este tipo devuelve una promesa, le permite evitar múltiples funciones de devolución de llamada anidadas, lo que facilita mucho el manejo del flujo.

 const express = require('express') const request = require('supertest-as-promised'); const app = express(); request(app).get("/recipes").then(res => assert(....));

SuperTest se creó para el marco Express.js, pero con pequeños cambios también se puede usar con otros marcos.

Otras utilidades

En algunos casos, existe la necesidad de burlarse de alguna dependencia en nuestro código, probar la lógica de las funciones usando espías o usar stubs en ciertos lugares. Aquí es donde algunos de estos paquetes de utilidades resultan útiles.

SinonJS es una gran biblioteca que admite espías, stubs y simulacros para pruebas. También es compatible con otras funciones de prueba útiles, como el tiempo de flexión, la zona de pruebas de prueba y la afirmación ampliada, así como servidores y solicitudes falsos.

En algunos casos, es necesario simular alguna dependencia en nuestro código. Las referencias a los servicios que nos gustaría simular son utilizadas por otras partes del sistema.

Para resolver este problema, podemos usar la inyección de dependencia o, si esa no es una opción, podemos usar un servicio de simulación como Mockery.

Mockery ayuda a burlarse de código que tiene dependencias externas. Para usarlo correctamente, se debe llamar a Mockery antes de cargar pruebas o código.

 const mockery = require('mockery'); mockery.enable({ warnOnReplace: false, warnOnUnregistered: false }); const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);

Con esta nueva referencia (en este ejemplo, mockingStripe ), es más fácil simular servicios más adelante en nuestras pruebas.

 const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null));

Con la ayuda de la biblioteca de Sinon, es fácil burlarse. El único problema aquí es que este código auxiliar se propagará a otras pruebas. Para ponerlo en una caja de arena, se puede usar la caja de arena sinon. Con él, las pruebas posteriores pueden devolver el sistema a su estado inicial.

 const sandbox = require('sinon').sandbox.create(); const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null)); // after the test, or better when starting a new test sandbox.restore();

Hay una necesidad de otros componentes para funciones como:

  • Vaciar la base de datos (se puede hacer con una consulta preconstruida de jerarquía)
  • Configurándolo en estado de trabajo (sequelize-fixtures)
  • Simulacro de solicitudes TCP a servicios de terceros (nock)
  • Usar afirmaciones más ricas (chai)
  • Respuestas guardadas de terceros (solución fácil)

Pruebas no tan simples

La abstracción y la extensibilidad son elementos clave para crear un conjunto de pruebas de integración eficaz. Todo lo que quita el foco del núcleo de la prueba (preparación de sus datos, acción y afirmación) debe agruparse y abstraerse en funciones de utilidad.

Aunque aquí no hay un camino correcto o incorrecto, ya que todo depende del proyecto y sus necesidades, algunas cualidades clave siguen siendo comunes a cualquier buen conjunto de pruebas de integración.

El siguiente código muestra cómo probar una API que crea una receta y envía un correo electrónico como efecto secundario.

Cierra el proveedor de correo electrónico externo para que pueda probar si se habría enviado un correo electrónico sin enviarlo realmente. La prueba también verifica si la API respondió con el código de estado apropiado.

 const co = require('co'); const factory = require('factory'); const superTest = require('../utils/super_test'); const basicEnv = require('../utils/basic_test_enivornment'); const path = '/v1/admin/recipes'; basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { const chef = yield factory.create('chef'); const body = { chef_id: chef.id, recipe_name: 'cake', Ingredients: ['carrot', 'chocolate', 'biscuit'] }; const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null); const serverResponse = yield superTest.get(path, body); assert.spies(stub).called(1); assert.statusCode(serverResponse, 201); }));

La prueba anterior es repetible ya que comienza con un entorno limpio cada vez.

Tiene un proceso de configuración simple, donde todo lo relacionado con la configuración se consolida dentro de la función basicEnv.test .

Prueba solo una acción: una sola API. Y establece claramente las expectativas de la prueba a través de afirmaciones simples. Además, la prueba no involucra código de terceros mediante stubbing/simulacros.

Comience a escribir pruebas de integración

Al enviar código nuevo a producción, los desarrolladores (y todos los demás participantes del proyecto) quieren estar seguros de que las nuevas funciones funcionarán y las antiguas no se estropearán.

Esto es muy difícil de lograr sin pruebas y, si se hace mal, puede generar frustración, fatiga del proyecto y, finalmente, el fracaso del proyecto.

Las pruebas de integración, combinadas con las pruebas unitarias, son la primera línea de defensa.

Usar solo uno de los dos es insuficiente y dejará mucho espacio para errores descubiertos. Utilizar siempre ambos hará que los nuevos compromisos sean sólidos y brindará confianza e inspirará confianza en todos los participantes del proyecto.