Mostrando las entradas con la etiqueta Aplicaciones Web. Mostrar todas las entradas
Mostrando las entradas con la etiqueta Aplicaciones Web. Mostrar todas las entradas

lunes, 4 de abril de 2016

Prácticas Recomendadas para Proyectos con AngularJS 1.x Parte V: Pruebas


Introducción

En posts anteriores se ha venido desarrollando una aplicación web con AngularJS 1.5, aplicando las recomendaciones que han sido aceptadas por el propio equipo de Google tanto en estructura del proyecto, secciones y rutas como el estilo de desarrollo de componentes como Directivas, Constantes, Controladores y Servicios, e inclusive se han abordado algunos puntos a tener en cuenta para publicar en un ambiente de producción.

Ahora para finalizar esta serie de posts se tratará el tema de las pruebas en la aplicación, tanto unitarias como las conocidas como end-to-end (e2e).

Pruebas Unitarias

Herramientas y Librerías

Para las pruebas se recomienda usar una librería que permita escribir el código de manera clara y descriptiva, casi como una historia.

Dos de las librerías más usadas son Jasmine y Mocha. Personalmente prefiero Jasmine (desde la versión 2) ya que es la que se usa en los tutoriales oficiales de Google de AngularJS y adicionalmente incluye varias de las características más requeridas en una librería de este tipo: aserciones (assertions) y mocks/spy/stubs, mientras que con Mocha se requiere de librerías adicionales para ello, aunque esto último puede ser una ventaja para Mocha si se tiene preferencia por otras librerías de aserciones o mocks. Una comparación más detallada sobre ambas librerías se puede encontrar en: http://thejsguy.com/2015/01/12/jasmine-vs-mocha-chai-and-sinon.html

Para ejecutar las pruebas unitarias se usará Karma ya que su fácil configuración y uso permite que puedan ejecutarse las pruebas tanto localmente como en un servidor de integración continua.

Como se mencionó anteriormente Jasmine ya permite construir mocks, pero para seguir la recomendación se usará Sinon.JS para ello.

Dado que las pruebas unitarias también se escriben usando JavaScript se requiere de un navegador web que lo interprete y lo ejecute sobre el código de la aplicación web, sin embargo existen pocas razones prácticas para usar un navegador con interfaz gráfica para este propósito por lo que se usará PhantomJS, el cual es un navegador web basado en WebKit precisamente sin interfaz gráfica.

Para usar estas librerías/herramientas más fácilmente mediante Gulp se instalarán los siguientes plugins:
  • karma: Paquete principal de Karma.
  • karma-angular-filesort: La tarea de Gulp "inject" está usando el plugin "gulp-angular-filesort" para ordenar adecuadamente los archivos JavaScript que se referencian en "index.html" según las dependencias declaradas. Este nuevo plugin permite hacer lo mismo pero de manera interna en Karma al momento de iniciar las pruebas.
  • karma-ng-html2js-preprocessor: Algunas pruebas unitarias requieren el uso de las vistas HTML de la aplicación web, por ejemplo las pruebas de directivas (o en general las que usen el método "scope.$digest()"). Este plugin toma el contenido de los archivos HTML y los adiciona en un "$templateCache" para que pueda durante en las pruebas en lugar de tener que configurar el servicio "$httpBackend" con cada llamado a estos archivos manualmente y evitando así los errores "Unexpected GET /file.html".
  • karma-spec-reporter: Se adiciona este plugin para reportar el resultado de las pruebas de una manera un poco más clara.
npm install karma karma-phantomjs-launcher karma-jasmine karma-angular-filesort karma-ng-html2js-preprocessor karma-spec-reporter --save-dev

Adicionalmente se requieren dos dependencias de desarrollo mediante Bower:
  • angular-mocks: Provee varias funcionalidades para el desarrollo de pruebas de AngularJS, como por ejemplo servicios mock propios de AngularJS.
  • jasmine-sinon: Permite usar algunos matchers adicionales de Sinon.JS dentro del código Jasmine, instalando de paso ambas librerías también como dependencias.
bower install angular-mocks#1.5.0 jasmine-sinon --save-dev

Nota: En este caso se indica manualmente la versión de "angular-mocks" (1.5.0) para que coincida con la misma que se tiene de AngularJS en la aplicación.

Para configurar Karma se puede usar el comando "karma init" (debe ejecutarse desde la raíz del proyecto) el cual realizará una serie de preguntas sobre el código para las pruebas:
karma init

Which testing framework do you want to use ?
Press tab to list possible options. Enter to move to the next question.
> jasmine

Do you want to use Require.js ?
This will add Require.js plugin.
Press tab to list possible options. Enter to move to the next question.
> no

Do you want to capture any browsers automatically ?
Press tab to list possible options. Enter empty string to move to the next question.
> PhantomJS
>

What is the location of your source and test files ?
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".
Enter empty string to move to the next question.
> app/**/*.js
>

Should any of the files included by the previous patterns be excluded ?
You can use glob patterns, eg. "**/*.swp".
Enter empty string to move to the next question.
>

Do you want Karma to watch all the files and run the tests on change ?
Press tab to list possible options.
> yes


Config file generated at "D:\angularjs-demo\karma.conf.js".

Al finalizar se debe tener un nuevo archivo "karma.conf.js" con la configuración se indicó en las respuestas anteriores. Algunos cambios son requeridos para trabajar con todas las herramientas previamente mencionadas, así que debe abrirse el archivo y dejarlo como el siguiente ejemplo:

karma.conf.js
// Karma configuration

module.exports = function(config) {
  config.set({

    // base path that will be used to resolve all patterns (eg. files, exclude)
    basePath: '',


    // frameworks to use
    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
    frameworks: ['jasmine', 'angular-filesort'],


    // list of files / patterns to load in the browser
    files: [
      // bower:js
      // endbower
      'app/**/*.js',
      'app/**/*.html'
    ],


    angularFilesort: {
      whitelist: [
        'app/**/*.js'
      ]
    },


    // list of files to exclude
    exclude: [
    ],


    // preprocess matching files before serving them to the browser
    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
    preprocessors: {
      'app/**/*.html': ['ng-html2js']
    },


    ngHtml2JsPreprocessor: {
      stripPrefix: 'app/',
      moduleName: 'weatherApp.templates'
    },


    // test results reporter to use
    // possible values: 'dots', 'progress'
    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
    reporters: ['spec'],


    // web server port
    port: 9876,


    // enable / disable colors in the output (reporters and logs)
    colors: true,


    // level of logging
    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
    logLevel: config.LOG_INFO,


    // enable / disable watching file and executing tests whenever any file changes
    autoWatch: true,


    // start these browsers
    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
    browsers: ['PhantomJS'],


    // Continuous Integration mode
    // if true, Karma captures browsers, runs the tests and exits
    singleRun: false,

    // Concurrency level
    // how many browser should be started simultaneous
    concurrency: Infinity
  })
}

  • Línea 12: Se indica que se usarán la librerías Jasmin (para escribir las pruebas) y el plugin "angular-filesort" dentro de Karma.
  • Líneas 17-18: Más adelante se creará una tarea de Gulp que inyecte las dependencias de Bower en la lista de archivos que se deben tener en cuenta para las pruebas.
  • Líneas 19-20: Se incluyen los archivos JavaScript y HTML de la aplicación en la lista de archivos que se deben tener en cuenta para las pruebas.
  • Líneas 24-28: Mediante la propiedad "whitelist" se indica que sólo los archivos JavaScript de la aplicación deben procesarse por el plugin "karma-angular-filesort".
  • Líneas 43-46: Configuración del plugin "karma-ng-html2js-preprocessor" para que no incluya el prefijo "app/" en la ruta de las vistas HTML en el "$templateCache" generado y se le asignará el módulo "weatherApp.templates", el cual no corresponde a ninguno que se esté usando en la aplicación, se usará sólo para las pruebas.
  • Línea 74: Se especifica que las pruebas ejecutadas con Karma usarán PhantomJS como navegador.

gulp-paths.json
{
  "app": "./app/",
  "js": ["**/*.js"],
  "jsApp": ["**/*.js", "!**/*.spec.js"],
  "jsMin": "js/scripts.min.js",
  "css": ["**/*.css"],
  "views": ["**/*.html", "!index.html"],
  "templatesCache": "templates.js",
  "dist": "./dist/"
}

Se agrega la entrada "jsApp" con expresiones regulares que permiten incluir el código JavaScript de la aplicación y excluir el código de pruebas (sufijo ".spec.js"), es decir que donde se ese esta entrada sólo se tendrá en cuenta el código realmente funcional de la aplicación.

La razón para tener los archivos con las pruebas unitarias también dentro de "app" es que se recomienda tener el archivo con las pruebas unitarias de un componente en la misma carpeta que el componente probado, así las pruebas son más fáciles de encontrar a primera vista, mejorando la probabilidad de tener ambos archivos actualizados.

