Article écrit par Tom Panier
À 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’utiliserVue.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 !