Go to Hackademy website

Tests end to end avec Jest et Puppeteer

Numa Claudel

Posté par Numa Claudel dans les catégories outils

Dans cet article, on va partir sur des tests end to end. Pour planter le décor, un test end to end (e2e) est un test logiciel qui a pour but de valider que le système testé répond correctement à un cas d’utilisation, mais également de vérifier l’intégration de ce cas dans l’interface.

L’intérêt de ces tests est de s’assurer qu’une fonctionnalité développée répond à la demande du point de vue de l’utilisateur. Ce faisant, toutes les couches logicielles sont traversées et testées.

Les tests end to end sont en fait toujours réalisés. S’ils ne sont pas automatisés, c’est le product owner qui validera manuellement que la fonctionnalité répond à la demande et est exempte de bug. Ou encore, en dernier recours, c’est l’utilisateur final qui effectuera malgré lui cette validation :)

Il peut être intéressant d’écrire ces tests avant ou parallèlement au développement de la fonctionnalité, pour décrire un cas d’utilisation. Il faut alors que la fonctionnalité se conforme aux étapes décrites dans ceux-ci.

Mais commençons ! Nous allons nous atteler à l’écriture de ces tests avec Jest et Puppeteer.

De quoi avons-nous besoin ?

Faisons une petite checklist de ce dont nous allons avoir besoin pour réaliser ces tests end to end de manière automatisée. Il nous faut :

  • une application web
  • un serveur applicatif qui soit en route
  • un navigateur web
  • une suite de tests
  • un test runner

Les deux premiers points de la liste font partie de l’ensemble que nous voulons tester. Le navigateur web sera manipulé par Puppeteer. Pour les tests, nous les écrirons avec Jest dans cet article, qui fera aussi office de test runner.

Nous avons donc l’application (celle que vous voulez tester), il ne nous reste plus qu’à installer et paramétrer Jest et Puppeteer, pour enfin écrire et exécuter nos tests.

Bémols :

Exécuter une suite de tests end to end avec cette solution ne se fait pas sans difficultés, et il faudra les garder en tête lors de l’écriture desdits tests :

  • les cookies sont conservés dans le navigateur entre les tests (par exemple le cookie de session)
  • les données ne sont pas remises à zéro entre chaque suite de tests
  • nous n’avons pas accès directement aux données

Nous sommes dans un navigateur web Chrome pour ces tests, ils sont joués du point de vue d’un utilisateur naviguant sur l’application. Une bonne idée, c’est de partir d’un état (de données), et de remettre ces données, ainsi que les cookies, à leur état initial après chaque suite de tests. Ceci peut être fait manuellement dans un callback à la fin de chaque série.

Cette démarche ne sera pas toujours aisée, par exemple dans le cas où des données sont modifiées en cascade, la maîtrise de celles qui sont impactées est facilement perdue.

Puppeteer

Puppeteer est une bibliothèque Node.js développée par Google, permettant de contrôler un navigateur Chrome, aussi bien en mode headless qu’avec une interface utilisateur. Il est possible d’exécuter la plupart des actions que l’on fait manuellement sur un navigateur. Avec cet outil, on va donc pouvoir automatiser des tests d’interface. Le mode graphique sera très utile pendant la phase de développement des tests, permettant de suivre en direct les différentes étapes décrites. Tandis que le mode headless (sans interface graphique) permettra d’exécuter les tests dans un outil d’intégration continue, c’est d’ailleurs le comportement par défaut de Puppeteer.

Voici un exemple d’étapes l’on peut écrire avec Puppeteer :

// subscribe.js

const puppeteer = require('puppeteer')

const subscribe = async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  await page.goto('https://www.synbioz.com/blog')
  await page.type('input[name="subscription[email]"]', '__votre_mail__')
  await page.click('form.subscription button[type="submit"]')

  await browser.close()
}

subscribe()

Il est possible qu’il vous soit nécessaire de passer ces options à la méthode launch pour que le script fonctionne : args: ['--no-sandbox', '--disable-setuid-sandbox'].

Sinon, il ne reste plus qu’à renseigner votre adresse mail et exécuter le fichier avec Node.js pour vous abonner à la diffusion de nos articles :)

Pour lancer Puppeteer en mode graphique et voir apparaitre une instance du navigateur Chrome exécuter les différentes étapes, il faut également passer l’option headless: false à la méthode launch.