Adicionalmente se modificará el archivo ".jshintrc" para indicar que se usará Jasmine y otros elementos de pruebas, para que no sean reportados como errores en el código JavaScript:

.jshintrc
{
  "node": true,
  "browser": true,
  "bitwise": true,
  "curly": true,
  "eqeqeq": true,
  "forin": true,
  "freeze": true,
  "latedef": "nofunc",
  "noarg": true,
  "nonbsp": true,
  "nonew": true,
  "undef": true,
  "unused": true,
  "strict": true,
  "jasmine": true,
  "globals": {
    "angular": false,
    "google": false,
    "sinon": false,
    "inject": false
  }
}


gulpfile.js
'use strict';

var gulp        = require('gulp');
var plugins     = require('gulp-load-plugins')();
var paths       = require('./gulp-paths.json');
var wiredep     = require('wiredep').stream;
var del         = require('del');
var st          = require('st');
var runSequence = require('run-sequence');
var KarmaServer = require('karma').Server;

// Search for js and css files created for injection in index.html
gulp.task('inject', function () {
  return gulp.src('./index.html', {cwd: paths.app})
    .pipe(plugins.inject(
      gulp.src(paths.jsApp, {cwd: paths.app}).pipe(plugins.angularFilesort()), {
        relative: true
    }))
    .pipe(plugins.inject(
      gulp.src(paths.css, {cwd: paths.app, read: false}), {
        relative: true
    }))
    .pipe(gulp.dest(paths.app));
});

// Inject libraries via Bower in between of blocks "bower:xx" in index.html
gulp.task('wiredep', ['inject'], function () {
  return gulp.src('index.html', {cwd: paths.app})
    .pipe(wiredep({
      'ignorePath': '..'
    }))
    .pipe(gulp.dest(paths.app));
});

// Inject libraries via Bower in between of blocks "bower:xx" in karma.conf.js
gulp.task('wiredep-karma', function () {
  return gulp.src('./karma.conf.js')
    .pipe(wiredep({devDependencies: true}))
    .pipe(gulp.dest('./'));
});

// Run test once and exit
gulp.task('test', ['wiredep-karma'], function (done) {
  new KarmaServer({
    configFile: __dirname + '/karma.conf.js',
    singleRun: true
  }, done).start();
});

// Watch for file changes and re-run tests on each change
gulp.task('tdd', ['wiredep-karma'], function (done) {
  new KarmaServer({configFile: __dirname + '/karma.conf.js'}, done).start();
});

// Compress into a single file the ones in between of blocks "build:xx" in index.html
gulp.task('compress', ['wiredep'], function () {
  return gulp.src('index.html', {cwd: paths.app})
    .pipe(plugins.useref({ searchPath: ['./', paths.app] }))
    .pipe(plugins.if('*/scripts.min.js', plugins.replaceTask({
      patterns: [
        {
          match: 'debugInfoEnabled',
          replacement: 'false'
        },
        {
          match: 'debugLogEnabled',
          replacement: 'false'
        }
      ]
    })))
    .pipe(plugins.if('**/*.js', plugins.ngAnnotate()))
    .pipe(plugins.if('**/*.js', plugins.uglify({
      mangle: true
    }).on('error', plugins.util.log)))
    .pipe(plugins.if('**/*.css', plugins.cssnano()))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:build', function () {
  return gulp.src(paths.views, {cwd: paths.app})
    .pipe(plugins.htmlmin({collapseWhitespace: true}))
    .pipe(plugins.angularTemplatecache({
      module: 'weatherApp',
      moduleSystem: 'IIFE'
    }))
    .pipe(plugins.uglify({
      mangle: true
    }))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:concat', ['templates:build'], function () {
  return gulp.src([paths.jsMin, paths.templatesCache], {cwd: paths.dist})
    .pipe(plugins.concat(paths.jsMin))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:clean', ['templates:concat'], function () {
  return del(paths.dist + paths.templatesCache);
});

// Copies the assets into the dist folder
gulp.task('copy:assets', function () {
  return gulp.src('assets*/**', {cwd: paths.app})
    .pipe(gulp.dest(paths.dist));
});

// Looks for code correctness errors in JS and prints them
gulp.task('jshint', function() {
  return gulp.src(paths.js, {cwd: paths.app})
    .pipe(plugins.jshint())
    .pipe(plugins.jshint.reporter('jshint-stylish'))
    .pipe(plugins.jshint.reporter('fail'));
});

// Looks for code style errors in JS and prints them
gulp.task('jscs', function () {
  return gulp.src(paths.js, {cwd: paths.app})
    .pipe(plugins.jscs())
    .pipe(plugins.jscs.reporter())
    .pipe(plugins.jscs.reporter('fail'));
});

// Cleans the dist folder
gulp.task('clean:dist', function () {
  return del(paths.dist + '**/*');
});

// Watch changes on application files
gulp.task('watch', function() {
  gulp.watch(paths.css, {cwd: paths.app}, ['inject']);
  gulp.watch(paths.jsApp, {cwd: paths.app}, ['jshint', 'jscs', 'inject']);
  gulp.watch(['./bower.json'], ['wiredep']);
  gulp.watch('**/*.html', {cwd: paths.app}, function(event) {
    gulp.src(event.path)
      .pipe(plugins.connect.reload());
  });
});

// Starts a development web server
gulp.task('server', function () {
  plugins.connect.server({
    root: paths.app,
    hostname: '0.0.0.0',
    port: 8080,
    livereload: true,
    middleware: function (connect, opt) {
      return [
        st({
          path: 'bower_components',
          url: '/bower_components'
        })
      ];
    }
  });
});

// Starts a server using the production build
gulp.task('server-dist', ['build'], function () {
  plugins.connect.server({
    root: paths.dist,
    hostname: '0.0.0.0',
    port: 8080
  });
});

// Production build
gulp.task('build', function (done) {
  runSequence('jshint', 'jscs', 'clean:dist', 'compress', 'templates:clean', 'copy:assets', done);
});

// Generates the documentation
gulp.task('ngdocs', function () {
  var options = {
    title: "Weather Web App Documentation",
    html5Mode: false
  };
  return gulp.src(paths.jsApp, {cwd: paths.app})
    .pipe(plugins.ngdocs.process(options))
    .pipe(gulp.dest('./docs'));
});

// Starts a server with the docs
gulp.task('server-docs', ['ngdocs'], function () {
  plugins.connect.server({
    root: './docs',
    hostname: '0.0.0.0',
    port: 8081
  });
});

gulp.task('default', ['inject', 'wiredep', 'server', 'watch', 'tdd']);
  • Línea 10: Se agrega una variable con el servidor que inicia las pruebas usando Karma.
  • Línea 16: En la tarea "inject" sólo se requiere el código funcional de la aplicación, para no inyectar el código de pruebas en "index.html", es por ello que se cambia "paths.js" por "paths.jsApp".
  • Líneas 36-40: Se crea la tarea "wiredep-karma" para que mediante "wiredep" se agreguen las dependencias de Bower en el archivo "karma.conf.js" entre los comentarios "bower:js" y "endbower", incluyendo las dependencias de desarrollo.
  • Líneas 43-48: Para ejecutar las pruebas unitarias se crea la tarea "test" para iniciar Karma, luego de ejecutar "wiredep-karma". Aquí se usa "__dirname" dado que se requiere indicar la ruta completa de la ubicación del archivo "karma.conf.js". Teniendo "singleRun" con valor "true" se indica que al ejecutar esta tarea las pruebas sólo se ejecutarán una vez, lo cual puede ser útil para verificar cambios recientes localmente o para ejecutar las pruebas en un servidor de integración continua.
  • Líneas 51-53: Se crea la tarea "tdd" que como su nombre lo indica permite a quienes practican el Desarrollo Orientado a Pruebas (Test-driven Development - TDD) puedan desarrollar cambios en la aplicación y cada vez que se guardan las pruebas se ejecutan para saber si los últimos cambios afectan las pruebas unitarias.
  • Línea 178: La documentación sólo se genera con base en el código funcional, así que también e cambia "paths.js" por "paths.jsApp".
  • Línea 192: Se adiciona la tarea "tdd" a la lista de tareas que se ejecutan al usar el comando "gulp" para que pueda verse el resultado de las pruebas mientras se desarrolla.

Pruebas de Directivas

A continuación se mostrarán las pruebas unitarias para la directiva que se desarrolló en la aplicación "waSectionHeader":

