[Case study] NightWatchJS et les tests E2E

Dans l’univers du test E2E, un des premiers noms qui nous vient à l’esprit c’est Selenium et ça tombe bien parce qu'aujourd’hui nous allons découvrir une bibliothèque Javascript qui peut s’appuyer sur Selenium : NightWatchJS. À la base, nous connaissions CasperJS, mais nous nous sommes confrontés à un cas particulier : les iframes.

La promesse de NightWatch est simple : Write End-To-End tests in Node.js quickly and effortlessly that run against a Selenium/WebDriver server. Nous allons voir dans cet article que son utilisation répond bien à ces promesses.

L'installation

Tout d'abord, si vous n’avez pas encore NodeJS rendez-vous ici pour l’installer et n’oubliez pas d’installer le gestionnaire de paquet npm.

$ npm init

name: (code)
version: (1.0.0)
description:
entry point: (index.js)
test command: ./node_modules/nightwatch/bin/nightwatch
git repository:
keywords:
license: (ISC)

$ npm install nightwatch —-save

Ensuite il nous faut de quoi ouvrir les pages à tester :)

$ npm install chromedriver —-save 

La configuration

À la racine du projet, créez un fichier nightwatch.json : 

{
  "src_folders" : ["tests"], // répertoire(s) racine des tests
  "output_folder" : "reports", // répertoire des retour d’erreur
  "custom_commands_path" : "commands", // répertoire pour les commandes personnalisées
  "page_objects_path" : "pages", // répertoire pour les pages object
  "globals_path" : "globals.js », // module accessible pour chaque test

  // parallélisation des tests
  "test_workers": {
    "enabled": true,
    "workers": "auto"
  },

  // pour notre demo on demandera à ce que selenium ne soit pas géré automatiquement
  "selenium" : {
    "start_process" : false
  },

  // configuration des tests pour le branchement sur chrome
  "test_settings" : {
    "default" : {
      "launch_url": "http://localhost",
      "selenium_port"  : 9515,
      "selenium_host"  : "localhost",
      "default_path_prefix" : "",

      "desiredCapabilities": {
        "browserName": "chrome",
        "chromeOptions" : {
          "args" : ["--no-sandbox"]
        },
        "acceptSslCerts": true
      }
    }
  }
}

Ensuite, puisque nous y faisons référence, créons le fichier globals.js à la racine du projet :

var chromedriver = require('chromedriver');

module.exports = {
    // lance le driver avant les tests
    before: function(done) {
        chromedriver.start();

        done();
    },

    // stop le driver une fois les tests terminé
    after: function(done) {
        chromedriver.stop();

        done();
    },

    // ferme le navigateur après chaque scenario
    afterEach: function(browser, done) {
        browser.end();

        done();
    },

    // arrête l’exécution des tests en cas d'erreur
    abortOnAssertionFailure: true,

    // temps d’attente entre chaque condition de test
    waitForConditionPollInterval: 300,

    /*
     * temps d’attente dans les commande d’attente d’élément
     * cela évite de préciser à chaque fois la durée.
     */
    waitForConditionTimeout: 2000,

    /*
     * défini si le test doit échouer lorsque plusieurs élément HTML sont trouvé alors
     * que nous n’en n’attendons qu’un.
     */
    throwOnMultipleElementsReturned: true
};

Dans ce fichier, nous définissons des actions à lancer en début et fin d'exécution des tests ainsi qu’après chaque test. Il est possible aussi de définir des variables qui auront des implications dans les différentes commandes accessibles, ou simplement qui seront accessibles depuis les tests. Jetez un coup d'oeil à la doc, il existe d’autres options disponibles :)

L'écriture des tests

Une fois la configuration terminée, nous allons pouvoir écrire nos tests. Dans le répertoire tests, commencez par créer un répertoire « searchEngine » . C’est un nom arbitraire puisque de toute manière, dans la configuration, nous avons défini que tous les tests seraient dans le répertoire tests. NightWatch considérera ainsi que tous les modules seront exportés comme des tests, peu importe où ils se trouvent dans ce répertoire. Ce répertoire « searchEngine » contiendra le fichier testResearch.js

module.exports = {
  ‘research' : function (browser) {
    browser
      .url('http://www.google.com')
      .waitForElementVisible('body', 1000)
      .setValue('input[type=text]', ’lille symfony')
      .waitForElementVisible('button[name=btnG]', 1000)
      .click()
      .pause(1000)
      .assert.containsText(‘#rcnt', ’les-tilleuls.coop')
      .end();
  }
};

Et voilà ! Le premier test est écrit et nous pouvons l’exécuter avec la commande suivante :

$ npm test

Là, quelques explications sont les bienvenues. Lors du npm init, nous précisons test command: ./node_modules/nightwatch/bin/nightwatch. Cela signifie que « test » est un alias. À vous de constater le résultat.

Ensuite, on souhaite exécuter le même test sur qwant. La première solution est de dupliquer le fichier searchEngine.js, le renommer et de modifier l’url. Ok, ça nous a pris 2 secondes, mais on a dupliqué le code, et si demain on doit tester 500 sites web, bonjour les dégats… Alors on passe tout de suite au truc cool, le PageObject. C’est une représentation d’une page par son DOM. L’idée, c’est que l’on va créer un pageObject par url sur laquelle on va vouloir jouer nos tests. Ainsi, si on joue plusieurs tests sur la même page, il n’y aura qu’un seul fichier à éditer et qu’une seule ligne par élément qui puisse changer en cas d’évolution dans la page.

Concrètement une pageObject c’est ça :

module.exports = {
  url: 'http://google.com',
  elements: {}
};

On va étoffer ça, rassurez-vous… Si l’url doit être dynamique, ce n’est pas un souci.

module.exports = {
  url: function() { 
    return this.api.launchUrl + '/login'; 
  },
  elements: {}
};

Nous vous renvoyons à la doc pour la définition de launchUrl, il faut regarder côté globals. Les éléments correspondent à des sélecteurs du DOM. 

module.exports = {
  elements: {
    searchBar: 'input[type=text]'
  }
};

searchBar est un nom arbitraire, qui vous servira pour cibler l’élément dans votre suite de test.

module.exports = {
  'Test': function (browser) {
    var google = browser.page.google();

    google.navigate()
      .assert.title('Google')
      .assert.visible('@searchBar')
      .setValue('@searchBar', 'nightwatch’);

    browser.end();
  }
};

Vous aurez remarqué browser.page.google(). Souvenez vous, nous avons défini le répertoire « pages »  comme répertoire des pageObject dans la configuration. NightWatch va donc parcourir ce répertoire et instancier des pageObject avec chaque module exporté. Il stockera l’objet obtenu dans l’attribut page du browser. Le nom de l’objet correspond au nom du fichier js. Il faudra donc appeler le fichier google.js pour aller chercher browser.page.google(). Attention à bien mettre les parenthèses car l’objet est défini dans une closure.

Passons maintenant aux éléments. Les éléments, c'est bien mais si on veut éviter une liste sans fin, on peut grouper le tout avec des Sections.

module.exports = {
  sections: {
    menu: {
      selector: '#gb',
      elements: {
        mail: {
          selector: 'a[href="mail"]'
        },
        images: {
          selector: 'a[href="imghp"]'
        }
      }
    }
  }
};

L’avantage c’est que c’est récursif. Admettons la suite suivante :

module.exports = {
  'Test': function (browser) {
    var google = browser.page.google();
    google.expect.section('@menu').to.be.visible; // cherche #gb

    var menuSection = google.section.menu;
    menuSection.expect.element('@mail').to.be.visible; // vérifie que #gb a[href="mail"] existe
    menuSection.expect.element('@images').to.be.visible; 

    menuSection.click('@mail');

    browser.end();
  }
};

Nous appliquons des règles CSS pour trouver chacun des éléments reprenant systématiquement la règle du parent. Et là, nous voyons déjà venir la question qui vous taraude : dans section, on retrouve des éléments mais peut-on mettre une section dans une section ? Oui, on peut.

Selon la version que vous utilisez, vous pouvez aussi dire à NightWatch de Skip (ne pas jouer) un test. Deux méthodes existent. La première, qui marche toute version confondue, est de transformer la fonction ‘Test’ en chaîne de caractères en ajoutant tout simplement ‘’+ devant le mot clé function.

Exemple :

module.exports = {
  'Test': ''+function (browser) {...}
};

Et l'autre qui consiste a ajouter à l’objet une propriété @disable à true. Exemple :

module.exports = {
  ‘@disable': true
};
module.exports = {
  ‘@tags': ['groupeName','pageName']
};

Il faudra rajouter l’option —tag à votre commande suivi du tag en question et c’est tout ! Enfin, pour l’instant parce que nous avons d’autres problématiques à mettre en place et c’est là que les choses sérieuses vont commencer. Si vous avez un bout de code qui doit être réutilisé pour chaque test pour une page, alors ajoutez une commande dans la page :

module.exports = {
  commands: [submit: function() {
    this.api.pause(1000);
    return this.waitForElementVisible('@submitButton', 1000)
      .click('@submitButton')
      .waitForElementNotPresent('@submitButton');
  }],
  elements: {
    searchBar: {
      selector: 'input[type=text]'
    },
    submitButton: {
      selector: 'button[name=btnG]'
    }
  }
};

Si vous avez un bout de code qui doit être réutilisé absolument partout (dans tous les tests), alors ajoutez une commande dans le browser : la custom command.

Créez un fichier IfElementVisible.js dans le répertoire commands (comme d’hab on l’a défini au tout début dans le custom_commands_path). De la même manière que les pages, le nom du fichier, est le nom de la commande.

var util = require('util');
var drequire = require('./_drequire.js');
var WaitForElementVisible = drequire('waitForElementVisible.js');

function ifElementVisible() {
    WaitForElementVisible.call(this);
}

util.inherits(ifElementVisible, WaitForElementVisible);

ifElementVisible.prototype.fail = function (result, actual, expected, defaultMsg) {
    this.message = this.formatMessage(defaultMsg);

    // If we're not aborting on failure, mark this test case as passed.
    // That way, we don't fail the test case on an if statement
    if(this.abortOnFailure)
        this.client.assertion(false, actual, expected, this.message, this.abortOnFailure);
    else
        this.client.assertion(true, actual, expected, this.message + " - not critical", this.abortOnFailure);

    return this.complete(result);
};

module.exports = ifElementVisible;

Et pour l'utiliser :

module.exports = {
    "testing accessing element if visible" : function (browser) {
        var page, mySection;

        page = browser.page['pageName']();
        mySection = page.section.mySection;

        mySection.ifElementVisible('@myElement', 1000, false, function(isVisible) {
        if (isVisible.value) {
            mySection.click('@myElement');
        }
    });
}};

Ici, la touche XP ++ est présente parce que si vous utilisez les dernières versions stables 0.9.12 à l'heure actuelle, les customs commands ne prennent que des chaînes de caractère. Dans notre exemple il n'y a pas de problème, mais si vous voulez utiliser un élément défini dans une page (autrement dit page.section.maSection.maCommande(‘@element’);) vous devrez utiliser master sans quoi vous recevrez une chaine au lieu de l’élément. Pour plus de sécurité, bloquez votre package sur un commit précis plutôt que master.

Autre cas, si vous devez intéragir avec du JS présent dans la page (par exemple jQuery), c’est possible ! Il existe déjà quelques commandes ou assertions écrite par Massimo Galbusera sur https://github.com/maxgalbu/nightwatch-custom-commands-assertions et sinon, la base de chaque commande jQuery est toujours la même donc il y a même moyen de rendre ça plus extensible. 

En parlant d’extension, admettons que vous ayez besoin d’avoir une base commune a toutes vos pages :

var extend = function(obj, props){
    for(var prop in props) {
        if(props.hasOwnProperty(prop)) {
            if ("commands" === prop) {
                extend(obj.commands[0], props[prop][0]);
            } else if ("object" ===  typeof(props[prop])) {
                extend(obj[prop], props[prop]);
            } else {
                obj[prop] = props[prop];
            }
        }
    }
};

module.exports = extend;

Et on l’utilise comme ça :

var _commonPage = require('../lib/_common_page');
var _extends = require('../lib/_extends');

var myPage = new _commonPage();
_extends(myPage, {
    // surchargez ce dont vous avez besoin :)
});

module.exports = myPage;

Voilà, on a fait un bon tour de tout ce qui était possible de faire avec NightWatch ! Nous vous épargnons l’entièreté des vérifications possible et imaginables via assert, expect, et les commandes puisque la doc est bien pour ça. Dernier conseil : browser.pause dans un browser.perform sera votre ami pour exécuter des opérations asynchrones.

En résumé 

NightWatch est très bon dans son domaine. Il n’enlève pas les contraintes des tests e2e qui sont les délais liés au réseau ou au navigateur (plus rare) ou les lenteurs du JS qui font que le script va trop vite pour la librairie. Il est fiable si la page testée permet d’écrire des tests avec les élément cités plus haut. Il arrive parfois que vous n’ayez pas toujours accès au DOM et que vous devrez contourner l’absence d’ID ou de classes sur les éléments de DOM et c’est possible. Vous pouvez alors préciser que le sélecteur que vous utilisez est un xpath et vous pourrez construire des règles plus compliqués.

Dernier point : si NightWatch trouve plusieurs éléments pour un sélecteur, au choix, soit il plante soit il prend le premier, à vous de configurer le comportement. Le créateur de cet outil est plutôt réactif sur github et il y a pas mal de monde qui l’utilise alors ce ne sera jamais vraiment une difficulté de trouver la réponse à un de vos problèmes sur google ou dans les issues.

Et vous, quelle est votre expérience de NightWatch ? Nous serions ravis de lire vos retours à ce sujet :)