Puppeteer offre beaucoup de possibilités que je vous laisse découvrir sur la documentation de l’API.

Avantages

  • navigateur natif, non émulé
  • bonne documentation
  • syntaxe simple à prendre en main (exemples : click, goto)

Bémols

  • très dépendant du DOM, dans le sens où il faut utiliser des sélecteurs pour accéder aux éléments

Jest

Jest est un test runner Node.js développé par Facebook. Il permet d’exécuter des suites de tests, de les structurer et d’écrire des assertions.

Il faut savoir qu’une suite de tests est exécutée de manière séquentielle. Les différentes suites sont quant à elles exécutées en parallèle.

L’exemple du Getting Started de Jest est simple et parlant. Je vous le remets ici :

// sum.js

function sum(a, b) {
  return a + b
}
module.exports = sum
// sum.test.js

const sum = require('./sum')

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3)
})

Il faut exécuter la commande jest dans le même dossier que ces 2 fichiers pour voir dans la console :

PASS  ./sum.test.js
✓ adds 1 + 2 to equal 3 (5ms)

Avantages

  • bonne documentation
  • syntaxe simple à prendre en main (syntaxe habituelle d’assertion)

Bémols

Tous les fichiers sont exécutés en parallèle, dans un premier temps on peut se dire : bien et gain de temps ! Le problème c’est que les données sont aussi modifiées en parallèle, ce qui donne lieu à des effets de bord entre les suites de test. Pour remédier au problème, il y a l’option runInBand qui a pour effet de jouer chaque suite, à la suite. On perd malheureusement en rapidité, mais on peut contrôler l’état d’entrée et de sortie d’une suite.

Il faut aussi faire attention au temps d’exécution de chaque bloc, qui est limité à 5 secondes par défaut. Si l’exécution du bloc déborde du temps imparti, Jest passe immédiatement au bloc suivant, avant de terminer ledit bloc. Les tests suivants vont donc potentiellement être en erreur, puisque l’état précédent n’est plus maîtrisé. Heureusement, il est possible de passer un timeout plus long au bloc pour lui permettre de se terminer et donc éviter ce problème.

it("a long test", async () => {

}, 10000)

Et enfin, un test donnant lieu à une exception mène souvent les tests suivants à être en erreur. Il faut trouver et corriger le coupable (le premier en erreur dans une suite) pour que les suivants passent au vert.

Installation de Jest et Puppeteer

Il va falloir installer Jest et Puppeteer, paramétrer Jest pour qu’il utilise Puppeteer et les configurer en fonction de notre besoin.

Commençons par se placer dans le dossier où l’on souhaite installer les paquets, pour écrire ceci :

npm init # initialise le paquet de test. Répondez aux questions posées
npm install --save jest puppeteer jest-puppeteer # installe les paquets et les sauve dans les dépendances du package.json

Si vous voulez faire plus simple, créez un package.json avec ceci, avant d’exécuter npm install :

{
  "description": "Exécution de tests EndToEnd avec Jest et Puppeteer",
  "private": true,
  "dependencies": {
    "jest": "^22.4.3",
    "jest-puppeteer": "^2.4.0",
    "puppeteer": "^1.4.0"
  },
  "scripts": {
    "test": "jest --runInBand"
  },
  "jest": {
    "preset": "jest-puppeteer"
  }
}

L’option "private": true est là pour éviter que NPM émette des alertes à cause d’informations manquantes sur le paquet (tel que le nom par exemple).

L’installation de Puppeteer téléchargera un navigateur Chrome compatible avec la version du paquet. Il est possible de sauter cette étape en définissant une variable d’environnement lors de l’installation, mais il faudra alors donner à Puppeteer le chemin vers l’exécutable Chrome :

PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm install

Et pour passer le chemin de l’exécutable Chrome à la fonction puppeteer.lauch :

puppeteer.launch({
  executablePath: '/path/to/binary/chrome'
})

Il y a un troisième paquet, qui se charge, lui, de faire la jonction entre Puppeteer et Jest, c’est jest-puppeteer. La configuration de l’assemblage sera donc faite pour nous.

Une fois que tout est installé et configuré, il ne reste plus qu’à écrire des tests (placés dans le même dossier ou dans un sous-dossier). Pour les exécuter la commande à utiliser est :

npm test

Ou :

jest --runInBand

Avec Docker