app/widgets/wa-section-header/wa-section-header.directive.spec.js
(function() {
  'use strict';

  describe('waSectionHeader', function() {

    var $scope;
    var $compile;

    beforeEach(function() {
      module('weatherApp.widgets');
      module('weatherApp.templates');

      inject(function($rootScope, _$compile_) {
        $scope = $rootScope.$new();
        $compile = _$compile_;
      });
    });

    it('Adds the header to the HTML content from plain text', function() {
      var element = '';

      element = $compile(element)($scope);
      $scope.$digest();

      expect(element.find('h1').text()).toEqual('Plain Title');
      expect(element.find('p').text()).toEqual('Plain Subtitle');
    });

    it('Adds the header to the HTML content from the scope', function() {
      $scope.header = {
        title: 'Scope Title',
        subtitle: 'Scope Subtitle'
      };

      var element = '';

      element = $compile(element)($scope);
      $scope.$digest();

      expect(element.find('h1').text()).toEqual('Scope Title');
      expect(element.find('p').text()).toEqual('Scope Subtitle');
    });
  });
}());
  • Línea 4: Se empieza definiendo una suite de pruebas con Jasmine usando la función "describe". Una suite de pruebas (test suite) es un conjunto de pruebas unitarias relacionadas, bien sea porque pruebas un mismo componente o una misma funcionalidad. En este caso se tiene como descripción de la suite el nombre de la directiva "waSectionHeader".
  • Líneas 6-7: Definición de variables globales dentro de la prueba, las cuales serán usadas en varios métodos de prueba.
  • Líneas 9-17:  Se define una función que se ejecutará antes de cada uno de los métodos de prueba en la suite, usando "beforeEach", la cual:
    • Carga los módulos "weatherApp.widgets" (en el que se encuentra la directiva) y "weatherApp.templates" (en el que se cargan las vistas HTML para las pruebas).
    • Usa la función "inject" para inyectar como dependencias los servicios "$rootScope" y "$compile" y asignarlos a las variables globales de la suite. En el caso de "$compile" se tiene entre símbolos guión bajo "_" ya que "inject" ofrece la posibilidad de indicar los nombres de las dependencias de esta manera, quitando internamente dichos símbolos al buscar los objetos a retornar, permitiendo así que las variables a las que se asignen las dependencias obtenidas puedan tener el mismo nombre del servicio y tener así mayor claridad para leer el código.
  • Líneas 19-27: Mediante la función "it" se define un método de prueba, especificación o "Spec" con una descripción que explique lo que se está probando, que idealmente debe ser una sola funcionalidad. En este caso se probará que la directiva pueda tomar el texto del título y subtítulo como texto indicado directamente en el HTML.
    • Línea 20: Se crea el texto HTML con la directiva y los textos para título y subtítulo.
    • Línea 22: Compila el HTML y se retorna un objeto "element" que tendrá el DOM generado a partir de la directiva.
    • Línea 23: Usando "$digest" se procesa la directiva usada y a partir de ese momento la variable "element" ya tiene el DOM con el HTML resultante.
    • Líneas 25-26: Se comprueba que el resultado obtenido sea el esperado usando expectativas (expectations) mediante la función "expect", la cual recibe como parámetro el valor obtenido como resultado de la operación que se está probando (en este caso el texto generado dentro de los tags "h1" y "p") y se contrasta mediante el comparador (matcher) "toEqual" con el valor que se espera tener. Si todas las comprobaciones son exitosas, se considera que la prueba ha sido exitosa, pero si al menos una falla, entonces se considera una prueba fallida.
  • Líneas 29-42: De manera similar el método de prueba anterior, se verifica que la directiva pueda tomar valores para mostrar un título y subtítulo, pero esta vez los toma de un objeto que se encuentre dentro del scope usando en el HTML.
Al ejecutar las pruebas mediante el comando "gulp test" debe obtenerse un resultado como el siguiente:
Figura 1 - Pruebas unitarias exitosas

Si se introduce un error en alguna de las comparaciones, al ejecutar nuevamente "gulp test" podrá verse cómo se reportan los errores:

Figura 2 - Prueba unitaria con error

Como se puede ver en la figura 2, se indica tanto la descripción de la prueba que falló, como la razón del error (Expected 'Plain Subtitle' to equal 'Plain Subtitle Test'.) e inclusive la línea del archivo en la que se presentó el error.

Nota: Al final de una ejecución con pruebas fallidas puede aparecer una traza adicional de error "Error: 1 at formatError ..." con invocaciones internas de Gulp. Esto se debe a que la tarea de Gulp se reporta como ejecutada con error. Un pull-request fue enviado al repositorio que tiene la documentación de la integración entre Karma y Gulp (gulp-karma) para que al finalizar Karma la tarea de Gulp se reportara como exitosa, pero fue rechazado ya que en procesos automatizados (construcción/entrega continua por ejemplo) se requiere verificar si las tareas se ejecutaron correctamente antes de por ejemplo generar o desplegar una nueva versión. Dicha traza con las invocaciones internas no representan un problema para la ejecución de las pruebas, así que simplemente puede ignorarse.

Pruebas de Servicios

Ahora se realizarán las pruebas unitarias del servicio "WeatherService".

app/core/weather.service.spec.js
(function() {
  'use strict';

  describe('WeatherService', function() {

    var WeatherService;
    var $httpBackend;
    var fakeData = {
      name: 'Fake City',
      sys: {
        country: 'Fake Country'
      },
      weather: [{
        description: 'This is a test',
        icon: 'test'
      }],
      main: {
        temp: 20
      }
    };
    var expectedData = {
      name: 'Fake City',
      country: 'Fake Country',
      description: 'This is a test',
      temperature: 20,
      image: 'http://openweathermap.org/img/w/test.png'
    };

    beforeEach(function() {
      module('weatherApp.core');

      inject(function(_WeatherService_, _$httpBackend_) {
        WeatherService = _WeatherService_;
        $httpBackend = _$httpBackend_;
      });
    });

    afterEach(function() {
      $httpBackend.resetExpectations();
    });

    it('Should return the city info', function(done) {
      $httpBackend
        .expectGET(/http:\/\/api\.openweathermap\.org\/data\/2\.5\/weather\?.*/g)
        .respond(fakeData);

      WeatherService.checkCity()
        .then(
          function(data) {
            expect(data).toEqual(expectedData);
            done();
          });

      $httpBackend.flush();
    });

    it('Should handle an unexisting city response', function(done) {
      $httpBackend
        .expectGET(/http:\/\/api\.openweathermap\.org\/data\/2\.5\/weather\?.*/g)
        .respond({
          cod: '404'
        });

      WeatherService.checkCity()
        .catch(
          function(error) {
            expect(error).toEqual('City not found');
            done();
          });

      $httpBackend.flush();
    });
  });
}());
  • Línea 34: Se inyecta el servicio "$httpBackend" el cual permite indicar que debe responder el servicio "$http" cuando sea llamado por alguno de los componentes que hagan parte de la prueba para evitar que los datos de la prueba dependan de componentes o servicios externos. En este caso "WeatherService" usa "$http" para consultar el clima mediante el API de OpenWeatherMap. A diferencia de las funciones de prueba anteriores, aquí se está indicando el parámetro "done" el cual es una función que se debe llamar cuando se tenga código ejecutado asíncronamente, como es el caso de "WeatherService.checkCity" el cual retorna una promesa.
  • Líneas 38-40: Al finalizar la ejecución de cada una de las funciones de prueba se invocará "$httpBackend.resetExpectations()" borrar las expectativas antes de iniciar otra prueba.
  • Líneas 42-55: Se define un método de prueba en el cual se espera que cuando se consulte una ciudad se obtengan los datos del clima en esta.
    • Líneas 43-45: Usando "$httpBackend" se indica que al hacerse un llamado a la URL de OpenWeatherMap se responda con el objeto indicado en la variable global "fakeData". Aquí se usa una expresión regular en lugar de una cadena (string) para la URL para no tener que especificar todos los parámetros GET (query string).
    • Líneas 47-52: Se invoca la función "WeatherService.checkCity" y se espera que al resolver exitosamente la promesa el resultado sea igual al objeto de la variable global "expectedData" y se invoca la función "done" para indicar que la prueba ha finalizado.
    • Línea 54: Para indicar que todas las peticiones HTTP deben responder se invoca "$httpBackend.flush()".
  • Líneas 57-72: Ahora se define una prueba que permite verificar que cuando se obtiene como respuesta que la ciudad no existe la promesa se rechace y que el valor indicado como la razón del rechazo sea el texto "City not found".

Pruebas de Controladores

Para las pruebas del controlador "CityController" se tiene lo siguiente:

