Go to Hackademy website

Testez unitairement votre application Vue.js avec Jest

Tom Panier

Posté par Tom Panier dans les catégories front

À la toute fin de notre série d’articles d’introduction à Vue.js, je vous avais — je l’espère ! — laissés sur votre faim avec un peu de teasing sur les possibilités ultérieures d’amélioration et d’industrialisation de notre codebase, en parlant notamment de tests unitaires, et en indiquant que ceux-ci pourraient se trouver être le sujet d’un prochain article.

Vous l’aurez compris, cet article est celui que vous avez devant les yeux ! Voyons donc ensemble sans plus attendre comment écrire ces fameux tests dans une application Vue.js.

A jester of sorts, you stand holding your court…

Numa vous a déjà parlé de Jest dans un précédent article, où il l’utilise en tant que runner pour exécuter des tests fonctionnels. Je vais vous laisser lire sa prose si vous souhaitez une présentation exhaustive ; sachez toutefois qu’il s’agit d’un outil développé par Facebook, et en l’occurrence d’une surcouche à Jasmine, l’un des test runners les plus populaires de l’écosystème JavaScript, si ce n’est le plus populaire. De la même façon qu’avec son illustre aïeul, un test s’écrit, très simplement, de la manière suivante :

describe("Some feature", () => {
  it("works", () => {
    expect(true).toBe(true);
  });
});

Ce qui, à l’exécution, produit le résultat suivant :

