Tests unitaires en Vue.js avec vue-test-utils et Jest

le 27/03/2018 par Roman Quelen
Tags: Software Engineering

Cet article décrit des cas de tests unitaires, des plus communs à certains plus complexes, sur une application web en Vue.js.

Vous pouvez lire l'article Vue.js en TDD au préalable, qui se concentre d'avantage sur la méthodologie de test (Test Driven Development). Cet article-ci décrit un panel de cas de tests plus large, en utilisant l'utilitaire officiel vue-test-utils et le framework de test Jest.

1/ Jest

Jest est un framework de test développé et utilisé par Facebook pour tester unitairement du code JavaScript. Son principal objectif est de simplifier la mise en place des tests sur un projet. Il intègre pour cela la plupart des outils dont peuvent avoir besoin les développeurs pour tester leur application (lanceur de test, assertions, mocks, headless browser, ...) tout en favorisant l'expérience développeur. Néanmoins, Jest n'est pas parfait, et nous pourrions résumer ses avantages et inconvénients comme ceci :

Avantages :

  • Tout en un : facile à mettre en place (configuration par défaut avec jsdom, babel, …)
  • Snapshot testing (cf. stratégie de test dans la partie 3 ci-dessous)
  • Bonne expérience développeur (CLI)
  • Exécution parallèle des tests pour améliorer les performances

Inconvénients :

  • Tout en un : la configuration de base peut ne pas convenir à tous les projets
  • Ne peut pas mocker la valeur de retour en fonction des paramètres d'appel (contrairement à Sinon)
  • Pourrait avoir des performances légèrement inférieures à la concurrence

Jest est souvent associé à React du fait de son origine, et parce qu'il fonctionne très bien avec dès son installation. Cependant, nous pouvons quand même tirer parti de ses fonctionnalités pour d'autres bibliothèques/frameworks, comme Vue dans cet exemple.

D'ailleurs, Jest est maintenant inclus dans le boilerplate Vue webpack ce qui signifie qu'il existe une configuration officielle pour Vue.js.

NB : Nous utilisons une configuration spécifique dans notre projet d'exemple, car la configuration officielle n'était pas disponible au moment de la rédaction de cet article.

2/ Vue-test-utils

Vue-test-utils est une bibliothèque officielle qui contient des utilitaires d'aide au test de composants Vue. Il s'agit de l'équivalent d'Enzyme du monde React. Il fournit donc une API similaire à de rares détails près. Il est principalement utilisé pour instancier un composant en "stubbant" ses sous-composants (shallow), pour trouver des éléments dans le template, simuler des évènements et facilement mocker des plugins. Dans les prochains exemples, nous détaillerons les principales fonctionnalités que nous utilisons pour nos tests unitaires. Dans le cas où certains attributs seraient manquants dans vue-test-utils, vous pouvez toujours accéder à l'instance Vue du composant grâce à son "wrapper" ("wrapper.vm" comme dans les exemples d'émission d'évènement et de test asynchrone).

3/ Stratégie de test unitaire

Un test unitaire est la plus petite échelle de test possible. C'est un test automatisé concentré sur une portion de code précise en s'affranchissant de ses dépendances. Sa taille et sa durée d'exécution permettent une boucle de feedback rapide qui facilite la pratique du TDD.

Avec cette définition, nous pouvons voir que l'approche d'un test unitaire est subjective (surtout dans le front), mais deux critères semblent prédominants : les tests unitaires doivent être assez rapides pour le TDD et isolés de leurs dépendances.

De nos jours, tous les frameworks et bibliothèques modernes (Vue, React, Ember, Angular, ...) sont basés sur l'architecture en composants. C'est pourquoi nous pouvons dire que l'unité la plus atomique dans le développement est un composant, privé de ses dépendances.

De plus, les composants Vue n'ont pas besoin d'un véritable navigateur pour être rendus, nous pouvons donc faire des assertions rapides sur le template grâce à jsdom (navigateur headless configuré par défaut dans Jest).

