Test de extremo a extremo con Nightwatch y Grunt

Hace unos meses escribía sobre mi decisión de abandonar AngularJS para futuros desarrollos, y después de evaluar algunas alternativas, nos ha gustado el enfoque de ReactJS, por lo que será la librería básica que usaremos en nuestro próximo proyecto.

AngularJS tiene sus cosas buenas, y una de ellas es que con AngularJS tienes un conjunto de herramientas bien establecidas para casi todas las facetas del desarrollo. Concretamente, para los tests de extremo a extremo inicialmente se recomendaba usar Karma con sus ng-scenario, y posteriormente protractor, basado en Selenium.

Al cambiar a una librería como ReactJS, con un alcance más limitado, tienes más libertad para elegir las herramientas que vas a usar, pero eso implica que también tienes que hacer más esfuerzo para buscarlas e integrarlas unas con otras.

El estado actual de las librerías de tests de extremo a extremo

La comunidad de desarrollo de Javascript es una de las más activas, vibrantes y caótica del momento. Existen infinidad de librerías y herramientas para cualquier cosa, cada una de ellas desarrollada de forma independiente y con distintos grados de madurez y facilidad de integración entre si.

Esto hace que no siempre sea sencillo encontrar librerías que funcionen bien o que, una vez encontrada una librería que nos guste, podamos integrarla cómodamente con el resto de herramientas que estamos usando.

La inmensa mayoría de las librerías para escribir tests de extremo a extremo están basadas en Selenium. Se trata de un servidor escrito en Java (por lo que hace falta tener Java poder ejecutarlo) que expone un API accesible desde muchos lenguajes, entre ellos Javascript, y que puede conectarse a casi cualquier browser para automatizar su comportamiento.

Para utilizarlo desde Javascript, existen varias librerías con diferentes APIs, como WebDriverJs, WebDriverIO o NightwatchJS. Cada una de ellas tiene plugins para Grunt y Gulp, y además existen otros plugins para utilizar distintas librerías de test, como chai-webdriver, mocha-webdriver o jasmine-testdriver.

En definitiva, manejarse entre ese maremagnum de liberías y plugins no ha resultado tan sencillo como me hubiera gustado, pero después de unas cuantas vueltas hemos conseguido llegar a la solución que voy a contar en este post para mantenerla como referencia para futuras generaciones.

En realidad, gran parte del trabajo la ha hecho un compañero mío, Carlos Olivar, que forma parte de esa masa de excelentes desarrolladores que no tienen presencia online pero saben muy bien lo que se hacen.

El objetivo

Lo que pretendemos es poder ejecutar tests de extremo a extremo sobre una aplicación web, a ser posible, de forma independiente a la tecnología empleada para construirla (no como ocurre con protractor, que está ligado a AngularJS).

Estos tests queremos poder ejecutarlos de dos formas:

  • Ejecutarlos continuamente cada vez que cambiemos un fichero mientras estamos desarrollando. Esto nos permitirá tener feedback lo antes posible cuando algo haya ido mal.
  • Ejecutarlos en un servidor de integración para garantizar que el código que subimos al control de código fuente funciona.

Para ello, vamos a utilizar Grunt como herramienta de compilación, Nightwatch como librería de tests y el plugin Grunt-Nightwath para integrar ambos.

Montando el proyecto

Lo primero que necesitamos, como es habitual hoy en día, es instalar unos cuantos paquetes usando npm:

npm install -g grunt-cli
npm install grunt --save-dev
npm install grunt-contrib-jshint --save-dev
npm install grunt-contrib-watch --save-dev
npm install grunt-nightwatch --save-dev
npm install phantomjs --save-dev
npm install selenium-standalone@2.39 --save-dev

Ojo a la última instalación, la de selenium-standalone, porque estamos marcando una versión concreta, la 2.39, ya que en versiones posteriores hay un problema con el driver para PhantomJS.

Ya expliqué cómo utilizar Grunt, así que vamos a centrarnos en la configuración de los tests. De todas formas al final del post dejaré un enlace a un repositorio con el código de ejemplo para el que quiera trastear con él.

Nightwatch se puede configurar a través de un fichero externo, pero en este caso he preferido mantener toda la configuración en el propio Gruntfile.js para tenerla más a la vista.

La parte que nos interesa es la siguiente:

grunt.initConfig({
  // ... más configuración
  nightwatch: {
    options: {
      src_folders: './test/',
      output_folder : './reports/',
      selenium: {
          start_process : true,
          server_path : './node_modules/selenium-standalone/.selenium/2.39.0/server.jar',
          cli_args : {
              'webdriver.chrome.driver': './node_modules/selenium-standalone/.selenium/2.39.0/chromedriver'
          }
      },
      test_settings: {
        default: {
          desiredCapabilities: { 
            browserName: 'phantomjs',
            'phantomjs.binary.path': './node_modules/phantomjs/lib/phantom/phantomjs.exe'
          }
        }
      },
      chrome: {
        desiredCapabilities: { 
          browserName: 'chrome'
        }
      }
    }
  },
  // ... más configuración
});

En la configuración de Nightwatch estamos definiendo las opciones por defecto, en las que además de las carpetas con los tests y los resultados, estamos indicando la configuración de selenium y el navegador que usaremos por defecto, en este caso, PhantomJS.