app/home/city/city.controller.spec.js
(function() {
  'use strict';

  describe('CityController', function() {

    var $controller;
    var $q;
    var $scope;
    var WeatherService;
    var WeatherServiceMock;
    var $window;
    var $windowMock;
    var mockTitle = 'Mock Title';
    var mockCity = {
      name: 'Mock City',
      country: 'Mock Country',
      description: 'This is a test',
      temperature: 20,
      image: 'http://url.com/test.png'
    };

    beforeEach(function() {
      module('weatherApp.home');
      module('weatherApp.templates');

      inject(function($rootScope, _$q_, _$controller_, _$window_, _WeatherService_) {
        $scope = $rootScope.$new();
        $scope.pageTitle = mockTitle;
        $q = _$q_;
        $controller = _$controller_;
        $window = _$window_;
        $windowMock = sinon.mock($window);

        WeatherService = _WeatherService_;
        WeatherServiceMock = sinon.mock(WeatherService);
      });
    });

    afterEach(function() {
      WeatherServiceMock.restore();
      $windowMock.restore();
    });

    it('Should have the city weather info', function() {
      var cityController = buildCityController(1, $q.resolve(mockCity));

      expect(cityController.cityWeather).toBe(mockCity);
      expect($scope.pageTitle).toEqual(mockCity.name + ' - ' + mockTitle);
      WeatherServiceMock.verify();
    });

    it('Should handle an unexisting city', function() {
      $windowMock.expects('alert').once().withArgs('Error checking the weather.');

      var cityController = buildCityController(1, $q.reject());
      var unknownCity = {
        name: 'Unknown City'
      };

      expect(cityController.cityWeather).toEqual(unknownCity);
      expect($scope.pageTitle).toEqual(unknownCity.name + ' - ' + mockTitle);
      WeatherServiceMock.verify();
      $windowMock.verify();
    });

    function buildCityController(cityId, cityResult) {
      WeatherServiceMock.expects('checkCity').once().withArgs(cityId).returns(cityResult);

      var cityController = $controller('CityController', {
        $rootScope: $scope,
        $stateParams: {
          cityId: cityId
        },
        $window: $window,
        WeatherService: WeatherService
      });

      // Required to resolve the promise on WeatherService
      $scope.$apply();

      return cityController;
    }
  });
}());
  • Línea 32: Usando "sinon.mock" se crea un mock del servicio "$window" para no usar directamente el servicio real y adicionalmente poder crear expectativas y comprobaciones sobre este, dado que el controlador lo usa para mostrar una alerta en el navegador web si la ciudad consultada no existe.
  • Línea 35: Se crea también un mock para el servicio "WeatherService" para que no se realicen llamados reales al API de OpenWeatherMap.
  • Línea 39-42: Luego de cada prueba se invoca "restore" para borrar las expectativas indicadas en los mocks.
  • Líneas 44-50: En la primera prueba se espera que al consultar una ciudad se pueda ver la información del clima:
    • Línea 45: Se construye el controlador indicando que el ID de la ciudad será "1" y la respuesta de "WeatherServiceMock" será una promesa exitosamente resuelta con los datos del objeto de la variable global "mockCity" usando "$q.resolve".
    • Línea 47: Se comprueba que el valor resultante en "cityController.cityWeather" sea el objeto de la variable global "mockCity". Aunque el comprobador (matcher) "toEqual" pudo haberse usado, ahora se tiene "toBe" simplemente como demostración. Mientras "toEqual" comprueba que dos valores u objetos sean equivalentes (que los atributos de los objetos tengan los mismos valores), "toBe" compara si dos objetos son exactamente el mismo, lo cual ocurre en este caso ya que al usar un mock para indicar los datos de la ciudad, estos siempre corresponderán al objeto que se usó como parámetro en la expectativa.
    • Línea 48: Dado que en el controlador se modifica el título de la página ($scope.pageTitle) según la ciudad encontrada, se comprueba que el resultado final corresponda con el nombre de la ciudad retornada por el mock.
    • Línea 49: Adicionalmente para verificar que el mock "WeatherServiceMock" haya sido llamado de acuerdo a la expectativa indicada (una sola vez con el parámetro "1") se usa "verify()".
  • Líneas 52-64: Se prueba que el controlador sea capaz de manejar un intento de consultar una ciudad que no exista.
    • Línea 53: Cuando se obtiene como resultado que la ciudad no existe, el controlador está usando el servicio "$window" para mostrar una alerta al usuario en el navegador web. Aquí se crea una expectativa en el mock de ese servicio que espera que la función "alert" sea llamada una vez con el parámetro "Error checking the weather.".
    • Línea 55: Esta vez se crea el controlador, pero indicando que la respuesta de "WeatherServiceMock" será una promesa rechazada usando "$q.reject()".
    • Línea 60: Al rechazarse la promesa de "WeatherService" en el controlador se ejecuta la función "checkCityError" la cual crea un objeto con el atributo "name" de valor "Unknown City" y lo asigna a "cityWeather". Dado que en este caso "cityController.cityWeather" tendrá un objeto que es equivalente, pero el mismo que la variable local "unknownCity", se debe usar el comparador "toEqual".
    • Líneas 66-82: Centraliza la lógica requerida para crear instancias del "CityController" usando los mocks y demás datos necesarios para ello (como se define en el constructor de CityController), así cualquier función de prueba puede simplemente invocarla con los parámetros que necesite la prueba. Se hace de esta manera ya que la consulta del clima en una ciudad se hace cuando se inicializa el controlador, en lugar de en una función ejecutada con una acción del usuario. Se usa también "$scope.$apply()" para que AngularJS inicie los procesos internos necesarios para responder las promesas que se indicaron como resultado (cityResult).

Al ejecutar de nuevo todas las pruebas (gulp test) se obtiene un resumen con el resultado de todas las ejecuciones en las suites desarrolladas:

Figura 3 - Varias suites de pruebas ejecutadas

Pruebas end-to-end (e2e)

Mientras las pruebas unitarias se centran en verificar el código de la aplicación, las pruebas end-to-end procuran simular algunos comportamientos de los usuarios reales en la aplicación y verificar que el resultado obtenido sea lo que un usuario espera.

Para ejecutar este tipo de pruebas el propio equipo de desarrollo de AngularJS creó la herramienta Protractor, la cual está basada en WebDriverJs (Selenium), para tomar escenarios de prueba y ejecutarlos en navegadores web simulando navegación del usuario.

Al usar Selenium se hace necesario que como pre-requisito se necesite tener instalado el JDK de Java.

Protactor se debe instalar de manera global mediante el comando "npm install -g protractor", el cual también instalará "webdriver-manager" para facilitar la integración entre Protactor y Selenium.

Posteriormente debe ejecutarse "webdriver-manager update" para descargar (o actualizar) los archivos de Selenium.

Archivo de Configuración

Para configurar Protactor basta con crear un archivo "conf.js" en la raíz del proyecto:

conf.js
exports.config = {
  framework: 'jasmine',
  seleniumAddress: 'http://localhost:4444/wd/hub',
  baseUrl: 'http://localhost:8080',
  specs: ['tests/e2e-scenarios.js']
}

Los escenarios de pruebas estarán desarrollados usando Jasmine (al igual que las pruebas unitarias), estarán en el archivo "tests/e2e-scenarios.js" y se ejecutarán sobre la aplicación web que se encuentre disponible en la URL "http://localhost:8080". Por defecto el navegador usado será Google Chrome, pero puede cambiarse definiendo la llave "capabilities.browserName".

Escenarios de Prueba

tests/e2e-scenarios.js
(function() {
  'use strict';

  describe('weatherApp', function() {

    describe('home', function () {
      beforeEach(function() {
        browser.get('/');
      });

      it('Should has an appropriate title', function() {
        expect(browser.getTitle()).toEqual('Home - Weather Web App');
      });

      it('Should has a list of cities', function() {
        var cities = element.all(by.css('.list-group-item')).getText();

        expect(cities.count()).toEqual(15);
        expect(cities.get(2).getText()).toEqual('Cali - CO');
      });
    });

    describe('city', function() {
      beforeEach(function() {
        browser.get('/#/home/city/3687925');
      });

      it('Should has an appropriate title', function() {
        expect(browser.getTitle()).toEqual('Cali - Weather Web App');
      });
    });

  });
}());
  • Línea 4: Se define la suite de pruebas "weatherApp" para la aplicación.
  • Líneas 6-21: Para la sección "home" se define una suite anidada (nested).
    • Líneas 7-9: Antes de cada prueba se accederá en el navegador web a la raíz (/). La variable global de Protactor "browser" es la que permite manipular el navegador web.
    • Líneas 11-13: Función de prueba que verifica que el título en el navegador sea "Home - Weather Web App" usando expectativas y matchers de Jasmine.
    • Líneas 15-20: Función de prueba para identificar si la sección está mostrando la lista de ciudades.
      • Línea 16: Mediante es localizador (locator) "by.css" de Protactor se obtiene el objeto del "ElementFinder" que se espera tenga la lista de ciudades. En "app/home/home.html" se tiene que cada elemento de la lista de ciudades tendrá la clase CSS "list-group-item".
      • Líneas 18-19: Se comprueba que la lista de ciudades tenga 15 elementos y que la tercera que se muestra sea "Cali - CO".
  • Líneas 23-31: Para la sección "city" se define otra suite anidada en la que se prueba si al consultar una ciudad se llega a una página que tenga como título el nombre de la misma seguida del nombre de la aplicación.

