Las pruebas unitarias son vitales para el desarrollo de software. En especial cuando desarrollamos aplicaciones de gran tamaño, debemos asegurarnos de que cada una de sus unidades funciona como se espera.
Por definición, las pruebas unitarias nos ayudan a asegurarnos de que una unidad de código en específico hace lo que debe de hacer. Por lo tanto, para crear pruebas unitarias sólidas debemos asegurarnos de aislar debidamente la pieza de código que queremos probar. No queremos que otros módulos (las dependencias) interfieran con el resultado de la unidad que estamos probando.
Sin embargo, puede resultar un trabajo complicado simular cada dependencia requerida por un módulo. Aquí es donde Proxyquire entra en acción.
Proxyquire es un módulo bastante útil, el cuál nos permite sustituir todas las dependencias requeridas por la unidad que estemos probando al importar dicha unidad. Si lo combinamos con Sinon, otro módulo de Node JS para pruebas, nos resultará mucho más fácil simular todas las dependencias y, por lo tanto, enfocarnos únicamente en el comportamiento del módulo que estamos probando.
Cómo funciona
Veamos un ejemplo para ilustrar cómo trabajan en conjunto Proxyquire y Sinon. Imaginemos que necesitamos desarrollar un módulo que lea/escriba la configuración de los usuarios desde/hacia un archivo. Vamos a crear ese módulo y sus respectivas pruebas unitarias.
El código (simplificado) para este gestor de configuraciones quedaría más o menos como sigue:
const fs = require('fs/promises');
module.exports = () => {
const load = async () => {
try {
const rawSettings = await fs.readFile('settings.json');
return JSON.parse(rawSettings);
} catch (error) {
return {};
}
};
const save = async (settings = {}) => {
const settingsStr = JSON.stringify(settings);
fs.writeFile('settings.json', settingsStr);
};
return {
load,
save
};
};
Como se observa, nuestro gestor de configuraciones depende del módulo fs de Node JS. Por lo tanto, el comportamiento de este módulo externo podría afectar los resultados de las pruebas unitarias que realicemos para este módulo que acabamos de crear. Idealmente, buscaríamos evitar esto, ya que: 1) el comportamiento del módulo fs (o cualquier otro módulo que nuestra unidad a probar requiera) está completamente fuera de nuestras manos (por ejemplo, conexiones a APIs externas, bases de datos o sistemas de archivos, como en este ejemplo) y 2) necesitamos enfocarnos en probar una única unidad (y no en sus dependencias).
Ahora veamos cómo podemos probar nuestro módulo sustituyendo sus dependencias y evitando así comportamientos no deseados.
(Nota: para este ejemplo, utilizaremos Mocha + Chai como marco + biblioteca para pruebas unitarias. Luego, haremos uso de Sinon y Proxyquire para sustituir las dependencias del gestor de configuraciones y probarlo de manera aislada.)
Pero primero lo primero: para instalar todas las dependencias de desarrollo que vamos a usar para las pruebas podemos usar el siguiente comando:
npm install mocha chai sinon sinon-chai proxyquire
Después, viene el código:
const chai = require('chai');
chai.use(require('sinon-chai'));
const { expect } = chai;
const sinon = require('sinon');
const proxyquire = require('proxyquire').noCallThru();
describe('Settings manager', () => {
const settingsPath = 'settings.json';
let settingsFake;
let fsFake;
let settingsManager;
beforeEach(() => {
settingsFake = {
language: 'English'
};
// [1] Con Sinon, se crea un doble del módulo 'fs'.
fsFake = {
// [2] Se crea un doble de la función 'readFile'.
readFile: sinon.stub().callsFake(path => JSON.stringify(settingsFake)),
// [3] Se crea un doble de la función 'writeFile'.
writeFile: sinon.stub().callsFake((path, settings) => {
settingsFake = JSON.parse(settings);
})
};
// [4] Se carga el módulo gestor de archivos con Proxyquire.
settingsManager = proxyquire('../../settingsManager', {
'fs/promises': fsFake
})();
});
it('loads settings', async () => {
const settings = await settingsManager.load();
// Se revisa que la función haya sido llamada correctamente.
expect(fsFake.readFile).to.have.been.calledOnceWith(settingsPath);
// Se revisa que las configuraciones regresadas sean correctas.
expect(settings).to.be.an('object').that.deep.equals(settingsFake);
});
it('saves settings', async () => {
const defaultSettings = {
language: 'English'
};
const updatedSettings = {
language: 'Japanese'
};
const updatedSettingsStr = JSON.stringify(updatedSettings);
// Primero, se revisa que las configuraciones originales coincidan con las configuraciones por defecto.
let settings = await settingsManager.load();
expect(settings).to.be.an('object').that.deep.equals(defaultSettings);
// Luego, se actualizan las configuraciones...
await settingsManager.save(updatedSettings);
// ...y se revisa que la función haya sido llamada correctamente.
expect(fsFake.writeFile).to.have.been.calledOnceWith(settingsPath, updatedSettingsStr);
// Finalmente, se revisa que las configuraciones hayan sido actualizadas correctamente.
settings = await settingsManager.load();
expect(settings).to.be.an('object').that.deep.equals(updatedSettings);
});
});
Aquí una pequeña explicación de lo que sucede en el código:
Primero, creamos un módulo fs falso con Sinon (1), el cuál contiene versiones simplificadas de las funciones que utiliza nuestro gestor de configuraciones. Creamos una función readFile (2) con comportamiento simplificado: recibe una ruta como parámetro (el cuál es completamente ignorado) y únicamente regresa el objeto falso de configuraciones convertido a cadena. También creamos una función writeFile (3) con un comportamiento, igualmente, simplificado: recibe una ruta y las configuraciones a almacenar como parámetros y, simplemente, actualiza el objeto falso de configuraciones con las configuraciones recibidas.
Nuestro objetivo no es probar estas funciones del módulo fs, por lo que podemos simplificar su comportamiento para que funcionen como lo esperamos y no afecten el resultado del módulo que sí nos interesa probar.
Finalmente, cargamos el módulo gestor de configuraciones con Proxyquire (4) para inyectar las dependencias falsas, evitando así que se carguen las dependencias reales.
De esta manera podemos evitar comportamiento no deseado y enfocarnos en probar el módulo que realmente nos interesa. Con esto podemos ejecutar nuestras pruebas unitarias las veces que sean necesarias y esperar el mismo resultado con cada ejecución.
¡Así es como podemos simular dependencias para nuestras pruebas unitarias en Node JS con Proxyquire y Sinon!
¡Gracias por leer hasta aquí! Cualquier comentario o duda, háganmelo saber.
Imagen de portada por Zafiro Luna.
Para profundizar en el tema: