node.js, Programación, Tutorial

Tests unitarios con node.js, jasmine y sync-request

En este tutorial vamos a ver como usar dos librerías de node.js: sync-request y LokiJS. El primero sirve para realizar peticiones HTTP SÍNCRONAS. El segundo es un sistema de base de datos noSQL, similar a MongoDB, Cassandra, u otros. Su mayor virtud es la velocidad, pero ha sido elegido porque es muy sencillo de implementar. El objetivo del ejercicio es el siguiente: Implementar todo el CRUD de la entidad Car, usando node.js, Express y LokiDB. De esa forma, tenemos un API REST. Es el momento de escribir los tests unitarios de cada una de las rutas que hemos implementado. Para ello, usaremos sync-request, y jasmine-node.

Primero, vamos a ver el código fuente de nuestro tutorial:

app.js
var express = require('express');
var RequestTools = require('./tools.js');
var LokiDatabaseLibrary = require('lokijs');
var bodyParser = require('body-parser');

var oApp = express();
var oTool = new RequestTools();
oApp.use(bodyParser.json());
oApp.use(bodyParser.urlencoded({ extended: true }));
oApp.use(bodyParser.text());
var oLokiDB = new LokiDatabaseLibrary('cars.json');
var oCarCollection = null;
oCarCollection = oLokiDB.addCollection('cars');

oApp.post('/car/create', function(oRequest, oResponse) {
    var oBody = oRequest.body;
    var bFieldsOK = oTool.requiredFieldsExist(oBody, ['name', 'color', 'manufacturer', 'model', 'year']);
    if (bFieldsOK === true) {
        oCarCollection.insert(oBody);
        oLokiDB.saveDatabase();
        oTool.writeJSONResponse(oResponse, {
            error: false
        });
    } else {
        oTool.writeJSONResponse(oResponse, {
            error: true,
            error_message: 'No has pasado los parámetros requeridos'
        });
    }
});

oApp.post('/car/read', function(oRequest, oResponse) {
    var oBody = oRequest.body;
    var oCarsData = oCarCollection.data;
    if (!oBody.hasOwnProperty('index')) {
        oTool.writeJSONResponse(oResponse, oCarsData);
    } else {
        var iCar = parseInt(oBody.index);
        var oCar = oCarCollection.get(iCar);
        oTool.writeJSONResponse(oResponse, oCar);
    }
});

oApp.post('/car/update', function(oRequest, oResponse) {
    var oBody = oRequest.body;
    if(!oBody.hasOwnProperty('$loki')) {
        oTool.writeJSONResponse(oResponse, {
            error: true,
            error_message: 'Debes suministrarme el mismo objeto que te devuelve la base de datos, con las modiificaciones pertinentes'            
        });
    } else {        
        oCarCollection.update(oBody);
        oLokiDB.saveDatabase();
        oTool.writeJSONResponse(oResponse, {
            error: false
        });      
    }
});

oApp.post('/car/delete', function(oRequest, oResponse) {
    var oBody = oRequest.body;
    if(!oBody.hasOwnProperty('$loki')) {
        oTool.writeJSONResponse(oResponse, {
            error: true,
            error_message: 'Debes suministrarme el mismo objeto que te devuelve la base de datos para poder eliminarlo'   
        });
    } else {
        oCarCollection.remove(oBody);
        oLokiDB.saveDatabase();
        oTool.writeJSONResponse(oResponse, {
            error: false            
        });      
    }
});

oApp.listen(9023, function() {
    console.log('App test sync-request en puerto 9023');
});
tools.js
function ToolsSyncRequest() {
    if(!this instanceof ToolsSyncRequest) {
        return new ToolsSyncRequest();
    }
}

ToolsSyncRequest.prototype.requiredFieldsExist = function(oRequestBody, aRequired) {
    var bExist = true;
    var aFieldsBody = Object.keys(oRequestBody);
    var iFieldsRequired = aRequired.length;
     
    for(var i = 0; i < iFieldsRequired; i++) {
        if(!oRequestBody.hasOwnProperty(aRequired[i])) {
            return false;
        }                                
    }    
    return bExist;
}

ToolsSyncRequest.prototype.writeJSONResponse = function(oResponse, oToWrite) {
    oResponse.setHeader('Content-Type', 'application/json');
    oResponse.write(JSON.stringify(oToWrite));
    oResponse.end();
}