Para ejecutar las pruebas e2e se requiere de tres comandos, cada uno ejecutado bien sea en una instancia diferente de la línea de comandos (consola/terminal) o enviándolos a ejecutar en background (si ello es soportado):
  • webdriver-manager start: Inicia el servidor de Selenium, del cual puede comprobarse su estado accediendo a la URL indicada en el campo "seleniumAddress" del archivo de configuración (http://localhost:4444/wd/hub).
  • gulp server-dist: Para esta aplicación es la tarea de Gulp que genera los archivos de la aplicación para producción e inicia un servidor web con la aplicación en "http://localhost:8080", tal como se indicó que se haría en el campo "baseUrl" del archivo de configuración.
  • protractor conf.js: Ejecuta los escenarios de prueba abriendo un navegador web y usando Selenium ejecuta las acciones indicadas en los escenarios.
Por cada prueba exitosa en el reporte final de las pruebas aparecerá un punto verde y al final se tendrá un resumen del número de pruebas ejecutas.

Figura 4 - Pruebas e2e exitosas

Mientras que al tener errores, aparecerá una F en lugar del punto y una descripción del error.

Figura 5 - Pruebas e2e con error

Nota: Debido a la complejidad que puede llegar a tener el mantenimiento de las pruebas e2e, especialmente en aplicaciones de mayor tamaño y/o con más elementos gráficos, el propio equipo de Google recomienda mantener el número de pruebas e2e relativamente bajo (en caso de decidir usar este tipo de pruebas) ya que puede darse que se termine invirtiendo demasiado tiempo ajustando las pruebas e2e cada vez que se hacen cambios en la aplicación. Un ejemplo de ello se puede encontrar en: http://googletesting.blogspot.com.co/2015/04/just-say-no-to-more-end-to-end-tests.html

Conclusiones

Con las recomendaciones aplicadas para el desarrollo de aplicaciones web con AngularJS vemos que no solamente se puede llegar a tener una aplicación web completamente funcional sino también con un esquema de pruebas que permite asegurar un poco más su estabilidad a medida que se incluyen cambios en esta.

De esta manera se ha dado cobertura a un buen número de las recomendaciones oficiales, aunque se recomienda leerlas ya que algunas han quedado por fuera de lo que se vio en esta serie de posts o también se van añadiendo o modificando con el tiempo.

El proyecto que se desarrolló se puede descargar desde: https://github.com/guillermo-varela/angularjs-demo/tree/part-v

Referencias

https://github.com/johnpapa/angular-styleguide/blob/master/a1/README.md
https://docs.angularjs.org/api/ngMock
http://jasmine.github.io

lunes, 28 de marzo de 2016

Prácticas Recomendadas para Proyectos con AngularJS 1.x Parte IV: Recomendaciones para Producción


Introducción

En posts anteriores se ha venido desarrollando una aplicación web con AngularJS 1.5, aplicando las recomendaciones que han sido aceptadas por el propio equipo de Google tanto en estructura del proyecto, secciones y rutas como el estilo de desarrollo de componentes como Directivas, Constantes, Controladores y Servicios.

En esta ocasión se van aplicar otras recomendaciones para generar la aplicación para publicar en un ambiente de producción.

Copia de las Vistas

Actualmente la única vista (archivo HTML) que se está copiando a la carpeta "dist" al ejecutar la tarea de Gulp "build" es "index.html". Para copiar los demás archivos HTML se creará una nueva tarea en "gulpfile.js", muy similar a la tarea ya existente "copy:assets".

gulp-paths.json
{
  "app": "./app/",
  "js": ["**/*.js"],
  "css": ["**/*.css"],
  "views": ["**/*.html", "!index.html"],
  "dist": "./dist/"
}

En la línea 5 se adiciona la entrada "views" con expresiones Glob que permiten encontrar todos los archivos HTML y otra que excluye el archivo "index.html" para no sobrescribir este archivo al copiar los otros HTML.

gulpfile.js
...
// Copies the HTML files into the dist folder
gulp.task('copy:html', function () {
  return gulp.src(paths.views, {cwd: paths.app})
    .pipe(gulp.dest(paths.dist));
});
...
// Production build
gulp.task('build', function (done) {
  runSequence('jshint', 'jscs', 'clean:dist', 'compress', 'copy:assets', 'copy:html', done);
});
...

Se definió la tarea "copy:html", copiando los archivos que se indican en "paths.views" hacia "dist" y se incluyó como parte la tarea "build".


Inyección Explícita de Depedencias

Si en este momento se intenta generar y ejecutar la aplicación para producción usando el comando "gulp server-dist" se debe ver un error como el siguiente en la consola del navegador web:

Uncaught Error: [$injector:modulerr] Failed to instantiate module weatherApp due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.home due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.core due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.blocks.router due to:
Error: [$injector:unpr] Unknown provider: t
http://errors.angularjs.org/1.5.0/$injector/unpr?p0=t
    at http://localhost:8080/js/vendor.min.js:4:27196
    at http://localhost:8080/js/vendor.min.js:5:15882
    at r (http://localhost:8080/js/vendor.min.js:5:14810)
    at i (http://localhost:8080/js/vendor.min.js:5:15110)
    at Object.s [as instantiate] (http://localhost:8080/js/vendor.min.js:5:15475)
    at i (http://localhost:8080/js/vendor.min.js:5:13419)
    at Object.provider (http://localhost:8080/js/vendor.min.js:5:13356)
    at r (http://localhost:8080/js/vendor.min.js:5:14198)
    at http://localhost:8080/js/vendor.min.js:5:14304
    at o (http://localhost:8080/js/vendor.min.js:4:27644)

Esto se debe a que todo el código JavaScript se está minificando y los nombres de las variables y parámetros se están acortando a una letra, incluyendo los que representan dependencias (como servicios por ejemplo), por lo que se trata de un error generado directamente por AngularJS y no por un error en la lógica de la aplicación.

Para solucionar este problema se incluirá el uso de "gulp-ng-annotate" minificar el código.

Como primer paso se debe instalar este plugin mediante npm:

npm install --save-dev gulp-ng-annotate

Luego se incluye su uso en la tarea de Gulp "compress" antes de aplicar el plugin "gulp-uglify" a los archivos JavaScript (recordando que en esta tarea también se minifican los archivos CSS):
...
// Compress into a single file the ones in between of blocks "build:xx" in index.html
gulp.task('compress', ['wiredep'], function () {
  return gulp.src('index.html', {cwd: paths.app})
    .pipe(plugins.useref({ searchPath: ['./', paths.app] }))
    .pipe(plugins.if('**/*.js', plugins.ngAnnotate()))
    .pipe(plugins.if('**/*.js', plugins.uglify({
      mangle: true
    }).on('error', plugins.util.log)))
    .pipe(plugins.if('**/*.css', plugins.cssnano()))
    .pipe(gulp.dest(paths.dist));
});
...

En la línea 5 de la tarea "compress" se incluyó el llamado "plugins.ngAnnotate()" justo antes de minificar el código JavaScript con "uglify".

Nota: Cabe recordar que no fue necesario incluir una variable manualmente para usar el plugin "gulp-ng-annotate" ya que al usar "gulp-load-plugins" todos los plugins que tengan el prefijo "gulp-" quedan disponibles en la variable "plugins".

Al ejecutar nuevamente la aplicación para producción podrá verse que ya funciona sin problemas.

Figura 1 - Aplicación con archivos para producción

Caché de Vistas (Template Cache)

Luego de navegar por las secciones "Home", "About" y consultar el clima en una ciudad en la figura 1 se puede ver señalado que se han realizado 13 peticiones HTTP, de las cuales 6 (casi la mitad) corresponden a la descarga de archivos HTML.

Localmente esto no parece ser un problema ya que la descarga se hace casi de inmediato y adicionalmente dependiendo del navegador y el servidor web usado es posible que estos archivos HTML sólo se descarguen una vez y luego se use una copia en la memoria caché del navegador web.

Sin embargo al tener la aplicación web en producción se tendrán variables adicionales como velocidad a Internet del usuario, capacidad y ancho de banda del servidor que expone la aplicación web, por lo que se debe disminuir lo más posible el número de llamados HTTP necesarios en la aplicación, en especial si se tienen páginas mucho más elaboradas que estas.

Para ello AngularJS dispone del servicio "$templateCache" el cual permite adicionar y tomar plantillas (templates) HTML para las vistas desde código JavaScript de una manera más rápida, sin embargo mantener código HTML dentro de código JavaScript hace que sea más difícil de entender y mantener.

Afortunadamente el plugin "gulp-angular-templatecache" permite tomar el contenido de los archivos HTML y crear automáticamente un archivo JavaScript con todas las vistas.

Antes de considerar esta opción, se debe tener en cuenta el número de secciones que tiene la aplicación, el tamaño de las vistas y la frecuencia con que los usuarios navegarán por la mayoría de dichas secciones ya que en caso de ser una aplicación de muchas secciones de las cuales cada usuario usará muy pocas, el archivo JavaScript generado con todas las vistas estaría descargando más datos de los que el usuario realmente necesita y no tener el ahorro de transferencia de datos esperado.

Primero se necesitará instalar "gulp-htmlmin" para minificar el código HTML (quitando espacios y saltos de línea innecesarios para los navegadores web), "gulp-angular-templatecache" para generar el archivo "templates.js" con las vistas HTML y "gulp-concat" para concatenar el contenido de dicho archivo en el archivo JavaScript final de la aplicación "scripts.min.js".

npm install gulp-htmlmin gulp-angular-templatecache gulp-concat --save-dev

gulp-paths.json
{
  "app": "./app/",
  "js": ["**/*.js"],
  "jsMin": "js/scripts.min.js",
  "css": ["**/*.css"],
  "views": ["**/*.html", "!index.html"],
  "templatesCache": "templates.js",
  "dist": "./dist/"
}

gulpfile.js
...
gulp.task('templates:build', function () {
  return gulp.src(paths.views, {cwd: paths.app})
    .pipe(plugins.htmlmin({collapseWhitespace: true}))
    .pipe(plugins.angularTemplatecache({
      module: 'weatherApp',
      moduleSystem: 'IIFE'
    }))
    .pipe(plugins.uglify({
      mangle: true
    }))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:concat', ['templates:build'], function () {
  return gulp.src([paths.jsMin, paths.templatesCache], {cwd: paths.dist})
    .pipe(plugins.concat(paths.jsMin))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:clean', ['templates:concat'], function () {
  return del(paths.dist + paths.templatesCache);
});
...
// Production build
gulp.task('build', function (done) {
  runSequence('jshint', 'jscs', 'clean:dist', 'compress', 'templates:clean', 'copy:assets', done);
});
...
  • Tarea copy:html: Dado que ahora en el código de producción tomará las vistas HTML de cache no se requiere copiar los archivos HTML a la carpeta "dist", por lo que la tarea "copy:html" debe borrarse.
  • Tarea templates:build: Genera el archivo "templates.js" (nombre por defecto) dentro de la carpeta "dist" (paths.dist) con el HTML minificado, indicando que hará parte del módulo principal de la aplicación (weatherApp) y que se usará Immediately Invoked Function Expression (IIFE), por lo cual también puede minificarse el código JavaScript generado usando "gulp-uglify".

    El archivo (sin minificar) debe verse de manera similar al siguiente:
    (function(){angular.module("weatherApp").run(["$templateCache", function($templateCache) {$templateCache.put("about/about.html","<wa-section-header wa-title=\"\'About this Site\'\" wa-subtitle=\"\'This site is just a demonstration for AngularJS 1.5 with an organized project structure using Gulp.\'\" class=\"about-header\"></wa-section-header>");
    $templateCache.put("home/home.html","<wa-section-header wa-title=\"\'Available Cities\'\" wa-subtitle=\"\'Select the city to check the current weather.\'\"></wa-section-header><div class=\"list-group\"><a ng-repeat=\"city in vm.cities | orderBy:\'name\'\" ui-sref=\"city({ cityId: city.id })\" class=\"list-group-item\">{{ city.name }} - {{city.country}}</a></div>");
    $templateCache.put("layout/footer.html","<footer class=\"footer\"><div class=\"container\"><p class=\"navbar-text\">Copyright &copy; 2016</p></div></footer>");
    $templateCache.put("layout/menu.html","<nav class=\"navbar navbar-inverse navbar-fixed-top\"><div class=\"container\"><div class=\"navbar-header\"><button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#navbar\" aria-expanded=\"false\" aria-controls=\"navbar\"><span class=\"sr-only\">Toggle navigation</span> <span class=\"icon-bar\"></span> <span class=\"icon-bar\"></span> <span class=\"icon-bar\"></span></button> <a class=\"navbar-brand\" href=\"#\">Weather Web App</a></div><div id=\"navbar\" class=\"collapse navbar-collapse\"><ul class=\"nav navbar-nav\"><li ui-sref-active=\"active\"><a ui-sref=\"home\">Home</a></li><li ui-sref-active=\"active\"><a ui-sref=\"about\">About</a></li></ul></div></div></nav>");
    $templateCache.put("home/city/city.html","<wa-section-header wa-title=\"vm.cityWeather.name\" wa-subtitle=\"vm.cityWeather.country\"></wa-section-header><div id=\"currentWeather\" ng-show=\"vm.cityWeather.image\"><img id=\"weatherImage\" ng-src=\"{{vm.cityWeather.image}}\"><br><strong>Weather</strong>: {{vm.cityWeather.description}}<br><strong>Temperature</strong>: {{vm.cityWeather.temperature}} &deg;C</div><br>Weather data from: <a href=\"http://openweathermap.org\" target=\"_blank\">OpenWeatherMap</a>");
    $templateCache.put("widgets/wa-section-header/wa-section-header.html","<div class=\"jumbotron text-center\"><h1>{{title}}</h1><p ng-show=\"subtitle\">{{subtitle}}</p></div>");}]);})();
    
  • Tarea templates:concat: Se toma el contenido de "templates.js" (paths.templatesCache) y se concatena al final de "dist/js/scripts.min.js" (paths.jsMin). A diferencia de otras tareas de Gulp definidas en este archivo, esta tiene como directorio actual (Current Working Directory - cwd) "./dist/" ya que los archivos con los que se trabajará en están en dicha carpeta.
  • Tarea templates:clean: Al finalizar "templates:concat" se borra el archivo "dist/templates.js".
  • Tarea build: Se borra de la lista la tarea "copy:html" y se adiciona "templates:clean"; las dependencias declaradas entre las tareas "templates:xyz" permiten que sólo sea necesario indicar esta última.
Ejecutando nuevamente la aplicación (comando gulp server-dist) podrá verse que la cantidad de peticiones HTTP se reduce a la mitad haciendo la misma navegación que el ejemplo de la figura 1.

Figura 2 - Aplicación con archivos para producción con TemplateCache


Deshabilitar Información de Debug

Siguiendo una de las recomendaciones que se hace en la documentación oficial de AngularJS para poner las aplicaciones en producción es deshabilitar la información de debug que el framework adiciona a varios de sus componentes, ya que mucha de esta información sólo es usada para pruebas, lo cual puede ayudar a mejorar el rendimiento de la aplicación.

Recordando el contenido del archivo "app.config.js", allí se tienen dos condiciones que permiten habilitar o deshabilitar esta información:

app/app.config.js
(function() {
  'use strict';

  angular
    .module('weatherApp')
    .config(configure);

  /* @ngInject */
  function configure($compileProvider, $logProvider, $httpProvider) {
    // Replaced by Gulp build task
    $compileProvider.debugInfoEnabled('@@debugInfoEnabled' !== 'false');
    $logProvider.debugEnabled('@@debugLogEnabled' !== 'false');

    $httpProvider.interceptors.push('HttpInterceptor');
  }
})();

  • Línea 11: Condición para deshabilitar la información de debug en la aplicación.
  • Línea 12: Condición para deshabilitar la salida de debug en la consola del navegador enviada mediante "$log.debug()". Es común que en las aplicaciones se envíe mucha información a la consola sólo para ver su resultado en el navegador durante el desarrollo (por ejemplo respuestas de llamados a servicios web), pero dicha información es innecesaria en producción así que de esta manera puede deshabilitar esta salida sin tener que modificar directamente el código de la aplicación.


Las cadenas "@@debugInfoEnabled" y "@@debugLogEnabled" pueden ser reemplazadas usando el plugin "gulp-replace-task":

npm install gulp-replace-task --save-dev

gulpfile.js
...
// Compress into a single file the ones in between of blocks "build:xx" in index.html
gulp.task('compress', ['wiredep'], function () {
  return gulp.src('index.html', {cwd: paths.app})
    .pipe(plugins.useref({ searchPath: ['./', paths.app] }))
    .pipe(plugins.if('*/scripts.min.js', plugins.replaceTask({
      patterns: [
        {
          match: 'debugInfoEnabled',
          replacement: 'false'
        },
        {
          match: 'debugLogEnabled',
          replacement: 'false'
        }
      ]
    })))
    .pipe(plugins.if('**/*.js', plugins.ngAnnotate()))
    .pipe(plugins.if('**/*.js', plugins.uglify({
      mangle: true
    }).on('error', plugins.util.log)))
    .pipe(plugins.if('**/*.css', plugins.cssnano()))
    .pipe(gulp.dest(paths.dist));
});
...

En este caso se adicionó que al momento de procesar el archivo con el código JavaScript de la aplicación (scripts.min.js) se busque en este las cadenas mencionadas y se reemplacen por "false" para que se cumpla la condición indicada en "app.config.js".

Conclusiones

En este momento ya se tiene una aplicación web desarrollada con AngularJS lista para desplegarse en cualquier servidor web (Apache, http://nginx.org/, ExpressJS, o inclusive contenedores como Tomcat), bien sea con los archivos HTML copiados normalmente o incluidos en el "$templateCache".

Cabe anotar que para aplicar las recomendaciones indicadas no se requirió modificar el código de la aplicación como tal (a excepción de las dos líneas de código que de hecho ya estaban en "app.config.js"), sino que fueron tareas de Gulp modificadas o adicionadas, con lo cual se comprueba la versatilidad y conveniencia de esta herramienta de construcción de proyectos.

En un próximo post se concluirá esta serie con el tema de las pruebas para la aplicación. El proyecto de la aplicación se puede descargar desde: https://github.com/guillermo-varela/angularjs-demo/tree/part-iv

Referencias

https://github.com/johnpapa/angular-styleguide/tree/master/a1
https://docs.angularjs.org/guide/production
https://docs.angularjs.org/api/ng/provider/$logProvider
https://www.npmjs.com/package/gulp-ng-annotate
https://docs.angularjs.org/api/ng/service/$templateCache

miércoles, 16 de marzo de 2016

Prácticas Recomendadas para Proyectos con AngularJS 1.x Parte III: Manejo de Errores y Documentación


Introducción

En posts anteriores se ha venido desarrollando una aplicación web con AngularJS 1.5, aplicando las recomendaciones que han sido aceptadas por el propio equipo de Google tanto en estructura del proyecto, secciones y rutas como el estilo de desarrollo de componentes como Directivas, Constantes, Controladores y Servicios.

En esta ocasión se van aplicar otras recomendaciones para controlar de manera consistente los errores que se presenten y adicionalmente la generación de la documentación del proyecto.

Manejo de Errores/Excepciones

Errores no Controlados

Debido a la naturaleza dinámica y no tipada de JavaScript es posible que algunos errores se escapen a la verificación de código realizada por JSHint y JSCS. Por ejemplo si al consultar el clima se usa un método que no existe en "$http", al generarse el error no se invoca la función "checkCityError" sino que este aparece en la consola del navegador web.

app/core/weather.service.js
...
      $http.get('http://api.openweathermap.org/data/2.5/weather', {
          params: {
            id: cityId,
            units: 'metric',
            appid: openWheaterAppId
          }
        })
        .thens(checkCityComplete)
        .catch(checkCityError);
...

En este ejemplo se reemplaza la invocación de la función "then" por la inexistente "thens". Al consultar el clima de una ciudad en la aplicación podrá verse el error en la consola.

Figura 1 - Error no controlado

Para tener control sobre este tipo de errores en la aplicación se implementará un mecanismo para detectar este tipo de problemas en la aplicación y es registrar un decorador (decorator) para el servicio "$exceptionHandler" el cual se encarga de procesar los errores/excepciones no capturados. En el nuevo decorador se extenderá la funcionalidad de "$exceptionHandler" con la lógica que se desee.

app/blocks/exception/exception.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.blocks.exception
   *
   * @description
   * Module for common exception handling.
   */
  angular.module('weatherApp.blocks.exception', []);
})();

app/blocks/exception/exception.handler.decorator.js
(function() {
  'use strict';

  angular
    .module('weatherApp.blocks.exception')
    .config(exceptionConfig);

  /* @ngInject */
  function exceptionConfig($provide) {
    $provide.decorator('$exceptionHandler', extendExceptionHandler);
  }

  /**
   * @ngdoc function
   * @name weatherApp.blocks.exception.function:extendExceptionHandler
   * @private
   *
   * @description
   * Extends the $exceptionHandler service to perform a more advanced AngularJS exception handling.
   *
   * @param {Object} $delegate - $exceptionHandler delegator.
   * @param {Object} $log - Service to log messages.
   * @returns {function} Function defining how to handle the exception.
   */
  /* @ngInject */
  function extendExceptionHandler($delegate, $log) {

    function handleException(exception, cause) {
      $delegate(exception, cause);

      var errorData = {
        exception: exception,
        cause: cause
      };

      var msg = 'Weather Web App Error: ' + exception.message;
      $log.debug(msg, errorData);
    }

    return handleException;
  }
})();

  • Línea 28: Se define la función "handleException" que se invocará cuando se presente un error controlado.
  • Línea 29: Al invocar "$delegate" con los parámetros originales se ejecuta el procesamiento original de "$exceptionHandler".
  • Línea 36-37: Para mantener el ejemplo sencillo, el procesamiento del error será simplemente usar el servicio "$log" para enviar el error a la consola con un mensaje como prefijo.


app/core/core.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.core
   * @requires weatherApp.blocks.router
   *
   * @description
   * Module for the core bussiness logic and intra-app features.
   */
  angular.module('weatherApp.core', [
    /* Cross-app modules */
    'weatherApp.blocks.router',
    'weatherApp.widgets',
    'weatherApp.blocks.exception'
  ]);
})();

Aquí simplemente en la línea 16 se adiciona el nuevo módulo "weatherApp.blocks.exception".

Al ejecutar de nuevo la aplicación se podrá ver que el error ahora aparece con el prefijo indicado.

Figura 2 - Error ahora controlado por la aplicación

Manejo de Excepciones Centralizado

Para tener un manejo consistente de los errores generados en la aplicación se desarrollará un servicio que tome dichos errores y aplique una lógica definida y centralizada.

app/blocks/exception/exception.catcher.service.js
(function() {
  'use strict';

  angular
    .module('weatherApp.blocks.exception')
    .factory('ExceptionCatcher', ExceptionCatcher);

  /**
   * @ngdoc service
   * @name weatherApp.blocks.exception.service:ExceptionCatcher
   *
   * @description
   * Handles exceptions in the application.
   */
  /* @ngInject */
  function ExceptionCatcher($log) {
    var service = {
      catcher: catcher,
      httpCatcher: httpCatcher
    };

    return service;

    ///////////////

    /**
     * @ngdoc function
     * @name ExceptionCatcher#catcher
     * @methodOf weatherApp.blocks.exception.service:ExceptionCatcher
     *
     * @description
     * Handles an exception with its message.
     *
     * @param {string} event - Name of the event that caused the error.
     * @param {string} message - Message for the exception.
     * @param {object} reason - Object with more information about the error.
     */
    function catcher(event, message, reason) {
      $log.debug(event + ': ' + message, reason);
    }

    /**
     * @ngdoc function
     * @name ExceptionCatcher#httpCatcher
     * @methodOf weatherApp.blocks.exception.service:ExceptionCatcher
     *
     * @description
     * Handles HTTP errors.
     *
     * @param {Object} error - Error object.
     * @param {number} status - HTTP status code.
     * @param {Object} config - Object with the request data.
     */
    function httpCatcher(error, status, config) {
      var message = 'Error: ' + JSON.stringify(error) + '\n' + 'Status: ' + status + '\n';

      if (config && config.headers && config.headers.authorization) {
        config.headers.authorization = config.headers.authorization.replace(/./g, '*');
      }

      if (config.data && config.data.password) {
        config.data.password = config.data.password.replace(/./g, '*');
      }

      if (config.params && config.params.appid) {
        config.params.appid = config.params.appid.replace(/./g, '*');
      }

      catcher('HTTP_ERROR', message, config);
    }
  }
})();
  • Líneas 38-40: Se define la función "catcher" que será invocada cuando se maneje un error en la aplicación. Se tiene un parámetro "event" dado que algunas herramientas de monitoreo y reportes permiten asociar un error a un evento o categoría específico para diferenciar más fácilmente los errores entre sí. De momento esta función sólo envía los errores a la consola, pero una aplicación productiva puede usar servicios como Google AnalyticsFlurry Crash AnalyticsNew Relic, entre otras.
  • Líneas 54-66: Se tiene una función adicional "httpCatcher" para los errores específicos de llamados HTTP, ya que estos cuentan con información adicional (el código HTTP del estado y el objeto "config") y también por motivos de seguridad se deben ofuscar credenciales que puedan hacer parte de las peticiones (reemplazando cada caracter por asterisco). Al final se invoca "catcher" con la información del error debidamente procesada.

app/blocks/exception/exception.handler.decorator.js
(function() {
  'use strict';

  angular
    .module('weatherApp.blocks.exception')
    .config(exceptionConfig);

  /* @ngInject */
  function exceptionConfig($provide) {
    $provide.decorator('$exceptionHandler', extendExceptionHandler);
  }

  /**
   * @ngdoc function
   * @name weatherApp.blocks.exception.function:extendExceptionHandler
   * @private
   *
   * @description
   * Extends the $exceptionHandler service to perform a more advanced AngularJS exception handling.
   *
   * @param {Object} $delegate - $exceptionHandler delegator.
   * @param {Object} ExceptionCatcher - Exception handler.
   * @returns {function} Function defining how to handle the exception.
   */
  /* @ngInject */
  function extendExceptionHandler($delegate, ExceptionCatcher) {

    function handleException(exception, cause) {
      $delegate(exception, cause);

      var errorData = {
        exception: exception,
        cause: cause
      };

      var msg = 'Weather Web App Error: ' + exception.message;
      ExceptionCatcher.catcher('ANGULARJS_ERROR', msg, errorData);
    }

    return handleException;
  }
})();

  • Líneas 22 y 26: Se reemplaza la inyección del servicio "$log" por el nuevo servicio "ExceptionCatcher".
  • Línea 37: Se usa la función "ExceptionCatcher.catcher()" para procesar el error recibido.

Al revisar nuevamente la aplicación podrá verse que el error ya tiene como prefijo el evento "ANGULARJS_ERROR" asignado a los errores no controlados de AngularJS.

Figura 3 - Error con evento como prefijo

Para manejar los errores de las peticiones HTTP se creará un nuevo módulo con un servicio que haga las veces de interceptor.

app/blocks/http/http.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.blocks.http
   *
   * @description
   * Module for common http operations.
   */
  angular.module('weatherApp.blocks.http', []);
})();

app/blocks/http/http.interceptor.service.js
(function() {
  'use strict';

  angular
    .module('weatherApp.blocks.http')
    .factory('HttpInterceptor', HttpInterceptor);

  /**
   * @ngdoc service
   * @name weatherApp.blocks.http.service:HttpInterceptor
   *
   * @description
   * Intercepts all HTTP requests.
   */
  /* @ngInject */
  function HttpInterceptor($q, ExceptionCatcher) {
    var service = {
      responseError: responseError
    };

    return service;

    ///////////////

    /**
     * @ngdoc function
     * @name HttpInterceptor#responseError
     * @methodOf weatherApp.blocks.http.service:HttpInterceptor
     *
     * @description
     * Handles all HTTP responses with error.
     *
     * @param {Object} rejection - Object with information about the error.
     */
    function responseError(rejection) {
      ExceptionCatcher.httpCatcher(rejection.data, rejection.status, rejection.config);
      return $q.reject(rejection);
    }
  }
})();
  • Línea 35: Se implementa la función "responseError" que será ejecutada automáticamente por AngularJS cuando se presente un error en la respuesta de una petición HTTP.
  • Línea 36: El error obtenido se procesa usando "ExceptionCatcher.httpCatcher()" que como se dijo anteriormente, procesará el error HTTP usando la lógica centralizada para control/reporte de errores.
  • Línea 37: Al final se propaga el error recibido usando "$q.reject()".

app/core/core.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.core
   * @requires weatherApp.blocks.router
   *
   * @description
   * Module for the core bussiness logic and intra-app features.
   */
  angular.module('weatherApp.core', [
    /* Cross-app modules */
    'weatherApp.blocks.router',
    'weatherApp.widgets',
    'weatherApp.blocks.exception',
    'weatherApp.blocks.http'
  ]);
})();

app/app.config.js
(function() {
  'use strict';

  angular
    .module('weatherApp')
    .config(configure);

  /* @ngInject */
  function configure($compileProvider, $logProvider, $httpProvider) {
    // Replaced by Gulp build task
    $compileProvider.debugInfoEnabled('@@debugInfoEnabled' !== 'false');
    $logProvider.debugEnabled('@@debugLogEnabled' !== 'false');

    $httpProvider.interceptors.push('HttpInterceptor');
  }
})();

  • Línea 14: Se adiciona el interceptor en la configuración del módulo principal de la aplicación para que sea usado al fallar cualquier petición HTTP.

Al tenerse el manejo del error de las peticiones HTTP en el interceptor, ya no es necesario enviar a la consola el error capturado en "WeatherService.checkCity.checkCityError", por lo que el servicio quedaría de la siguiente manera:

app/core/weather.service.js
(function() {
  'use strict';

  angular
    .module('weatherApp.core')
    .factory('WeatherService', WeatherService);

  /**
   * @ngdoc service
   * @name weatherApp.core.service:WeatherService
   *
   * @description
   * Service with the weather operations.
   */
  /* @ngInject */
  function WeatherService($q, $http) {
    var openWheaterAppId = 'abc123';

    var service = {
      checkCity: checkCity
    };

    return service;

    ///////////////

    /**
     * @ngdoc function
     * @name WeatherService#checkCity
     * @methodOf weatherApp.core.service:WeatherService
     *
     * @description
     * Checks the weather on a given city.
     *
     * @param {number} cityId - Id of the city to check.
     * @returns {Promise} Promise that will be resolved with the current weather in the checked city.
     */
    function checkCity(cityId) {
      var deferred = $q.defer();

      $http.get('http://api.openweathermap.org/data/2.5/weather', {
          params: {
            id: cityId,
            units: 'metric',
            appid: openWheaterAppId
          }
        })
        .then(checkCityComplete)
        .catch(checkCityError);

      function checkCityComplete(response) {
        if (!response || !response.data || response.data.cod === '404') {
          checkCityError('City not found');
        } else {
          var cityWeather = {
            name: response.data.name,
            country: response.data.sys.country,
            description: response.data.weather[0].description,
            temperature: response.data.main.temp,
            image: 'http://openweathermap.org/img/w/' + response.data.weather[0].icon + '.png'
          };

          deferred.resolve(cityWeather);
        }
      }

      function checkCityError(error) {
        deferred.reject(error);
      }

      return deferred.promise;
    }
  }
})();

  • Línea 16: Se remueve la inyección del servicio "$window".
  • Líneas 67-69: El error ya no se envía a la consola, simplemente se indica que la promesa retornada originalmente ha generado un error.
Para probar que el interceptor está funcionando se puede indicar un Application ID (variable openWheaterAppId) erróneo y tratar de consultar el clima de una ciudad:

Figura 4 - Error en llamado HTTP controlado con credencial ofuscada

Generación de Documentación

Hasta el momento el código JavaScript se ha estado documentando usando las directivas/etiquetas de "ngdoc". Ahora se usará el plugin "gulp-ngdocs" para generar un sitio web con la documentación del código, similar a las páginas de Javadoc en Java.

npm install gulp-ngdocs --save-dev

gulpfile.js
...
// Generates the documentation
gulp.task('ngdocs', function () {
  var options = {
    title: "Weather Web App Documentation",
    html5Mode: false
  };
  return gulp.src(paths.js, {cwd: paths.app})
    .pipe(plugins.ngdocs.process(options))
    .pipe(gulp.dest('./docs'));
});

// Starts a server with the docs
gulp.task('server-docs', ['ngdocs'], function () {
  plugins.connect.server({
    root: './docs',
    hostname: '0.0.0.0',
    port: 8081
  });
});
...

  • Tarea ngdocs: Busca los archivos JavaScript del proyecto (paths.js) y genera la documentación usando las opciones indicadas en la variable "options", en la que se indica "html5Mode: false" para que la aplicación generada se pueda navegar sin tener que crear reglas adicionales en el servidor. Finalmente almacena esta documentación en la carpeta "docs".
  • Tarea server-docs: Inicia un servidor local luego de ejecutar la tarea "ngdocs" en el cual se puede ver la documentación generada. En este caso se usa el puerto 8081 ya que el 8080 ya está asociado al servidor local de la aplicación (tareas server y server-docs).

.gitignore


dist/
node_modules/
bower_components/
npm-debug.log
docs/

Nota: Este punto depende de las preferencias personales o los estándares de la organización ya que la documentación del proyecto puede generarse automáticamente y hay quienes no gustan tener archivos auto-generados en el repositorio de código, en cuyo caso se debe incluir la carpeta "docs/" en ".gitignore", pero para quienes prefieren tener la documentación siempre disponible en el repositorio pueden obviar esta entrada.


Al ejecutar el comando "gulp server-docs" y abrir la URL "http://localhost:8081" podrá verse la documentación del proyecto.

Figura 5 - Documentación del código del proyecto

Conclusiones

Aunque se trata de elementos relativamente opcionales para el funcionamiento de la aplicación, tener un manejo claro y consistente de los errores y una documentación actualizada permiten entender de manera más rápida y eficiente el funcionamiento actual de la aplicación, las posibles causas de errores que se presenten y su solución de manera más rápida.

En un próximo post se cubrirán algunas de las recomendaciones para generar aplicaciones para producción, por lo pronto el código del proyecto aquí desarrollado se puede descargar desde: https://github.com/guillermo-varela/angularjs-demo/tree/part-III

Referencias

https://docs.angularjs.org/api
https://github.com/johnpapa/angular-styleguide/blob/master/a1/README.md