AngularJS y los tests unitarios

5
24368

AngularJS y los tests unitarios.

0. Índice de contenidos.


1. Introducción

No vamos a descubrir nada nuevo si ensalzamos las virtudes de los tests unitarios. Sin embargo, en lenguajes como Javascript corriendo en el lado del cliente adquieren incluso mayor importancia. Cuando se ejecuta código Javascript en un navegador de algún usuario de nuestro sistema, no tenemos forma de saber qué ha ocurrido si se produce algún error (salvo que lo controlemos y reportemos) como sí que pasaría si ese fallo se produjese en nuestro servidor, donde podremos ir a consultar las trazas de log.

El otro día tuve la oportunidad de asistir a un evento sobre aseguramiento de la calidad del software y dijeron una frase que me gustó bastante: La calidad es innegociable, forma parte inherente e inseparable del producto software. Pues bien, podríamos considerar a los tests unitarios como los pilares de este proceso de aseguramiento de la calidad.

En este tutorial intentaremos explicar cómo realizar tests unitarios en AngularJS haciendo uso de Karma y Jasmine, jugaremos con mocks y aprenderemos a generar informes.


2. Entorno.

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15′ (2.2 Ghz Intel Core I7, 8GB DDR3).
  • Sistema Operativo: Mac OS X Mavericks 10.9
  • NetBeans IDE 8.0.1
  • AngularJS 1.2.26
  • Node Package Manager (npm) 2.1.8
  • Google Chrome 39
  • Mozilla Firefox 33

3. ¿Qué vamos a hacer?.

Desarrollaremos una pequeña aplicación que nos muestre un listado de dos coches y sus características: marca, modelo y motorización. Además, queremos que la marca del coche aparezca en mayúsculas y con dos asteríscos al principio.

Por tanto nuestros requisitos son:

  • Debemos listar un conjunto de coches, en concreto dos.
  • Debemos mostrar las características de los coches al usuario. La marca del coche debe aparecer en mayúsculas y con dos asteríscos al principio.

Para implementar nuestra solución haremos uso de AngularJS y nos apoyaremos en tres componentes:

  • Un filtro: que se encargará de mostrar la marca del coche en el formato requerido.
  • Un servicio: que se encargará de obtener la lista de coches.
  • Un controlador: que se encargará de exponer la lista de coches.

Estamos buscando algo así:

4. Configuración.

4.1 Estructura del proyecto.

La estructura de nuestro proyecto tendrá un aspecto similar al de la siguiente figura (el directorio «Site Root» no existe, lo añade NetBeans al mostrar el proyecto):

  • Directorio app/: Contendrá nuestro código de producción. Para el que esté acostrumbrado a trabajar con Maven, sería lo equivalente al directorio main. Estará compuesto por:
    • app/css/: Contendrá las hojas de estilo de nuestro proyecto.
    • app/js/: Contendrá el código Javascript que escribiremos para implementar la lógica funcional del proyecto.
    • app/lib/: Contendrá las librerías de terceros. En este caso, las dependencias de AngularJS. Lo gestionaremos mediante Bower.
    • app/index.html: Página inicial de nuestra web.
  • Directorio node_modules/: Módulos de node para la gestión del proyecto.
  • Directorio tests/: Contendrá el código de nuestros tests unitarios.
  • Fichero bower.json: Gestionará las dependencias de librerías externas de nuestro proyecto. En nuestro caso serán dependencias de AngularJS.
  • Fichero karma.conf.js: Fichero de configuración del framework Karma, que será la herramienta en la que nos apoyemos para configurar el comportamiento de la ejecución nuestros tests unitarios.
  • Fichero package.json: Fichero de configuración general de nuestro proyecto que será interpretado por el gestor de paquetes de node (npm).

4.2 package.json

Nuestro fichero package.json tendrá un aspecto similar a este:

{
  "name": "angular-testing",
  "private": true,
  "version": "0.0.1",
  "description": "AngularJS testing project",
  "repository": "https://github.com/marlandy/angularjs-testing",
  "license": "MIT",
  "devDependencies": {
    "bower": "^1.3.1",    
    "karma": "~0.10"
  },
  "scripts": {
    "postinstall": "bower install",
    "pretest": "npm install",
    "test": "karma start karma.conf.js"    
  }
}

Además de los metadatos relativos al proyecto, podemos destacaremos dos cosas:

  • Añadimos las dependendencias de bower y karma. El primero lo necesitaremos para gestionar las librerías de terceros que usará nuestro proyecto (AngularJS) y el segundo para la gestión de nuestros tests.
  • Añadimos los diferentes scripts vinculados a diferentes fases del ciclo de vida de nuestro paquete. En concreto: instalamos las dependencias a nivel de paquete (npm install), instalamos las librerías de terceros que necesitará nuestro proyecto (bower install) y nos aseguramos de que los tests se lancen haciendo uso de karma. ¿Que te traduzca esto a cristiano? pues muy fácil, cuando lancemos el comando npm test tendremos todas nuestras dependencias instaladas correctamente y todo funcionará de maravilla :-).