Avec ces éléments en tête, nous avons élaboré une stratégie de test basée sur notre stack technique :

  • Notre échelle de test unitaire est un composant, y compris son template
  • Nous "mockons" les dépendances externes (axios par exemple) grâce à Jest
  • Nous "mockons" les sous-composants grâce à la fonction "shallow" de vue-test-utils
  • Nous testons les éléments statiques du template (classe CSS, structure du HTML, ...) grâce aux snapshots de Jest.
  • Si possible, nous simulons les interactions de l'utilisateur en déclenchant des évènements grâce à vue-test-utils (trigger('click'))
  • Si possible, nous faisons des assertions sur le template, grâce à la fonction find de vue-test-utils

Cette stratégie de test unitaire nous permet de nous concentrer davantage sur le comportement fonctionnel du composant plutôt que sur son implémentation technique.

4/ Exemples

Dans les exemples suivants, "mon-fichier.js" contient l'implémentation et "mon-fichier.spec.js" contient les tests.

Vous pouvez retrouver tous ces différents exemples dans ce repo github.

4.1/ Cas de tests classiques

4.1.1/ Initialisation des données d'un composant Vue

Ici, nous testons la donnée "message" de l'instance du composant "Hello".  Par défaut, nous voulons que la donnée "message" contienne la chaîne "Welcome". Dans ce test, nous utilisons la fonction "shallow" de vue-test-utils. Shallow rend le composant en "stubbant" ses sous-composants automatiquement. Il retourne un Wrapper d'instance, qui contient le composant monté, ainsi que des utilitaires et raccourcis de vue-test-utils.

Hello.spec.js