$ jest

 PASS  test/getEmbedUrl.spec.js
  Some feature
    ✓ works (6ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.458s, estimated 1s
Ran all test suites.

On utilise describe pour délimiter une fonctionnalité à tester, it pour définir un test (typiquement formulé en langage naturel), et expect ainsi que ses nombreux matchers pour rédiger nos assertions.

Retour aux (codes) sources

Trêve d’exemples bateaux, passons à la pratique : nous allons écrire des tests pour notre application préférée, à savoir Memebox !

Une fois le projet installé en local si ce n’était pas déjà le cas, commençons par y ajouter Jest (et quelques paquets complémentaires) :

$ npm install jest babel-jest vue-jest --save-dev

Pourquoi --save-dev ?

Tout simplement parce que les paquets en question ne seront nécessaires qu’en développement ; une fois l’application déployée, il sera un peu tard pour la tester unitairement !

Cela étant fait, ajoutons à package.json la configuration requise pour faire tourner l’outil :

{
  // ...
  "scripts": {
    // ...
    "test": "jest"
  },
  // ...
  "jest": {
    "moduleFileExtensions": [
      "js",
      "vue"
    ],
    "transform": {
      "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
      ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
    }
  }
}

Via ces quelques lignes, nous indiquons à Jest de s’intéresser aux fichiers .js et .vue, en transpilant les premiers avec Babel et les seconds avec Vue.js. Nous faisons également en sorte de pouvoir lancer la testsuite avec npm test. Justement, essayons :

$ npm test

No tests found
  15 files checked.
  testMatch: **/__tests__/**/*.js?(x),**/?(*.)+(spec|test).js?(x) - 0 matches
  testPathIgnorePatterns: /node_modules/ - 15 matches
Pattern:  - 0 matches
npm ERR! Test failed.  See above for more details.

Il semble bien que l’outil fonctionne comme souhaité, même si nous n’avons écrit aucun test pour l’instant ; nous allons précisément remédier à cet état de fait !

Premiers tests

Pour débuter, nous allons tester du code vanilla, en l’occurrence notre service getEmbedUrl. Créons donc un dossier pour accueillir nos tests, ainsi que le fichier idoine :

$ mkdir test
$ touch test/getEmbedUrl.spec.js

Nous allons d’abord éliminer le cas le plus simple, à savoir celui où le paramètre passé à la fonction ne correspond à aucun des formats d’URL dont nous voudrions extraire un identifiant de vidéo YouTube :

import getEmbedUrl from "../src/getEmbedUrl";

describe("getEmbedUrl", () => {
  it("returns null for an incompatible URL", () => {
    expect(getEmbedUrl("whatever")).toBe(null);
  });
});
$ npm test

 PASS  test/getEmbedUrl.spec.js
  getEmbedUrl
    ✓ returns null for an incompatible URL (4ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.869s, estimated 1s
Ran all test suites.

Ça passe ! Occupons-nous maintenant de tester les cas réellement intéressants, c’est-à-dire les différents formats d’URL supportés par le service :

// ...

describe("getEmbedUrl", () => {
  // ...

  it("correctly formats a youtu.be URL", () => {
    expect(getEmbedUrl("https://youtu.be/Mem3b0x")).toBe("https://www.youtube.com/embed/Mem3b0x");
  });

  it("correctly formats a youtube.com URL", () => {
    expect(getEmbedUrl("https://youtube.com/watch?v=Mem3b0x")).toBe("https://www.youtube.com/embed/Mem3b0x");
  });

  it("leaves a youtube.com/embed URL untouched", () => {
    const url = "https://www.youtube.com/embed/Mem3b0x";
    expect(getEmbedUrl(url)).toBe(url);
  });
});
$ npm test

 PASS  test/getEmbedUrl.spec.js
  getEmbedUrl
    ✓ returns null for an incompatible URL (3ms)
    ✓ correctly formats a youtu.be URL (1ms)
    ✓ correctly formats a youtube.com URL
    ✓ leaves a youtube.com/embed URL untouched

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        0.613s, estimated 1s
Ran all test suites.

Parfait ! Nous avons pu déterminer avec certitude que la fonction getEmbedUrl fonctionne comme attendu. Si nous devons la faire évoluer dans le futur, afin de supporter d’autres sources que YouTube par exemple, nous avons désormais une garantie de ne pas casser le comportement existant sans s’en rendre compte.

Tests de composants : rendu

Jusqu’ici, nous avons utilisé Jest comme nous aurions utilisé Jasmine, sans réellement tirer parti de ce qui fait sa force, j’ai nommé JSDOM.

JSDOM est une implémentation de DOM virtuel, qui nous permet de tester de manière réellement unitaire du code JavaScript s’appuyant sur la présence d’un DOM, tel un composant React ou Vue.js. Cette façon de faire a plusieurs avantages si on la compare aux précédentes, qui s’appuyaient immanquablement sur l’utilisation d’un navigateur via Selenium ou encore Puppeteer :

  • réellement unitaire, et réellement différent d’un test fonctionnel / d’intégration qui, lui, est plus pertinent dans un navigateur (voire dans plusieurs)
  • plus rapide, moins sujet à d’obscurs dysfonctionnements

Voyons donc comment procéder en soumettant à l’épreuve des tests notre composant Meme !

$ touch test/Meme.spec.js
import Vue from "vue/dist/vue.common";
import Meme from "../src/components/Meme";

describe("Meme", () => {
  it("renders an image", () => {
    const vm = new Constructor({ propsData: { url: "https://example.com" } }).$mount();

    expect(vm.$el.outerHTML).toBe([
      "<div class=\"meme-container\">",
      "<div class=\"meme\" style=\"background-image: url(https://example.com);\"></div></div>"
    ].join(""));
  });
});

Nous constatons plusieurs choses :

  • nous réalisons un import un peu spécial de Vue.js, qui nous permet d’utiliser Vue.extend afin d’obtenir un constructeur autonome pour notre composant
  • nous appelons ce constructeur dans notre test, ce qui nous permet au passage de configurer le composant, en termes de props notamment
  • la variable résultant de l’appel au constructeur nous permet de manipuler le composant dont le rendu a été effectué dans JSDOM ; ici, nous vérifions précisément son markup grâce à $vm.el

Nous pouvons dès lors garantir rapidement le comportement du composant quand une URL YouTube lui est fournie en écrivant un second test similaire au premier :

// ...

describe("Meme", () => {
  // ...

  it("renders an embed", () => {
    const vm = new Constructor({ propsData: { url: "https://youtu.be/Mem3b0x" } }).$mount();

    expect(vm.$el.outerHTML).toBe([
      "<div class=\"meme-container\">",
      "<iframe src=\"https://www.youtube.com/embed/Mem3b0x\" frameborder=\"0\" allowfullscreen=\"allowfullscreen\" class=\"meme\">",
      "</iframe></div>"
    ].join(""));
  });
});

Notez qu’on pourrait remettre en cause le caractère unitaire de ce test, étant donné que la fonctionnalité testée s’appuie sur getEmbedUrl, que l’on devrait idéalement mocker (on y reviendra) pour garantir ce caractère. Ici, sachant que la fonction getEmbedUrl est elle-même couverte par la testsuite, et que je souhaite accessoirement conserver une certaine simplicité dans cet article, nous allons convenir du fait que « c’est bien comme ça ».

Jouons maintenant nos tests :

$ npm test

 PASS  test/getEmbedUrl.spec.js
 PASS  test/Meme.spec.js

Test Suites: 2 passed, 2 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        1.239s
Ran all test suites.

Tests de composants : interaction

Passons maintenant au niveau supérieur : nous allons tâcher de garantir le comportement de notre composant quand l’utilisateur interagit avec lui, au moyen d’autres outils offerts par Jest. Plus précisément, nous allons nous intéresser à ce qui se produit lorsque l’utilisateur clique sur le composant, à savoir la copie de l’URL reçue en prop dans le presse-papiers :

// ...
import * as copyToClipboard from "../src/copyToClipboard";

// ...

describe("Meme", () => {
  // ...

  it("copies its URL to clipboard when clicked", () => {
    const vm = new Constructor({ propsData: { url: "https://example.com" } }).$mount();
    copyToClipboard.default = jest.fn();

    vm.$el.dispatchEvent(new window.Event("click"));
    expect(copyToClipboard.default).toHaveBeenCalledWith("https://example.com");
  });
});

Est-ce que ça a un rapport avec cette histoire de « mock » dont tu parlais tantôt ?

10/10, mon neveu ! Compte tenu du fait que nous ne pouvons pas vérifier le contenu du presse-papiers dans notre test (ce qui risquerait de toute façon, une fois de plus, de remettre en cause sa nature unitaire), nous préférons « mocker » la fonction copyToClipboard, c’est-à-dire la remplacer au runtime par une fonction de notre cru, qui non seulement est sans effet de bord, mais peut également être « surveillée » afin de garantir qu’elle a bien été appelée, et avec des valeurs précises pour ses paramètres par-dessus le marché !

Le mieux dans tout ça, c’est que ça marche :

$ npm test

 PASS  test/Meme.spec.js
 PASS  test/getEmbedUrl.spec.js

Test Suites: 2 passed, 2 total
Tests:       7 passed, 7 total
Snapshots:   0 total
Time:        1.902s
Ran all test suites.

Pour s’en assurer définitivement, relançons les tests en commentant la ligne où l’on déclenche l’évènement click :

$ npm test

 FAIL  test/Meme.spec.js
  ● Meme › copies its URL to clipboard when clicked

    expect(jest.fn()).toHaveBeenCalled()

    Expected mock function to have been called, but it was not called.

      32 |     //vm.$el.dispatchEvent(new window.Event("click"));
      33 |
    > 34 |     expect(copyToClipboard.default).toHaveBeenCalled();
         |                                     ^
      35 |   });
      36 | });
      37 |

      at Object.<anonymous> (test/Meme.spec.js:34:37)

 PASS  test/getEmbedUrl.spec.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 6 passed, 7 total