4.3 karma.conf.js

Mediante el fichero karma.conf.js configuraremos el comportamiento que queremos te tenga la ejecución de nuestros tests. El fichero sería algo como esto:

module.exports = function (config) {
    config.set({
        basePath: './',
        files: [
            'app/lib/angular/angular.js',
            'app/lib/angular-mocks/angular-mocks.js',
            'app/js/**/*.js',
            'tests/**/*.js'
        ],
        autoWatch: false,
        frameworks: ['jasmine'],
        browsers: ['Chrome', 'Firefox'],
        singleRun: true,
        plugins: [
            'karma-chrome-launcher',
            'karma-firefox-launcher',
            'karma-jasmine'
        ]
    });
};

Destacamos lo siguiente:

  • files: Listado de ficheros que tendrá en cuenta karma para la ejecución de nuestros tests. En concreto le indicamos las librerías de terceros (que están en el directorio app/lib/), nuestro código de producción (app/js/) y nuestros tests unitarios (tests/).
  • autoWatch: Esta propiedad es muy interesante. Sirve para indicar si queremos que karma esté continuamente observando cambios en alguno de los ficheros y, en caso afirmativo, lance los tests. Le diremos que no ya que en este ejemplo los lancaremos «a demanda».
  • frameworks: Karma es un framework extraordinario, pero tiene una característica en concreto que a mí me encanta. Es totalmente imparcial a la hora de trabajar con el framework de testing que perfiera el usuario (QUnit, Jasmine, etc…) para escribir los tests. Con esta propiedad le indicaremos dicho framework. En este caso haremos uso de Jasmine.
  • browsers: Listado de navegadores en los que queremos que se lancen los tests. Recordemos que vamos a escribir código que se ejecuta en un cliente (navegador) y para comprobar su correcto funcionamiento debemos hacer uso alguno. Si eres «Javero» y esto te resulta algo extraño, debes comprender que aquí no tenemos una maravillosa máquina virtual de Java (JVM) instalada en nuestro equipo que nos ejecute nuestros tests y nos verifique su correcto funcionamiento. Esto es otro mundo…
  • singleRun: Indicamos que, después de correr los tests, queremos que se termine el proceso.
  • plugins: Listado de plugins necesarios para Karma. Añadimos el lanzador de Chrome, Firefox y el adaptador de Jasmine.

Pues con esto hemos terminado la configuración aunque nos hemos saltado la parte de Bower donde lo que hacemos es añadir las dependencias de AngularJS y Angular-Mocks (librería que nos ofrece el soporte de Angular al manejo de «mocks»). Si quieres saber más tienes todo el código fuente del ejemplo aquí.

5. Testeando el filtro.

Como dijimos anteriormente, necesitaremos un componente cuyo objetivo sea transformar cadenas de caracteres de forma que el resultado sea una cadena en mayúsculas con dos asteriscos al principio. Para tal propósito AngularJS nos proporciona los filtros.

Para ello crearemos un nuevo módulo llamado app.decorator-filter. En dicho módulo añadiremos nuestro filtro al que llamaremos decorator. Para ello crearemos dos ficheros: app/js/decorator-filter.js y tests/decorator-filter_test.js. En el primero añadiremos el código de producción y en el segundo el test.

Como no podía ser de otra manera, escribiremos primero nuestro test describiendo el comportamiento de nuestro componente (nuestro filtro), lo que esperamos de él en cada caso y después el código de producción (comprobaríamos que todo funciona y finalmente refactorizaríamos si hiciese falta). A esta técnica se la conoce como BDD.

Nuestro fichero decorator-filter_test.js quedaría así:

'use strict';

describe('Modulo app.decorator-filter', function () {
    
    beforeEach(function(){
        module('app.decorator-filter');
    });

    describe('Filtro decorator', function () {
        it('debe transformar en mayusculas cualquier string y anteponer asteriscos', 
        		inject(function (decoratorFilter) {
            var input = 'someThing';
            var expectedOutput = '**SOMETHING';
            expect(decoratorFilter(input)).toEqual(expectedOutput);
        }));
    });
});

Creo que el código habla por sí solo pero si te cuesta entender el anterior fragmento te recomiendo que le eches un ojo a este tutorial. Observemos que estamos inyectando el filtro mediante la convención: nombre del filtro + «Filter». Lo hacemos de esta forma ya que es la manera en la que Angular registra los filtros en el objeto $injector.

Pasamos ahora a escribir nuestro código de producción (el filtro). El fichero decorator-filter.js quedaría:

'use strict';

