Tests unitarios en JavaScript. Sinon.

Los tests son hoy en un día una parte fundamental del desarrollo de software. Nos ayudan a minimizar el número de bugs. Facilitan verificar que todo sigue correcto ante posibles cambios o refactorizaciones. Y además, entre otras cosas, dan mayor confianza al equipo, ya que son un indicativo del estado del proyecto. Existen muchos tipos de tests: de integración, unitarios, de carga… En esta entrada de blog nos centraremos en los tests unitarios en JavaScript y cómo usar Sinon para eliminar las dependencias con otros módulos. Estos tests podremos lanzarlos usando Mocha como ya vimos anteriormente.

Mocks, Spies y Stubs

Como ya sabemos, los tests unitarios sirven para probar el funcionamiento de una unidad de código. Este bloque que queremos probar suele tener dependencias con otros módulos tanto propios como ajenos. Debido a ello en nuestros tests pueden surgir  las siguientes necesidades:

  • Necesitemos comprobar cómo interacciona nuestro código con esos módulos externos.
  • Necesitemos que el módulo externo devuelva un resultado en concreto para el caso que estemos probando.
  • Queramos evitar llamadas reales a esos módulos externos para que no interfieran en los resultados o simplemente porque no queremos que se hagan.

Para resolver estas necesidades en JavaScript existen muchas opciones. Una de las más populares es usar la librería Sinon, que facilita estas tareas mediante tres tipos de elementos: Stubs, Spies y Mocks.

Spies en Sinon

Un Spy en Sinon es simplemente una función que nos permite realizar comprobaciones en las interacciones sobre ella. En tests unitarios es útil sobre todo para comprobar cómo hemos invocado a la dependencia externa. No sólo podemos comprobar si se ha realizado una llamada a un método, sino que hay muchas opciones como comprobar que se ha llamado un número de veces determinadas, los argumentos usados… También existen otras comprobaciones posibles que podemos ver en su API.

En Sinon los Spies se pueden establecer de diferentes formas, aunque la mayoría de veces los usaremos de una de estas dos formas:

  • Sobre funciones anónimas: Útiles sobre todo para probar funciones que se reciban como entrada en el código a probar. El ejemplo más típico son los callback pasados por parámetro, en los que solemos querer saber si se han invocado correctamente. En este caso el spy se crearía simplemente así:

var mySpy = sinon.spy();

  • Sobre funciones de objetos reales ya existentes: A veces la función que queremos monitorizar pertenecen a objetos de módulos externos, por ejemplo de una librería, de un objeto que recibimos como entrada o de uno que esté en el scope de ejecución. Para ello también disponemos de un modo de generar un Spy para ello. Si quisieramos que el spy fuera sobre la función ajax del objeto jQuery sería:

var mySpy = sinon.spy(jQuery, “ajax”);

Hay que tener en cuenta que en el último caso se reemplaza el método original con el spy, por lo que se necesita liberar el spy una vez que ya no lo necesitamos al finalizar el test. Para ello existe el método “restore” que eliminaría el spy generado. Por ejemplo lo podríamos usar así:

var mySpy = sinon.spy(jQuery, “ajax”); 
// … 
// release it once we do not need it
jQuery.ajax.restore();

Veamos ahora un ejemplo práctico de cómo usar los spies. En el código que aparece a continuación disponemos de dos servicios (phone_service y event_service) con sus dos tests asociados. Cada vez que se añade un teléfono en el servicio, éste se valida, y si resulta inválido se manda una notificación al servicio de eventos. Justo antes de cada test estamos creando los spies necesarios, uno para console.log() y otro para emitter.emit(), los cuales liberamos con restore() al terminar. En cada spy comprobamos si se han realizado llamadas sobre ellos para validar que el test ha ido satisfactoriamente. Para estas comprobaciones usamos called (comprueba que se ha llamado) y calledWith (que además valida que se haya llamado con esos argumentos.

var eventService = require('../src/events_service');

var emitter = eventService.emitter;

describe('Event service',function(done){

    beforeEach( function() {
        this.consoleSpy = sinon.spy(console, 'log');
    });

    afterEach( function() {;
        this.consoleSpy.restore();
    });

    it('Emitted errors should be logged', function() {
        emitter.emit('invalidPhone', 'a123456789');
        this.consoleSpy.called.should.be.true;
    });

});
var should = require('chai').should();
var sinon = require('sinon');

var phoneService = require('../src/phone_service');
var eventService = require('../src/events_service');

var emitter = eventService.emitter;

describe('Phone service module',function(done){

    beforeEach( function() {
        this.emitSpy = sinon.spy(emitter, 'emit');
    });

    afterEach( function() {;
        this.emitSpy.restore();
    });

    it('valid phones should not emit an error', function() {
        phoneService.setPhone('123456789');
        this.emitSpy.called.should.be.false;
    });

    it('invalid phones should emit an error', function() {
        phoneService.setPhone('a123456789');
        this.emitSpy.calledWith('invalidPhone', 'a123456789').should.be.true;
    });

});
var EventEmitter = require('events').EventEmitter;

var emitter = new EventEmitter();

emitter.on('invalidPhone', onInvalidPhone);

function onInvalidPhone(phone) {
    console.log('phone is invalid', phone);
}

exports.emitter = emitter;
var emitter = require('./events_service').emitter;

exports.setPhone = function(phone) {
    if (!/^\d+$/.test(phone)) {
        emitter.emit('invalidPhone', phone);
    }
}

Stubs en Sinon

Hasta ahora con los spies ya sabemos cómo comprobar las interacciones con dependencias externas, pero no podemos manipular cómo funcionan esas dependencias. Para ello surgen los stubs. En Sinon un stub es un spy al que se le puede definir un comportamiento.

Esto nos ayudará principalmente en dos aspectos:

  • Facilitará probar todos los flujos de nuestra unidad de código. Por ejemplo, si nuestro código realiza unas acciones otras en función del valor devuelto por una dependencia ajena, podremos tener diferentes tests cada caso con un stub en el que se haya programado una salida en concreto para cubrir todas las posibilidades.
  • Permitirá evitar la ejecución de código no deseado. Imaginemos que dentro de una lógica se está llamando a una función que por no encontrarse en un entorno de ejecución real va a fallar o a un servicio externo al que no queremos molestar durante nuestro testing. Un stub sobrescribiendo el comportamiento de esa función (simplemente con una función que no haga nada) nos ayudará.

Podremos definir stubs sobre una función anónima o sobre un objeto ya existente. Tiene una sintaxis bastante similar a los spies, pero empleando la palabra stub. Vemos algunos ejemplos de creación de stubs:

  • Evitar la ejecución de la función “sendEmail()” de emailService:

var stub = sinon.stub(emailService, 'sendEmail');

  • Hacer que la llamada a “getUser()” de userService devuelva un usuario con unos datos:
var stub = sinon.stub(userService, 'getUser')
                .returns(new User(1, 'User1'));
  • Hacer que la llamada a “getUser(id)” de userService devuelva dos usuarios diferentes en función del parámetro:
var stub = sinon.stub(userService, 'getUser');
stub.withArgs(1).returns(new User(1, 'User1'));
stub.withArgs(2).returns(new User(2, 'User2'));

Hay muchas más posibilidades que se encuentran documentadas en su API. Veamos un ejemplo ahora de cómo usar un stub dentro del test. Para ello partimos de un código muy similar al que ya usamos con los spies. Pero en este caso, además de validar el teléfono comprobamos si está en una lista negra. La comprobación de la lista negra la hace un servicio externo, por lo que usaremos un stub para cubrir los dos casos: que esté en la lista negra o no. De esta forma el stub devolverá true o false dependiendo del caso que estemos probando. El servicio phone_service podríamos testearlo así:

var should = require('chai').should();
var sinon = require('sinon');

var phoneService = require('../src/phone_service');
var eventService = require('../src/events_service');
var blacklistService = require('../src/blacklist_service');

var emitter = eventService.emitter;

describe('Phone service module',function(done){

    beforeEach( function() {
        this.emitSpy = sinon.spy(emitter, 'emit');
        this.isInBlackListStub = sinon.stub(blacklistService, 'isInBlackList');
    });

    afterEach( function() {;
        this.emitSpy.restore();
        this.isInBlackListStub.restore();
    });

    it('valid phones not in blacklist should not emit an error', function() {
        this.isInBlackListStub.returns(false);
        phoneService.setPhone('123456789');
        this.emitSpy.called.should.be.false;
    });

    it('valid phones in blacklist should emit an error', function() {
        this.isInBlackListStub.returns(true);
        phoneService.setPhone('123456789');
        this.emitSpy.calledWith('invalidPhone', '123456789').should.be.true;
    });

    it('invalid phones should emit an error', function() {
        phoneService.setPhone('a123456789');
        this.emitSpy.calledWith('invalidPhone', 'a123456789').should.be.true;
    });

});
var EventEmitter = require('events').EventEmitter;

var emitter = new EventEmitter();

emitter.on('invalidPhone', onInvalidPhone);

function onInvalidPhone(phone) {
    console.log('phone is invalid', phone);
}

exports.emitter = emitter;
var emitter = require('./events_service').emitter;

var blacklistService = require('./blacklist_service');

exports.setPhone = function(phone) {
    if (!/^\d+$/.test(phone) ||
        blacklistService.isInBlackList(phone)) {
        emitter.emit('invalidPhone', phone);
    }
}

Al igual que en el caso de los spies, una vez el stub ya no es necesario lo liberamos con restore().

Mocks en Sinon

Además de los spies y los stubs en sinon disponemos de un tercer elemento denominado mock. Un mock es una mezcla entre un spy y un stub, por lo que permite usar las APIs de ambos. Pero lo que lo diferencia es que se puede establecer unas condiciones en el que se debe usar el mock, las cuales podremos validar a posteriori. De esta forma si el mock se usa de forma incorrecta nuestro test fallará.

La API define permite definir diferentes validaciones sobre el mock. Para comprobarlo necesitaremos llamar a verify(). Los mocks cambian un poco la apariencia de nuestro test pero sigue siendo un proceso similar. Por ejemplo, en el mismo código anterior de los stubs, en lugar de un stub para blacklistService.isInBlackList podemos definir un mock que valide a su vez si la llamada se realizó con los argumentos adecuados (el número de teléfono usado). Así el test quedaría:

var should = require('chai').should();
var sinon = require('sinon');

var phoneService = require('../src/phone_service');
var eventService = require('../src/events_service');
var blacklistService = require('../src/blacklist_service');

var emitter = eventService.emitter;

describe('Phone service module',function(done){

    beforeEach( function() {
        this.emitSpy = sinon.spy(emitter, 'emit');
        this.blacklistServiceMock = sinon.mock(blacklistService);
    });

    afterEach( function() {;
        this.emitSpy.restore();
        this.blacklistServiceMock.restore();
    });

    it('valid phones not in blacklist should not emit an error', function() {
        this.blacklistServiceMock.expects('isInBlackList').withExactArgs('123456789').returns(false);
        phoneService.setPhone('123456789');
        this.blacklistServiceMock.verify();
        this.emitSpy.called.should.be.false;
    });

    it('valid phones in blacklist should emit an error', function() {
        this.blacklistServiceMock.expects('isInBlackList').withExactArgs('123456789').returns(true);
        phoneService.setPhone('123456789');
        this.blacklistServiceMock.verify();
        this.emitSpy.calledWith('invalidPhone', '123456789').should.be.true;
    });

    it('invalid phones should emit an error', function() {
        phoneService.setPhone('a123456789');
        this.emitSpy.calledWith('invalidPhone', 'a123456789').should.be.true;
    });

});
var emitter = require('./events_service').emitter;

var blacklistService = require('./blacklist_service');

exports.setPhone = function(phone) {
    if (!/^\d+$/.test(phone) ||
        blacklistService.isInBlackList(phone)) {
        emitter.emit('invalidPhone', phone);
    }
}
var EventEmitter = require('events').EventEmitter;

var emitter = new EventEmitter();

emitter.on('invalidPhone', onInvalidPhone);

function onInvalidPhone(phone) {
    console.log('phone is invalid', phone);
}

exports.emitter = emitter;

Para terminar

Hemos visto tres elementos muy útiles para incluir en nuestros tests unitarios. Dependiendo del caso deberemos usar el que se ajuste a nuestras necesidades. Sinon dispone de muchas otras funcionalidades como los fake timers útiles cuando tenemos que manejar operaciones en el tiempo o los Fake XHR para operaciones Ajax.

Cabe destacar observando los ejemplos anteriores que sólo hemos testeado los métodos públicos de cada módulo y poniendo spies/stubs/mocks en las dependencias con otros módulos. En ningún caso lo hemos hecho sobre funciones privadas. Esto es así porque estamos siguiendo un modelo de caja negra en el que sólo probamos la funcionalidad de nuestro sistema a través de sus entradas y salidas. Un buen código con una buena inyección de dependencias nos facilitará la tarea de testing. Si por una razón u otra se necesitara testear un método privado habría que usar otros técnicas adicionales como por ejemplo el uso de la librería rewire.

A veces no resulta fácil realizar estos tests. Pero dadas las ventajas que nos aportan os animamos a que no os olvidéis de ellos. A medida que hagáis más y más os resultará más sencillo. De todas formas, no hay que olvidar que estos tests deben ser complementados con otros tipos de tests, como pueden ser tests de integración usando Postman o de carga con JMeter, y que además configurarnos un linter nos ayudará a todo el equipo a escribirlos de forma homogénea.

Deja un comentario

Responsable » Solidgear.
Finalidad » Gestionar los comentarios.
Legitimación » Tu consentimiento.
Destinatarios » Los datos que me facilitas estarán ubicados en los servidores SolidgearGroup dentro de la UE.
Derechos » Podrás ejercer tus derechos, entre otros, a acceder, rectificar, limitar y suprimir tus datos.

¿Necesitas una estimación?

Calcula ahora