L’intérêt d’exécuter des tests end to end avec des containers Docker, c’est que l’on va pouvoir d’une part instancier un serveur applicatif, en initialisant son état de départ et d’autre part lancer parallèlement un autre container qui exécutera la suite de tests au travers du navigateur Chrome. On va ainsi être dans la même situation qu’un utilisateur final.

Dans cet exemple, les tests vont se jouer sur une application Rails, pour laquelle il va falloir initialiser un état de départ. Voici la commande pour préparer cet état :

# …

CMD rake db:setup \
  && rake db:fixtures:load \
  && rm -f /tmp/pid \
  && bundle exec puma -C config/puma.rb

Et pour le container de test, voici le Dockerfile (inspiré de la page Puppeteer troubleshooting) :

FROM node:8-slim

# See https://crbug.com/795759
RUN apt-get update && apt-get install -yq libgconf-2-4

# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer installs, work.
RUN apt-get update && apt-get install -y wget --no-install-recommends \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt-get update \
    && apt-get install -y google-chrome-unstable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst ttf-freefont \
      --no-install-recommends \
    && rm -rf /var/lib/apt/lists/* \
    && apt-get purge --auto-remove -y curl \
    && rm -rf /src/*.deb

# Skip the chromium download when installing puppeteer.
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true

WORKDIR /app

ADD jest/package*.json /app/
RUN npm install

CMD npm test

Tout ceci mixé dans un docker-compose.yml, permettra d’exécuter ces tests sur un environnement d’intégration continue, et ainsi savoir assez rapidement si la nouvelle fonctionnalité est bien intégrée à l’application.

Il faut tout de même rajouter un peu de configuration pour que tout cela fonctionne. À la racine du dossier de test, il faut rajouter un fichier jest-puppeteer.config.js avec au minimum ceci :

module.exports = {
  launch: {
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
    executablePath: 'google-chrome-unstable'
  }
}

launch est la méthode du même nom de Puppeteer décrite plus haut, à laquelle nous passons les options nécessaires ici.

Test de l’authentification

Que diriez-vous d’un petit exemple pour illustrer tout ça ? Alors commençons par le commencement, nous allons tester que notre page d’authentification se comporte comme attendu.

// tests/connection.spec.js

describe("Connection", () => {

  beforeEach(async () => {
    await page.goto('http://jest-test.docker', { timeout: 0 })
  })

  it("should display 'Se connecter' text on page", async () => {
    await expect(page).toMatch('Se connecter')
  })

  it("should return to the sign in page if nothing is filled in", async () => {
    await Promise.all([
      page.click('input[value=Connexion]'),
      page.waitForNavigation({ timeout: 0 })
    ])
    await expect(page).toMatch('Se connecter')
  })

  it("should return to the sign in page if I fill in bad user's data", async () => {
    await page.type('input[name="user[email]"]', 'bad@email.fr')
    await page.type('input[name="user[password]"]', 'badpassword')
    await Promise.all([
      page.click('input[value=Connexion]'),
      page.waitForNavigation({ timeout: 0 })
    ])
    await expect(page).toMatch('Se connecter')
  })

  it("should enter the app if I fill in correct user's data", async () => {
    await page.type('input[name="user[email]"]', 'email@email.fr')
    await page.type('input[name="user[password]"]', 'motdepasse')
    await Promise.all([
      page.click('input[value=Connexion]'),
      page.waitForNavigation({ timeout: 0 })
    ])
    await expect(page).toMatch('Connecté')
  })

  it("should disconnect me from the app if I click on disconnect", async () => {
    await page.click('.dropdown a.user-toggle')
    await Promise.all([
      page.click('.dropdown a[href="/users/sign_out"]'),
      page.waitForNavigation({ timeout: 0 })
    ])
    await expect(page).toMatch('Se connecter')
  })

})

Le bloc beforeEach fourni par Jest permet d’exécuter une action avant chaque bloc de test. D’ailleurs, voici la liste des méthodes Jest utilisables dans un fichier de test.

En exécutant la commande docker-compose up, nous pouvons voir des logs de navigation s’afficher et normalement se conclure ainsi :

# …
jest_1   | PASS tests/connection.spec.js (7.118s)
jest_1   |
jest_1   | Test Suites: 1 passed, 1 total
jest_1   | Tests:       5 passed, 5 total
jest_1   | Snapshots:   0 total
jest_1   | Time:        7.12s, estimated 9s
jest_1   | Ran all test suites.
app00jest_jest_1 exited with code 0

Cette suite de tests, qui joue avec l’authentification, fonctionne mais présente un problème qui illustre mes propos précédents au sujet des états d’entrée et de sortie :

Le cookie de connexion va être présent dans le navigateur manipulé par Puppeteer. La plupart des autres suites de tests de l’application vont avoir besoin d’être préalablement connectées, pour ainsi accéder directement à l’URL voulue et jouer leurs tests. Il va donc falloir veiller à ce que le navigateur ne soit pas connecté au début des tests de connexion, mais aussi qu’il soit de nouveau connecté à la fin. Et c’est là que l’on voit l’intérêt de jouer les suites séquentiellement.

Je vous propose pour la suite de mettre en place une authentification, et de faire en sorte que le navigateur soit dans un état connecté par défaut.

Pour commencer on va créer une classe qui se chargera de l’authentification :

// helpers/authenticator.js

class Authenticator {

  constructor(page) {
    this.properties()
    this.page = page
  }

  properties() {
    this.admin = {
      email: 'admin@email.fr',
      password: 'adminpassword'
    }
  }

  async connect(person) {
    await this.logout()
    await this.page.goto('http://jest-test.docker', { timeout: 0 })
    await this.page.type('input[name="user[email]"]', person.email)
    await this.page.type('input[name="user[password]"]', person.password)
    await Promise.all([
      this.page.click('input[value=Connexion]'),
      this.page.waitForNavigation({ timeout: 0 })
    ])
  }

  async logout() {
    await this.page.deleteCookie(...await this.page.cookies('http://jest-test.docker'))
  }

}

module.exports = Authenticator

Puis ajoutons un environnement personnalisé pour que le navigateur soit connecté avec un compte par défaut :

// environment.js

const PuppeteerEnvironment = require('jest-environment-puppeteer')
const Authenticator = require('./helpers/authenticator')

class CustomEnvironment extends PuppeteerEnvironment {

  async setup() {
    await super.setup()

    const authenticator = new Authenticator(this.global.page)
    await authenticator.connect(authenticator.admin)
  }

}

module.exports = CustomEnvironment

Il faut maintenant modifier notre suite de tests précédente, puisque nous voulons que pour le point de départ de cette suite, le navigateur ne soit pas connecté et qu’il le soit de nouveau à la fin :

const Authenticator = require('../helpers/authenticator')

describe("Connection", () => {

  const authenticator = new Authenticator(page)

  beforeAll(async () => {
    await authenticator.logout()
  })

  // reset to the default connection
  afterAll(async () => {
    await authenticator.connect(authenticator.admin)
  })

// …

Pour l’ajout d’autres suites de tests, il suffira au début de celles-ci de se rendre à l’URL prévue :

describe("La 2e suite", () => {

  beforeEach(async () => {
    await page.goto('http://jest-test.docker/chemin-de-la-page-a-tester', { timeout: 0 })
  })

// …

Conclusion

La combinaison de ces deux outils offre des possibilités très intéressantes pour tester une application, qui nous font gagner en temps et en sérénité. Ces avantages sont tout de même tempérés par le temps nécessaire à l’écriture des tests, par leur temps d’exécution et par l’état du jeu de données difficile à maitriser. Il faut donc être rigoureux et s’imposer des guidelines pour que ce soit intéressant.

Ressources


L’équipe Synbioz.
Libres d’être ensemble.

Articles connexes

Krita, c'est quoi ?

07/11/2019

Je dessine, retouche, peins et anime sur Photoshop depuis plus de 10 ans. C’est bien simple, j’aime Photoshop autant que je le déteste. C’est un très bon logiciel malgré ses défauts. Il est complet,...

Un plugin Vim à la mimine

03/01/2019

Dans l’article précédent, intitulé une assez bonne intimité, je vous présentais GPG et le chiffrement de courriels. Nous avons alors remarqué que le contenu d’un courriel était encodé de sorte que le...

Une assez bonne intimité

20/12/2018

Si vous êtes utilisateur de MacOS, il y a de fortes chances que vous utilisiez Apple Mail pour échanger des courriels. Et comme vous êtes sensible à la confidentialité des informations que vous...

Chasser les requêtes N+1 avec Bullet

05/04/2018

Aujourd’hui nous allons parler des requêtes N+1 dans une application Rails : vous savez ces lignes quasiment identiques, qui s’ajoutent de manière exponentielle aux logs, dès lors que l’on appelle...