(function () {
    var module = angular.module('app.decorator-filter', []);

    module.filter('decorator', function () {
        return function (text) {
            return '**' + String(text).toUpperCase();
        };
    });
})();

Pues este sería el código de nuestro filtro. Para lanzar los tests lo haremos con el siguiente comando:

npm test

MUY IMPORTANTE: en entornos Unix debemos contar con los permisos necesarios para instalar paquetes (npm install) y para las librerías de terceros que utilizará nuestro proyecto (bower install). Para entornos Windows te recomiendo el uso de alguna herramienta tipo Cygwin.

Y este es el resultado:

6. Testeando el servicio.

Necesitaremos contar con un servicio que se encargue de devolver el listado de coches. Dicho servicio tendrá un método al que llamaremos getAll que nos devolverá los coches. El servicio formará parte un un módulo llamado app.cars. A dicho servicio le identificaremos con el nombre «CarsService».

Nuestro fichero cars_test.js quedaría así:

'use strict';

describe('Modulo app.cars', function () {

    beforeEach(function () {
        module('app.cars');
    });

    describe('Cars service', function () {

        var carsService;

        beforeEach(function () {
            inject(['CarsService', function (service) {
                    carsService = service;
                }
            ]);
        });

        it('debe devolver una lista de dos coches', function () {
            var cars = carsService.getAll();
            expect(cars).toBeDefined();
            expect(cars.length).toBe(2);
        });
    });
});

En este caso, a diferencia del test anterior, hemos inyectado la dependencia a nivel del juego de pruebas del servicio (describe, dentro de beforeEach) y no en la propia especificación (it). Probablemente esta sería la forma más indicada cuando vamos a definir diferentes comportamientos (especificaciones) a un mismo componente.

Y nuestro código de producción con el servicio en el fichero cars.js sería algo como:

'use strict';

(function () {
    var module = angular.module('app.cars', []);

    module.factory('CarsService', function () {

        var cars = [
            createCar('volkswagen', 'golf', '2.0 TDI 150CV'),
            createCar('ford', 'focus', '1.8 TDCI 115CV')
        ];

        function createCar(brand, model, engine) {
            return {
                brand: brand,
                model: model,
                engine: engine
            };
        }

        function getCars() {
            return cars;
        }

        return {
            getAll: getCars
        };
    });

})();

Lanzamos los tests:

npm test

Y comprobamos que todo está perfecto 🙂

7. Testeando el controlador.

Bueno, pues esto ya está casi terminado. Lo último que nos quedará será nuestro controlador. Su misión es muy sencilla: haciendo uso del servicio de consulta de coches, deberá exponer el listado mediante un atributo de su $scope.

En este caso añadiremos una particularidad que no tenían los puntos anteriores. Con el fin de probar cada uno de los componentes de nuestro proyecto de manera independiente, haremos uso de mocks para hacer el test de este controlador puesto que hace uso de otro colaborador (servicio de consulta de coches).

Añadiremos el controlador al mismo módulo (app.cars) que contiene el servicio de consulta.

Nuestro test quedaría de la siguiente manera:

'use strict';

describe('Modulo app.cars', function () {

    beforeEach(function () {
        module('app.cars');
    });

    describe('Cars controller', function () {

        var $scope, CarsService;

        beforeEach(function () {
            module(function ($provide) {
            	// inyectamos del mock
                $provide.value('MockedCarsService', {'getAll': function () {
                        return [];
                    }});
            });
        });

        beforeEach(inject(function ($rootScope) {
            $scope = $rootScope.$new();
        }));

        it('debe exponer la lista de coches', inject(function ($controller, MockedCarsService) {
            $controller('CarsController', {'$scope': $scope, 'CarsService': MockedCarsService});
            expect($controller).toBeDefined();
            expect(CarsService);
            expect($scope.cars.length).toBe(MockedCarsService.getAll().length);
        }));
    });

});

Prestemos especial atención a las siguientes líneas:

  • línea 16: Estamos creando nuestro servicio mock (objeto de mentira que sustituirá al original) para que posteriormente pueda ser inyectado en el controlador.
  • línea 24: Creamos un nuevo $scope que será utilizado por nuestro controlador a través del cual expondremos la lista de coches.
  • línea 27: Inicializamos el controlador con el nuevo $scope y nuestro servicio de consulta de coches «mockeado». Como vemos, expondremos el listado de coches a través del objeto «car» del ámbito de nuestro controlador.

A continuación vemos cómo quedaría nuestro controlador:

'use strict';

(function () {
    var module = angular.module('app.cars', []);

    module.factory('CarsService', function () {
		// punto anterior...
    });

    module.controller('CarsController', ['$scope', 'CarsService', function ($scope, CarsService) {
            $scope.cars = CarsService.getAll();
        }]);        
})();

Lanzamos los tests…