module.exports = ToolsSyncRequest;
test.js
var SyncRequest = require('sync-request');
var sURLServices = "http://127.0.0.1:9023/car/";

var oCarToOperateWith = null;

var oCarToCreate = {
    name: "New Unit Test Car",
    color: "Unit Test Color",
    manufacturer: "Unit Test Manufacturer",
    model: "Unit Test Model",
    year: "Unit Test Year"
};

var oReqCarCreate = SyncRequest("POST", sURLServices + "create", {
    json: oCarToCreate
});
var oResponseCarCreate = JSON.parse(oReqCarCreate.body);

if(oResponseCarCreate !== undefined) {
    if(oResponseCarCreate.hasOwnProperty('error')) {
        if(oResponseCarCreate.error === true) {
            console.log('Error creando nuevo registro, mensaje de error: ' + oResponseCarCreate.error_message);            
        } else {
            console.log('OK, registro de LokiDB creado');            
        }
    }    
} else {
    console.log('Error creando nuevo registro');
}


var oReqCarRead = SyncRequest("POST", sURLServices + "read");
var oResponseCarRead = JSON.parse(oReqCarRead.body);

if(oResponseCarRead !== undefined) {
    if(oResponseCarRead[0].hasOwnProperty('$loki')) {
        oCarToOperateWith = oResponseCarRead[0];
        console.log('OK, devolviendo objetos de LokiDB');
    }    
} else {
    console.log('Error en lectura');
}

oCarToOperateWith.name = "NOMBRE_CAMBIADO_EN_TEST_UNITARIO";
var oReqCarUpdate = SyncRequest("POST", sURLServices + "update", {
    json: oCarToOperateWith    
});
var oResponseCarUpdate = JSON.parse(oReqCarUpdate.body);

if(oResponseCarUpdate !== undefined) {
    if(oResponseCarUpdate.hasOwnProperty('error')) {
        if(oResponseCarUpdate.error === true) {
            console.log('Error actualizando registro, mensaje de error: ' + oResponseCarUpdate.error_message);            
        } else {
            console.log('OK, registro de LokiDB actualizado');
        }     
    } else {
        console.log('Error en actualización');
    } 
} else {
    console.log('Error en actualización');
}

var oReqCarDelete = SyncRequest("POST", sURLServices + "delete", {
    json: oCarToOperateWith    
});
var oResponseCarDelete = JSON.parse(oReqCarDelete.body);

if(oResponseCarDelete !== undefined) {
    if(oResponseCarDelete.hasOwnProperty('error')) {
        if(oResponseCarDelete.error === true) {
            console.log('Error borrando registro, mensaje de error: ' + oResponseCarDelete.error_message);                        
        } else {
            console.log('OK, registro eliminado correctamente');
        }
    }    
} else {
    console.log('Error en borrado de registro');    
}

Y ahora, realicemos un análisis del código fuente que hemos escrito. Empezamos por el fichero principal, app.js, que contiene las rutas de la aplicación. Hemos creado cuatro rutas, todas ellas con el método HTTP POST, para realizar las cuatro operaciones CRUD (Create, Read, Update, Delete) sobre la entidad car. Todas ellas realizan las operaciones pertinentes sobre la base de datos LokiDB. Es importante que tengamos en cuenta que LokiDB es una base de datos noSQL que vive en memoria. Esto significa que si detenemos el servicio de node, todos los datos que hayamos insertado desaparecerán.

El fichero tools.js contiene dos métodos: uno que revisa la existencia (pero no comprueba el tipo de dato, ni el contenido), de las variables requeridas para cada servicio. El otro es un simple wrapper de la llamada a devolver JSON desde la aplicación de Express.

Por último, el fichero tests.js contiene una serie de tests muy sencillos, que revisan que la aplicación se comporta como esperamos, y, por tanto, cumple con la especificación. Es importante que tengamos en cuenta que este fichero no está integrado dentro de ningún sistema de tests unitarios, es un script que hemos escrito para testear el API. Pero, ¿existe una forma mejor de hacer estos tests?

La respuesta es si. Existen varias librerías para la realización de tests unitarios, pero vamos a coger jasmine-node por su sencillez de uso. Lo primero que debemos hacer es instalarlo. Para ello, ejecutaremos la siguiente instrucción en consola:

sudo npm install -g jasmine

De esa forma, jasmine-node quedará insalado globalmente en nuestro entorno, creando un script de bash en /usr/local/bin/jasmine-node para que podamos invocarlo desde la línea de comandos.