Snapshots:   0 total
Time:        1.287s
Ran all test suites.
npm ERR! Test failed.  See above for more details.

On peut pousser le vice jusqu’à en profiter pour revérifier le markup du composant, ou plus spécifiquement son attribut class, qui devrait avoir été modifié du même coup :

// ...

describe("Meme", () => {
  // ...

  it("copies its URL to clipboard when clicked", done => {
    // ...

    vm.$nextTick(() => {
      expect(vm.$el.classList.contains("meme-container-clicked")).toBe(true);
      done();
    });
  });
});

Afin de pouvoir constater les éventuelles évolutions du markup de notre composant, dans ce contexte, nous devons manuellement indiquer à Vue.js de « faire un tour de boucle » de rendu avant d’inspecter vm.$el. Pour ce faire, nous faisons appel à la fonction vm.$nextTick ; celle-ci étant asynchrone, nous avons besoin d’indiquer à Jest quand l’exécution du test est effectivement terminée, possibilité qui nous est offerte par la fonction done passée en second paramètre de tous les appels à it, mais que nous avons ignorée jusqu’ici puisque nous n’en avions pas besoin.

Jouons les tests une dernière fois pour la route :

$ npm test

 PASS  test/Meme.spec.js
 PASS  test/getEmbedUrl.spec.js

Test Suites: 2 passed, 2 total
Tests:       7 passed, 7 total
Snapshots:   0 total
Time:        1.166s
Ran all test suites.

Félicitations ! Nous disposons désormais d’une (ébauche de) testsuite pour notre application, qui nous apportera une certaine sérénité lors de nos futurs développements !

Le mot de la fin, again

Il va sans dire que les tests écrits dans le cadre de cet article ne sont ni très complexes, ni forcément très pertinents : en ce qui concerne les tests de composants, notamment, je privilégie personnellement le fait de tester peu, mais de tester ce qui a une réelle valeur ajoutée, plutôt que de vérifier que Vue.js fait bien ce qu’on attend de lui ; cela n’est toutefois envisageable qu’avec des composants un peu plus complexes que Meme, où la logique métier embarquée est légère voire inexistante.

Il est également important de noter que dans une optique de découverte éclairée, nous avons tout mis en place à la main ici, mais qu’utiliser vue-cli vous permettra de mettre directement en place Jest sur votre projet Vue.js lors de sa création, notamment au moyen du paquet @vue/cli-test-utils qui facilite quelque peu l’écriture des tests de composants.

Je ne sais pas encore quels horizons nous explorerons la prochaine fois, mais je vous donne d’ores et déjà rendez-vous sur ce blog très prochainement. D’ici là, portez-vous bien, et bon JavaScript !


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

Articles connexes

Houdini, CSS by JS

21/03/2019

Bonjour à tous, bienvenue dans le monde magique de l’illusion et des faux-semblants, où un background peut souvent en cacher un autre. Que le rideau se lève le temps d’apercevoir ce qui se cache...

Architecture en trois tiers d'une application Vue.js

21/02/2019

Ça commence à faire un petit moment que je vous bassine avec Vue.js, et que je vous fais construire des single-page applications en s’appuyant dessus. Néanmoins, nous n’avons jamais réellement parlé...

Une bibliothèque pour gérer l'authentification avec Vue.js, partie 2 — en route vers HTTP

08/02/2019

À l’heure où j’écris ces lignes, il m’est difficile de prévoir le temps qu’il fera quand vous les lirez. Laissons donc cette fois-ci les considérations météorologiques de côté et replongeons-nous...

Dark mode et CSS

24/01/2019

Bonjour à tous, aujourd’hui un sujet (presque) d’actualité puisque nous allons parler du mode sombre de MacOS, mais surtout d’une nouvelle manière — assez radicale — de penser nos interfaces. Le...