it('should contain default message', () => { // when const wrapper = shallow(Hello);

// then const title = wrapper.find('h1'); expect(title.text()).toContain('Welcome'); });

Hello.vue

<template> <div class="hello"> <h1>{{ message }} {{ name }}</h1> </div> </template>

<script> export default { name: 'hello', props: { name: String }, data () { return { message: 'Welcome' } } } </script>

4.1.2/ Mise à jour de la donnée d'un composant Vue

Nous voulons nous assurer que le template utilise les données (data) du composant Hello, et non des données "en dur". Ici, nous testons la donnée "message" de l'instance de "Hello" après mise à jour. Pour cela, nous utilisons à nouveau "shallow", mais aussi la fonction "setData()" qui permet de mettre à jour les données de l'instance du composant.

Hello.spec.js

it('should update h1 title when message data is changed', () => { // given const wrapper = shallow(Hello);

// when wrapper.setData({message: 'world'});

// then const title = wrapper.find('h1'); expect(title.text()).toContain('world'); });

Hello.vue

Même implémentation que dans le test en 4.1.1.

4.2/ Propriétés statiques et snapshots

Snapshot est un outil fourni par Jest. Nous l'utilisons pour détecter les changements des éléments statiques du template. La première fois, le test rend la structure HTML de l'instance du composant. Puis, à chaque exécution, il rendra à nouveau le composant pour le comparer avec le HTML stocké préalablement. L'assertion fonctionne seulement si les deux rendus sont identiques. Cet outil est utilisé comme test de non régression, ce qui n'entre pas dans la boucle de TDD.

Hello.spec.js

it('should match snapshot', () => { // when const wrapper = shallow(Hello);

// then expect(wrapper.element).toBeDefined(); expect(wrapper.element).toMatchSnapshot(); });

Hello.vue

Même implémentation qu'en 4.1.1.

Snapshot: Hello.spec.js.snap

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Hello.vue should match snapshot 1`] = ` <div class="hello"

<h1> Welcome </h1> </div> `;

Echec test de Snapshot

Voilà un exemple de test de snapshot qui échoue, dû au changement d'une classe CSS dans le template.

4.3/ Évènements natifs

Le prochain exemple montre un test d'évènement natif. Nous générons l'instance du composant en utilisant "shallow", puis nous retrouvons le bouton qui doit émettre l'évènement grâce à la fonction "find" . Ensuite, nous simulons l'émission de l'évènement grâce à la fonction "trigger" sur le wrapper du bouton. Cette méthode vient de vue-test-utils et prend le nom de l'évènement en premier argument.

Pour plus d'informations, lire la documentation de trigger.

ButtonCounter.spec.js

it('should increment counter on click', () => { // given const wrapper = shallow(ButtonCounter); const button = wrapper.find('.test');

// when button.trigger('click');

// then expect(button.text()).toEqual('1'); });

ButtonCounter.vue

<template> <button @click="incrementCounter" class="test">{{ counter }}</button> </template>

<script> export default { name: 'buttonCounter', data () { return { counter: 0 } }, methods: { incrementCounter: function () { this.counter += 1; } } } </script>

4.4/ Évènements spécifiques

4.4.1/ Emettre un évènement spécifique

Cet exemple montre comment tester l'émission d'un évènement spécifique (non natif). Nous commençons par rendre le composant en utilisant "shallow". Ensuite, nous simulons un click qui déclenche l'émission de notre évènement spécifique. Enfin, nous créons un espion (spy) qui écoute l'évènement à tester. Ici, l'évènement spécifique est "increment". Il doit être déclenché par le clic sur le bouton ".test". L'assertion se concentre sur l'appel de l'espion.

ButtonCounter.spec.js

it('should emit an event increment on click', () => { // given const wrapper = shallow(ButtonCounter); const button = wrapper.find('.test'); const spy = jest.fn(); wrapper.vm.$on('increment', spy);

// when button.trigger('click');

// then expect(spy).toHaveBeenCalledTimes(1); });

ButtonCounter.vue

<template> <button @click="incrementCounter" class="test">{{ counter }}</button> </template>

<script> export default { name: 'buttonCounter', data () { return { counter: 0 } }, methods: { incrementCounter: function () { this.counter += 1 this.$emit('increment') } } } </script>

4.4.2/ Handle a custom event

L'objectif de ce test est de vérifier l'écoute et la prise en charge de l'évènement d'un sous-composant par le composant parent. L'action du test est l'émission de l'évènement par le sous-composant, en utilisant la méthode d'instance $emit. Nous devons utiliser la fonction $nextTick pour attendre la mise à jour du DOM causée par l'évènement "increment". Nous utilisons "async/await" car la fonction $nextTick est asynchrone et renvoie une promesse. Enfin, nous pouvons écrire une assertion classique sur le DOM après mise à jour.

Pour plus d'informations, lire la documentation de $emit et de $nextTick.

Counter.spec.js

it('should increment counter on event increment from either buttons', async () => { // given const wrapper = shallow(Counter); const buttonCounters = wrapper.findAll(ButtonCounter);

// when buttonCounters.at(0).vm.$emit('increment'); buttonCounters.at(1).vm.$emit('increment'); await wrapper.vm.$nextTick();

// then const paragraph = wrapper.find('p'); expect(paragraph.text()).toEqual('2'); });

Counter.vue

<template> <div class="test"> <h1>counter</h1> <p>{{ total }}</p> <button-counter @increment="incrementTotal"></button-counter> <button-counter @increment="incrementTotal"></button-counter> </div> </template>

<script> import ButtonCounter from '../ButtonCounter/ButtonCounter'

export default { name: 'counter', data () { return { total: 0, }; }, methods: { incrementTotal () { this.total += 1; }, }, components: { ButtonCounter, } } </script>

4.5/ Test Asynchrone et Hook de cycle vie du composant

L'objectif est de tester les instructions présentes dans une étape du cycle de vie d'un composant. Dans ce cas de test, nous testerons les instructions contenues dans le hook "created" . Nous utilisons une API externe générée qui renvoie le chiffre 11 pour initialiser le compteur du composant et nous utilisons axios pour faire des requêtes à cette API.

Nous pouvons "mocker" cette dépendance externe en utilisant "jest.mock" . Jest substitue alors tous les appels à "axios", par le comportement fourni, ici un espion sur la fonction get. N'oubliez pas d'utiliser la fonction "mockReset" dans le "beforeEach", pour réinitialiser l'espion entre chaque test. Nous devons toutefois utiliser "$nexTick" car nous ne pouvons pas capturer la promesse retournée dans un hook de cycle vie asynchrone.

Vous pouvez noter que Vue attache automatiquement le contexte du composant dans la fonction "created", c'est pourquoi nous pouvons accéder directement à la donnée "counter" du composant depuis le hook "created".

Pour plus d'information, lire cet article sur le cycle de vie d'un composant Vue.

ButtonCounter.spec.js

import {shallow} from 'vue-test-utils'; import axios from 'axios';

import ButtonCounter from '../ButtonCounter';

jest.mock('axios', () => ({ get: jest.fn(), }));

describe('ButtonCounter.vue', () => {

beforeEach(() => { axios.get.mockReset(); axios.get.mockReturnValue(Promise.resolve({})); });

it('should set counter to API value on component creation', async () => { // given const response = { data: 11, }; axios.get.mockReturnValue(Promise.resolve(response));

// when const wrapper = shallow(ButtonCounter); await wrapper.vm.$nextTick();

// then expect(axios.get).toHaveBeenCalledWith('http://www.mocky.io/v2/5a01affc300000da45fac0cf'); const button = wrapper.find('.test'); expect(button.text()).toEqual('11'); }); });

ButtonCounter.vue

<template> <button @click="incrementCounter" class="test">{{ counter }}</button> </template>

<script> import axios from 'axios'

export default { name: 'buttonCounter', created () { axios.get('http://www.mocky.io/v2/5a01affc300000da45fac0cf') .then((response) => { this.counter = response.data }) }, data () { return { counter: 0 } }, methods: { incrementCounter: function () { this.counter += 1; this.$emit('increment'); } } } </script>

NB : pour des raisons de lisibilité, la requête vers l'API est écrite directement dans le composant. Ceci est une mauvaise pratique. Dans du code de production, nous recommandons d'extraire cet appel dans un service JavaScript externe (Single Responsibility Principle)

Conclusion

Maintenant vous devriez être plus familier avec Vue.js, vue-test-utils et Jest. Vous pouvez facilement extrapoler depuis ces exemples et la documentation officielle pour d'autres cas de test. Par exemple, nous avons choisi d'ignorer l'utilitaire "mount", qui permet d'écrire des tests d'intégration en instanciant un composant avec ses sous-composants.

Un framework basé sur les composants encourage le développeur à tester à travers le DOM et les interactions de l'utilisateur car il couple fortement les concepts de Vue et Contrôleur du pattern MVC. C'est pourquoi ces exemples se concentrent sur le comportement fonctionnel du composant plutôt que son implémentation technique, qui peut être amenée à évoluer, ce qui rendrait les tests plus fragiles.

Cette stratégie de test unitaire a été mise en place par tous les membres de l'équipe et correspond à notre définition de test unitaire, ainsi qu'à nos besoins de développement :

  • rapide à écrire
  • rapide à exécuter
  • isolé
  • meilleure couverture qu'un test unitaire classique, car teste aussi la logique du template

Anti-sèche technique :

  • jest.mock : mocke les dépendances externes
  • shallow : instancie un composant en mockant automatiquement ses sous-composants
  • snapshot : test de non régression sur les éléments statiques
  • trigger : simule une interaction de l'utilisateur
  • wrapper.find : permet de récupérer un élément du DOM pour simuler des évènements ou faire des assertions.