Como veis, hay algunas rutas que apuntan a los módulos que instalamos previamente. Podríamos haber descargado manualmente el jar de selenium y el ejecutable de PhantomJS, pero de esta forma los tratamos de forma homogénea con el resto de dependencias del proyecto.

Junto al navegador por defecto que hemos definido en test_settings, tenemos otro navegador definido en chrome para poder lanzar los tests con este navegador. Gracias a ello podemos ejecutar:

grunt nightwatch → Ejecuta los tests con PhantomJS
grunt nightwatch:chrome → Ejecuta los tests con Chrome

Teniendo esto, podemos definir una tarea con grunt-contrib-watch para cumplir con nuestro primer requisito (ejecutar los tests cada vez que cambiemos un archivo):

grunt.initConfig({
  // ... más configuración
  watch: {
    dev: {
      files: srcFiles,
      tasks: ['jshint', 'nightwatch'],
      options: { spawn: false, reload: true }
    }
  }
  // ... más configuración  
});

Ahora ya sólo necesitamos definir un par de tareas más y habremos terminado:

grunt.registerTask('default', ['jshint', 'nightwatch:chrome']);
grunt.registerTask('dev', ['watch']);

Así, en nuestro servidor de integración usaremos grunt para realizar todo el proceso de compilación (jshint, browserify, uglify, etc.) y luego lanzar los tests utilizando Chrome.

Mientras estemos desarrollando, usaremos grunt dev para que cada vez que guardemos un archivo se vuelvan a lanzar los tests.

Escribiendo los tests

Después de todos estos pasos hemos conseguido montar un entorno de trabajo que nos resulte cómodo, pero falta lo más importante: los propios tests.

Para escribir los tests necesitamos crear un archivo en que exportemos un cuyos métodos son los tests:

module.exports = {
  'search single word' : function(browser) {
    browser
      .url('http://www.duckduckgo.com')
      .waitForElementVisible('body', 5000);
      .setValue('#search_form_input_homepage', 'gato')
      .click('#search_button_homepage');
      .waitForElementVisible('.results', 1000)
      .assert.containsText('.results', 'gato');
  },

  'search exact phrase': function(browser) {
    browser
      .url('http://www.duckduckgo.com')
      .waitForElementVisible('body', 5000);
      .setValue('#search_form_input_homepage', '"gato feo"')
      .click('#search_button_homepage');
      .waitForElementVisible('.results', 1000)
      .assert.containsText('.results', '"gato feo"');
  },

  after: function(browser) {
    browser.end();
  }
};

En el ejemplo estamos testando el buscador DuckDuckGo y para ello usamos el API de Nightwatch que nos permite interactuar con la página y realizar aserciones sobre su contenido. Además podemos incluir los típicos before, beforeEach, after y afterEach para realizar acciones antes o después de ejecutar cada test o todos los tests.

No hace falta ser muy listo para ver esos tests son extremadamente frágiles y contienen mucho código repetido, pero es algo que podemos solucionar fácilmente refactorizando hacia el patrón Page Object:

var DuckDuckGo = function(browser) {
  
  this.reset = function() {
    browser.url('http://www.duckduckgo.com').waitForElementVisible('body', 5000);
    return this;
  };

  this.search = function(term) {
    browser
      .setValue('#search_form_input_homepage', term)
      .click('#search_button_homepage');
    return this;
  };

  this.resultsShouldContain = function(text) {
    browser
      .waitForElementVisible('.results', 1000)
      .assert.containsText('.results', text);
    return this;
  };
};


module.exports = {

  before: function(browser) {
    this.ddg = new DuckDuckGo(browser);
  },

  beforeEach: function(browser) {
    this.ddg.reset();
  },

  'search single word' : function(browser) {
    this.ddg.search('gato')
      .resultsShouldContain('gato');
  },

  'search exact phrase': function(browser) {
    this.ddg.search('"perro verde"')
      .resultsShouldContain('perro verde');
  },

  after: function(browser) {
    browser.end();
  }
};

De esta forma la intención de nuestros tests queda mucho más clara y, en caso de que haya cambios en el interfaz de usuario, sólo necesitaremos ajustar el objeto que representa la página y nuestros tests seguirán funcionando.

Podéis ver el código completo del ejemplo y jugar con él para haceros una idea mejor de cómo funcionan todo.

Limitaciones

En sistema propuesto en este post tiene dos limitaciones cuando se ejecuta en modo watch que todavía no hemos conseguido resolver.

Por una parte, el servidor de selenium se levanta y se para cada vez que se ejecutan los tests. Esto es relativamente rápido (alrededor de dos segundos en mi máquina), pero no deja de ser subóptimo. Lo bueno sería arrancarlo una vez y matarlo al final del proceso, pero no hemos encontrado una solución 100% satisfactoria y en determinados escenarios se nos acababan quedando procesos Java levantados.

Por otra, tampoco hemos podido convencer a Nightwatch (o a Selenium, no sé realmente de quién depende), de que reutilice la instancia del browser (cosa que sí podíamos hacer con protractor), lo que además de ralentizar la ejecución hace que estén saltando ventanas de Chrome que no hacen más que distraerte. Ese es el motivo de utilizar PhantomJS en el modo de ejecución continua: al menos no aparecen las ventanas.

Si alguien sabe cómo solucionar estos problemas y nos echa una mano en los comentarios, le estaré eternamente agradecido.