Pues con esto ya estaría todo, únicamente nos quedaría dar forma a nuestra aplicación (vista) mediante el fichero index.html:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>AngularJS - Testing</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link href="css/default.css" rel="stylesheet">
    </head>
    <body ng-app="app">

        <ul ng-controller="CarsController">
            <li ng-repeat="car in cars">
                <div>
                    <p>
                        <span>Marca:</span> {{car.brand | decorator}}
                    </p>
                    <p>
                        <span>Modelo:</span> {{car.model}}
                    </p>
                    <p>
                        <span>Motor:</span> {{car.engine}}
                    </p>
                </div>           
            </li>
        </ul>

        <script src="/wp-content/uploads/tutorial-data/lib/angular/angular.js"></script>
        <script src="/wp-content/uploads/tutorial-data/js/cars.js"></script>
        <script src="/wp-content/uploads/tutorial-data/js/decorator-filter.js"></script>
        <script src="/wp-content/uploads/tutorial-data/js/app.js"></script>
    </body>
</html>

AQUÍ TIENES TODO EL CÓDIGO FUENTE DEL EJEMPLO.

8. Generando informes: junit y cobertura.

Todo esto está muy bien, pero todavía podemos ir más lejos. Vamos a ver cómo podemos generar informes con la información relativa a la ejecución de nuestros tests gracias al soporte que nos da Karma.

Por defecto Karma nos proporciona un reporter al que llama progress, que no es más que la salida por consola que nos indica qué ha sucedido con nuestros tests. Añadiremos dos más:

  • junit: Generará un fichero .xml en formato junit. Este informe está especialmente indicado cuando queremos que sea consumido por otras herramientas como Jenkins o Bamboo.
  • coverage: Generaremos informes visuales (html) mostrando el porcentaje de código cubierto por nuestros tests. Además generaremos un fichero lcov especialmente indicado si queremos exportar nuestro informe a alguna herramienta tipo Sonar.

Para ello hacemos unos ligeros ajustes en nuestro fichero package.json:

  "devDependencies": {
    "bower": "^1.3.1",    
    "karma": "~0.10",
    "karma-junit-reporter": "^0.2.2",
    "karma-coverage": "~0.1"
  },
  "scripts": {
    "postinstall": "bower install",
    "pretest": "npm install",
    "test": "karma start karma.conf.js --reporters progress,junit,coverage"    
  }

Y retocamos también nuestro karma.conf.js:

module.exports = function (config) {
    config.set({
        basePath: './',
        reporters: ['junit', 'coverage'],
        files: [
            'app/lib/angular/angular.js',
            'app/lib/angular-mocks/angular-mocks.js',
            'app/js/**/*.js',
            'tests/**/*.js'
        ],
        autoWatch: true,
        frameworks: ['jasmine'],
        browsers: ['Chrome'],
        singleRun: true,
        plugins: [
            'karma-chrome-launcher',
            'karma-firefox-launcher',
            'karma-jasmine',
            'karma-junit-reporter',
            'karma-coverage'
        ],
        junitReporter: {
            outputFile: 'test_reports/junit/junit.xml',
            suite: 'unit'
        },
        preprocessors: {
            'app/js/**/*.js': ['coverage']
        },
        coverageReporter: {
            dir: 'test_reports/coverage/',            
            reporters: [
                {type: 'lcov', subdir: '.'},
                {type: 'cobertura', subdir: '.', file: 'cobertura.xml'}
            ]
        }

    });
};

Ahora, cuando lancemos nuestros tests observaremos que se nos creará un directorio test_reports con dos subdirectorios: junit y coverage con los respectivos informes.

Y podremos ver gráficamente la cobertura de nuestro código.

9. Referencias.


10. Conclusiones.

AngularJS es un excelente framework para desarrollo de aplicaciones Javascript que corren en el lado del cliente. El verdadero potencial de este framework reside en las directrices y herramientas que proporciona para diseñar aplicaciones altamente mantenibles.

Angular nos permite separar la lógica de negocio (o lógica de presentación) de la propia vista. Nos proporciona múltiples mecanismos para separar esa lógica en diferentes unidades funcionales como son los servicios, filtros, directivas, controladores, etc… y que podamos aislarlos y testearlos de manera independiente: principio de alta cohesión y bajo acoplamiento.

Cualquier duda en la sección de comentarios.

Espero que este tutorial os haya sido de ayuda. Un saludo.

Miguel Arlandy

marlandy@autentia.com

Twitter: @m_arlandy

5 COMENTARIOS

  1. Disculpen la molestia pero me gustaría saber si pueden indicarme el paso a paso de como hacer para ejecutar Pruebas de test unitario de Jasmine.. desde la herramienta Jenkins… he buscado información al respecto pero no logro conseguir nada concreto.. algún pluging? o alguno otro método?– estaría muy agradecido.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad