Tests unitaires avec Vue.js et Jest

Introduction

Ce tutoriel est un introduction aux tests unitaires avec Vue.js et jest. Nous y verrons les bases de l'écriture de tests avec la vérification du contenu du DOM, le test des évènements, les fonctions asynchrones et enfin le mocking du store Vuex.

Creation d'un projet

Créons un project vue avec l'option extended

vue create demo-unit-tests

Ensuite selectionner le preset extended

Vue CLI v4.5.2
? Please pick a preset:
  Default ([Vue 2] babel, eslint)
❯ extended ([Vue 2] dart-sass, babel, typescript, router, vuex, eslint, unit-jest, e2e-cypress)
  Default (Vue 3 Preview) ([Vue 3] babel, eslint)
  Manually select features

Ce preset contient notament unit-jest qui va nous permettre de tester nos composants.

vue create génère automatiquement une arboresence de fichier avec un dossier spécifique pour les tests:

tests
├── e2e
│   ├── plugins
│   │   └── index.js
│   ├── specs
│   │   └── test.js
│   └── support
│       ├── commands.js
│       └── index.js
└── unit
    └── example.spec.ts

Nous allons nous interesser à tests/unit.

Mais d'abord créons un petit composant pour afficher un compteur.

Modifions le fichier src/views/Home.vue avec le contenu suivant:

<template>
  <div class="home">
    <h1 id="title">Counter value: {{ counter }}</h1>
    <button id="increment" @click="increment">+ increment</button>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component({
  components: {},
})
export default class Home extends Vue {
  counter = 0;

  increment(): void {
    this.counter++;
  }
}
</script>

C'est un simple compteur qui affiche la valeur courante du compeur et qui contient deux boutons permettant d'incrémenter et décrementer le compteur.

Examples de test unitaires

Vérifier la valeur d'un texte

Maintenant créons un fichier Home.test.ts dans tests/unit: (you can remove the file: rm tests/unit/example.spec.ts)

// Importer les utilitaires de test
import { shallowMount } from "@vue/test-utils";
// Importer le composant que l'on souhaite tester
import Home from "@/views/Home.vue";

// Créer un groupe de test "Home.vue"
describe("Home.vue", () => {
  // Créer un test dans le groupe
  it("renders counter value", () => {
    // Monter le composant et y accéder avec la variable wrapper
    const wrapper = shallowMount(Home);
    // Obtenir un élement du dom avec un sélecteur CSS
    const title = wrapper.get("#title");
    // Obtenir le text de l'élément
    const text = title.text();
    // Le comparer avec le texte attendu
    expect(text).toMatch("Counter value: 0");
  });
});

Le test vérifie la valeur par défaut du compteur. Les commentaires du codes permettent de comprendre chaque étape.

Pour lancer le test, vue à préparé des commandes pour nous: yarn test:unit

Jest se lance et on obtient le résultat suivant:

 PASS  tests/unit/Home.spec.ts
  Home.vue
    ✓ renders counter value (16ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.138s
Ran all test suites.
Done in 2.92s.

Tester les évènements

Testons maintenant le clic des boutons increment:

// Créer un test pour les boutons
it.only("renders counter value after increment", async () => {
  // Monter le composant et y accéder avec la variable wrapper
  const wrapper = shallowMount(Home);
  // Obtenir le bouton d'incrément
  const increment = wrapper.get("#increment");
  // Simuler le clic
  await increment.trigger("click");
  // Accéder à un élement spécifique avec un sélecteur CSS
  const title = wrapper.get("#title");
  // Obtenir le texte de l'élement
  const text = title.text();
  // Comparer le texte à la valeur attendue
  expect(text).toMatch("Counter value: 1");
});

La commande await increment.trigger("click"); permet de simuler un click sur le boutton.

On remarque ici l'utilisateur du mot clé await. Cela est du au caractère asynchrone des évènements. Il faut aussi utiliser le mot clé async pour indiquer que la fonction est asynchrone:

it.only("renders counter value after increment", ...

Dans ce test j'ai ajouté .only derrière le it. Cela permet de ne lancer que ce test et d'ignorer les autres.

En lancant les tests on obtient le résultat suivant:

 PASS  tests/unit/Home.spec.ts
  Home.vue
    ✓ renders counter value after increment (17ms)
    ○ skipped renders counter value

Test Suites: 1 passed, 1 total
Tests:       1 skipped, 1 passed, 2 total
Snapshots:   0 total
Time:        2.192s
Ran all test suites.
Done in 2.97s.

Travailler avec les fonctions asynchrones

Travailler avec des fonctions asynchrones peut être délicat. Il faut attendre que la promesse soit résolu avant de comparer les valeurs attendues. Voici un exemple pour illustrer le problème.

Ajoutons deux boutons avec un setTimeout pour incrémenter 1s plus tard.

<template>
  <div class="home">
    <h1 id="title">Counter value: {{ counter }}</h1>
    <button id="increment" @click="increment">+ increment</button>
    <br />
    <button id="increment-later" @click="incrementLater">
      + increment later
    </button>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component({
  components: {},
})
export default class Home extends Vue {
  counter = 0;

  increment(): void {
    this.counter++;
  }

  async incrementLater(): Promise<void> {
    return new Promise((resolve) => {
      setTimeout(() => {
        this.counter++;
        resolve();
      }, 1000);
    });
  }
}
</script>

Puis ajoutons un test pour ce bouton:

// Créer un test pour les boutons
it("renders counter value after later increment", async () => {
  // Monter le composant et y accéder avec la variable wrapper
  const wrapper = shallowMount(Home);
  // Obtenir le bouton d'incrément
  const increment = wrapper.get("#increment-later");
  // Simuler le clic
  await increment.trigger("click");
  // Accéder à un élement spécifique avec un sélecteur CSS
  const title = wrapper.get("#title");
  // Obtenir le texte de l'élement
  const text = title.text();
  // Comparer le texte à la valeur attendue
  expect(text).toMatch("Counter value: 1");
});

On remarque que le test ne passe pas yarn test:unit.

 FAIL  tests/unit/Home.spec.ts
  Home.vue
    ✕ renders counter value after increment (21ms)
    ○ skipped renders counter value
    ○ skipped renders counter value after increment

  ● Home.vue › renders counter value after increment

    expect(received).toMatch(expected)

    Expected substring: "Counter value: 1"
    Received string:    "Counter value: 0"

      47 |     const text = title.text();
      48 |     // Compare it to the expected text
    > 49 |     expect(text).toMatch("Counter value: 1");
         |                  ^
      50 |   });
      51 | });
      52 |

      at Object.<anonymous> (tests/unit/Home.spec.ts:49:18)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 skipped, 3 total
Snapshots:   0 total
Time:        2.245s
Ran all test suites.

En effet, le test opère avant que la promesse soit résolu. Donc le dom n'est pas encore mis à jour au moment de la vérification. Dans ce cas on peut utiliser des fonctions utilitaires pour attendre la résolution des promesses.

Dans notre cas, nous allons utiliser une fonction de jest qui permet de faire un mock sur les fonctions de temps comme setTimeout.

Ajouter la ligne suivante au début des tests:

jest.useFakeTimers();

Cela dit à Jest de remplacer les fonctions de temps pour nos tests.

Ensuite ajoutez les lignes suivantes avant de récupérer le texte du DOM:

// Demande à Jest d'executer tous les timers en attente
jest.runOnlyPendingTimers();
// Attend que Vue est mis à jour le DOM
await wrapper.vm.$nextTick();

Le code final est le suivant:

// Créer un test pour les boutons
it("renders counter value after later increment", async () => {
  // Monter le composant et y accéder avec la variable wrapper
  const wrapper = shallowMount(Home);
  // Obtenir le bouton d'incrément
  const increment = wrapper.get("#increment-later");
  // Simuler le clic
  await increment.trigger("click");
  // Demande à Jest d'executer tous les timers en attente
  jest.runOnlyPendingTimers();
  // Attend que Vue est mis à jour le DOM
  await wrapper.vm.$nextTick();
  // Accéder à un élement spécifique avec un sélecteur CSS
  const title = wrapper.get("#title");
  // Obtenir le texte de l'élement
  const text = title.text();
  // Comparer le texte à la valeur attendue
  expect(text).toMatch("Counter value: 1");
});

Utiliser le store Vuex

Le store peut utiliser des fonctions asynchrones (des actions). Dans ce contexte, il faut implémenter les tests de manière spécifique pour les tester correctements. On va donc 'mocker' le store:

Modifiez le composant Home.vue:

<template>
  <div class="home">
    <h1 id="title">Counter value: {{ counter }}</h1>
    <button id="increment" @click="increment">+ increment</button>
    <br />
    <button id="increment-later" @click="incrementLater">
      + increment later
    </button>
    <button id="increment-store" @click="incrementStore">
      + increment store
    </button>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component({
  components: {},
})
export default class Home extends Vue {
  counter = 0;

  increment(): void {
    this.counter++;
  }

  async incrementLater(): Promise<void> {
    return new Promise((resolve) => {
      setTimeout(() => {
        this.counter++;
        resolve();
      }, 1000);
    });
  }

  incrementStore(): void {
    this.$store.dispatch("increment");
  }
}
</script>

On y a ajouté un bouton increment store et une méthode pour appelé l'action increment du store.

Assuez-vous d'ajouter ces lignes en haut du fichier de test:

import Vuex from "vuex";
import { createLocalVue } from '@vue/test-utils'
const localVue = createLocalVue();
localVue.use(Vuex);
// Créer un test pour le store
it.only("renders counter value after increment", async () => {
  // Create a mock store
  // Créer un store mocké
  const actions = {
    increment: jest.fn(),
  };
  const store = new Vuex.Store({
    actions,
  });
  // Monter le composant et y accéder avec le wrapper
  const wrapper = shallowMount(Home, { store, localVue });
  // Obtenir le bouton pour incrementer
  const increment = wrapper.get("#increment-store");
  // Simuler le clic
  await increment.trigger("click");
  expect(actions.increment).toHaveBeenCalled();
});

Ces lignes indiquent qu'il faut utiliser Vuex mais avec un objet local de vue plutôt que l'objet global. Dans ce test, ce n'est pas le résultat du clic qui est testé mais le fait que le clic appelle bien le store

Conclusion

Les différents cas d'utilisation suivants on été vue: vérification des textes du DOM, évènements, fonctions asynchrones, mocking du store Vuex.

Pour plus d'information vous pouvez vous référer à la documentation officiel.

top