Veamos a continuación como implementar nuestros tests unitarios. Para ello, y dado que vamos a testear un API REST, usaremos sync-request. La idea para ello es la siguiente: Existen formas de acceder a una ruta HTTP directamente implementadas en node.js, pero todas ellas funcionan de forma asíncrona. Esto, si bien es el día a día de node.js, es a menudo bastante engorroso para depende qué tarea queramos realizar. Para simplificar, tanto a nivel código como a nivel conceptual, la librería sync-request nos permite ejecutar una petición, y garantiza que en la siguiente línea de código, ya ha sido ejecutada, y el resultado de llamar a la ruta del API REST ya existe. Esto es muy útil, desde mi punto de vista, para organizar los tests.

Veamos pues, como implementar nuestros tests con jasmine-node. Crearemos una carpeta llamada spec, en la que guardaremos los tests. Los ficheros que contengan tests unitarios debería llamarse [loquesea]Spec.js, porque jasmine busca los sufijos Spec para reconocer qué ficheros son de tipo test unitario. Esto es simplemente una convención, que recomiendo usemos.

spec/appSpec.js
var SyncRequest = require('sync-request');
var oCarToOperateWith = null;
var sBaseURLServices = 'http://127.0.0.1:9023/car/';
var oURLServices = {
    CREATE: sBaseURLServices + 'create',
    READ: sBaseURLServices + 'read',
    UPDATE: sBaseURLServices + 'update',
    DELETE: sBaseURLServices + 'delete'
};

describe("Tests unitarios de aplicación LokiDB + sync-request", function() {
    
    it("Test de ruta POST /car/create. Debería devolver {error: false}. PRIMER TEST UNITARIO.", function() {
        oCarToOperateWith = {
            name: "Test unitario jasmine-node. Campo name.",
            color: "Test unitario jasmine-node. Campo color.",
            manufacturer: "Test unitario jasmine-node. Campo manufacturer.",
            model: "Test unitario jasmine-node. Campo model.",
            year: "Test unitario jasmine-node. Campo year."
        };
        var oRequestCreate = SyncRequest("POST", oURLServices.CREATE, {
            json: oCarToOperateWith            
        });
        var oResponseCreate = JSON.parse(oRequestCreate.body);        
        expect(oResponseCreate.error).toBe(false);
    });
    
    it("Test de ruta POST /car/create. Debería devolver {error: true}", function() {
        oCarToOperateWith = {};
        var oRequestCreate = SyncRequest("POST", oURLServices.CREATE, {
            json: oCarToOperateWith            
        });
        var oResponseCreate = JSON.parse(oRequestCreate.body);        
        expect(oResponseCreate.error).toBe(true);
    });
    
    it("Test de ruta POST /car/read. Debería devolver como mínimo un registro", function() {
        var oRequestRead = SyncRequest("POST", oURLServices.READ);
        var oResponseRead = JSON.parse(oRequestRead.body);
        expect(oResponseRead.length).toBeGreaterThan(0);
        
        oCarToOperateWith = oResponseRead[0];
        expect(oCarToOperateWith.name).toBeDefined();
        expect(oCarToOperateWith.color).toBeDefined();
        expect(oCarToOperateWith.manufacturer).toBeDefined();
        expect(oCarToOperateWith.model).toBeDefined();
        expect(oCarToOperateWith.year).toBeDefined();
    });
    
    it("Test de ruta POST /car/read. Debería devolver un elemento vacío", function() {
        var oRequestRead = SyncRequest("POST", oURLServices.READ, {
            json: {
                index: 10
            }
        });
        var oResponseRead = JSON.parse(oRequestRead.body);
        expect(oResponseRead).toBeNull();
    });
    
    it("Test de ruta POST /car/update. Debería devolver {error: false}", function() {
        oCarToOperateWith.name = 'Nombre editado en test unitarios jasmine-node';        
        var oRequestUpdate = SyncRequest("POST", oURLServices.UPDATE, {
            json: oCarToOperateWith            
        });
        var oResponseUpdate = JSON.parse(oRequestUpdate.body);
        expect(oResponseUpdate.error).toBe(false);                
    });
    
    it("Test de ruta POST /car/update. Debería devolver {error: true}", function() {
        var oIncorrectUpdateCar = {
            name: 'Incorrecto, no tiene todos los campos',
            color: 'Incorrecto, no tiene todos los campos',
            model: 'Incorrecto, no tiene todos los campos'
        };
        var oRequestUpdate = SyncRequest("POST", oURLServices.UPDATE, {
            json: oIncorrectUpdateCar            
        });
        var oResponseUpdate = JSON.parse(oRequestUpdate.body);
        expect(oResponseUpdate.error).toBe(true);
    });
    
    it("Test de ruta POST /car/delete. Debería devolver {error: true}", function() {
        var oIncorrectDeleteCar = {
            name: 'Incorrecto, no podremos borrarlo',
            color: 'Incorrecto, no podremos borrarlo'            
        };
        var oRequestDelete = SyncRequest("POST", oURLServices.UPDATE, {
            json: oIncorrectDeleteCar            
        });
        var oResponseDelete = JSON.parse(oRequestDelete.body);
        expect(oResponseDelete.error).toBe(true);
    });
    
    it("Test de ruta POST /car/delete. Debería devolver {error: false}. ULTIMO TEST UNITARIO." , function() {
        var oRequestDelete = SyncRequest("POST", oURLServices.DELETE, {
            json: oCarToOperateWith            
        });
        var oResponseDelete = JSON.parse(oRequestDelete.body);
        expect(oResponseDelete.error).toBe(false);
    });
            
});

Vamos a ver la estructura que tienen los tests unitarios de jasmine viendo un ejemplo de tests que se cumple siempre:

describe("Suite de tests unitarios", function() {

	it("Test 1", function() {
		var bTrue = true;
		expect(bTrue).toBe(true);
		expect(bTrue).not.toBe(false);
	});

});

Lo primero, la llamada a describe indica el inicio de la suite de testeo. Dentro del callback, la llamada a it inicia las aserciones que estén dentro del test. Las aserciones, en este caso son .toBe(true) y .not.toBe(false). Dado que nuestra variable tiene un valor true, se cumplen ambas aserciones. Ahora, revisemos un poco el código que tenemos en la suite de testeo de nuestra aplicación:

it("Test de ruta POST /car/create. Debería devolver {error: false}. PRIMER TEST UNITARIO.", function() {
	oCarToOperateWith = {
		name: "Test unitario jasmine-node. Campo name.",
		color: "Test unitario jasmine-node. Campo color.",
		manufacturer: "Test unitario jasmine-node. Campo manufacturer.",
		model: "Test unitario jasmine-node. Campo model.",
		year: "Test unitario jasmine-node. Campo year."
	};
	var oRequestCreate = SyncRequest("POST", oURLServices.CREATE, {
		json: oCarToOperateWith            
	});
	var oResponseCreate = JSON.parse(oRequestCreate.body);        
	expect(oResponseCreate.error).toBe(false);
});

Describimos un objeto oCarToOperateWith, que hemos definido previamente como global a la suite de testeo. En el caso de este test, que es la creación de un nuevo registro de la entidad, rellenamos los datos requeridos, y llamamos a la ruta correspondiente. Como vemos, en la instrucción siguiente, podemos referenciar oRequestCreate.body, y saber que tiene valor. En un paradigma asíncrono, el valor de esa variable sería undefined. De este modo, podemos programar de una forma mas similar a JAVA, PHP, u otros lenguajes que no dependen tanto de callbacks y asincronicidad. Por último, realizamos la aserción de este test. Estamos seguros de que el objeto creado es correcto, por tanto, deberíamos recibir una estructura de datos JSON con un campo error, con valor false, para indicar que no se ha producido ningún error realizando la operación de creación de un nuevo registro.

El resto de tests son sencillos de entender teniendo en cuenta estas premisas. Generalmente es una buena idea implementar tests que devuelven errores, así como resultados correctos. El siguiente test revisa precisamente eso: que si pasamos una estructura de datos incorrecta, el API devolverá un valor true para el campo error de la estructura de datos devuelta por el API.

De esta forma, usando jasmine-node y sync-request, podemos realizar con sencillez tests unitarios para APIs REST escritas con node.js.

Para ejecutar la batería de tests, ejecutaremos el siguiente comando desde la raíz del proyecto:

jasmine-node --verbose spec/appSpec.js

Y deberíamos obtener por consola el resultado de los tests. Todos deberían aparecer en color verde, si alguno aparece en rojo, es que ese test no cumple todas las aserciones que hemos especificado.

El código de este tutorial puede descargarse del repositorio de github correspondiente: Repositorio de GitHub con el tutorial

Leave a Comment

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *