Cours Vue.js · Module 12: Tests et déploiement

Tester les composants Vue

8 min de lecture
Dans cette leçon9

Bienvenue sur la plateforme de lancement du NOVA LAB ! Année 2087 - avant d'envoyer des systèmes vers Mars, chaque morceau de code doit passer des tests rigoureux. Tout comme les systèmes d'une fusée sont vérifiés avant le décollage, nous testons nos applications Vue avant leur déploiement.

Tu as vérifié le panneau de télémétrie à la main : chaque bouton fonctionne. Dans une semaine, quelqu'un de l'équipe corrigera un composant, et le bouton « + » cessera de fonctionner à un endroit où plus personne ne clique. La vérification manuelle ne passe pas à l'échelle. Un test automatisé, c'est une procédure de pré-lancement écrite en code - tu la lances d'une seule commande avant chaque décollage.

Ce que tu vas apprendre

Sur la plateforme de lancement, tu vas préparer l'application pour le vol :

  • écrire des tests de composants avec Vitest et Vue Test Utils,
  • tester les composables et les stores Pinia sans monter l'interface,
  • vérifier la coopération des modules dans des tests d'intégration et E2E,
  • construire la version de production et la déployer sur un hébergement,
  • automatiser tout le processus dans un pipeline CI/CD.

Pourquoi tester, et quels types de tests existent

Les tests garantissent la qualité du code, détectent les bugs tôt et facilitent le refactoring : tu modifies l'intérieur d'un composant, et des tests au vert confirment que son comportement n'a pas changé. Les types de tests forment une pyramide. À la base se trouvent les tests unitaires d'une fonction ou d'un composant - les plus rapides et les moins coûteux, donc les plus nombreux. Au-dessus viennent les tests d'intégration, qui vérifient plusieurs parties à la fois. Au sommet se trouvent les tests de bout en bout (E2E), qui cliquent dans la vraie application dans un navigateur - les plus lents et les plus coûteux, donc quelques-uns pour les parcours les plus importants suffisent.

Vitest - le framework de test

Vitest est un framework de test moderne pour Vue, recommandé par la documentation de Vue pour les projets basés sur Vite :

  • Rapide comme Vite
  • Compatible avec Jest
  • ESM natif
  • TypeScript prêt à l'emploi

Sa rapidité vient de ce que Vitest utilise la même configuration et le même traitement des fichiers que Vite, il comprend donc directement les fichiers .vue, et son API compatible avec Jest rend describe, it et expect familiers.

Installation

À Vitest, on ajoute deux paquets : Vue Test Utils (@vue/test-utils), la bibliothèque officielle pour monter et tester des composants Vue de manière isolée, et happy-dom, qui simule le DOM d'un navigateur dans Node.js :

1npm install -D vitest @vue/test-utils happy-dom

L'option -D enregistre les paquets dans devDependencies - les outils du laboratoire n'iront pas dans le build de production. Un projet Vite dispose déjà du plugin @vitejs/plugin-vue.

Configuration

Dans le fichier vitest.config.js, la section test active globals: true, grâce à quoi describe, it et expect fonctionnent sans import, ainsi que environment: 'happy-dom', le DOM simulé :

1// vitest.config.js
2import { defineConfig } from 'vitest/config'
3import vue from '@vitejs/plugin-vue'
4
5export default defineConfig({
6  plugins: [vue()],
7  test: {
8    globals: true,
9    environment: 'happy-dom'
10  }
11})

Tu peux aussi ajouter la section test dans vite.config.js ; quand les deux fichiers existent, c'est vitest.config.js qui l'emporte. Il reste les scripts de package.json :

1// package.json
2{
3  "scripts": {
4    "test": "vitest",
5    "test:ui": "vitest --ui",
6    "test:coverage": "vitest --coverage"
7  }
8}

La commande vitest fonctionne en mode surveillance et relance les tests après chaque modification, tandis qu'en CI elle les exécute une seule fois. vitest --ui ouvre un tableau de bord dans le navigateur et nécessite le paquet @vitest/ui. vitest --coverage mesure la couverture de tests, c'est-à-dire le pourcentage d'instructions, de branches, de fonctions et de lignes du code source exécutées par les tests ; il faut pour cela le paquet @vitest/coverage-v8.

Composant testé

Nous allons tester un simple compteur avec des boutons + et - et une prop initialCount :

1<!-- Counter.vue -->
2<template>
3  <div class="counter">
4    <p>Count: {{ count }}</p>
5    <button class="increment" @click="count++">+</button>
6    <button class="decrement" @click="count--">-</button>
7  </div>
8</template>
9
10<script setup>
11import { ref } from 'vue'
12
13const props = defineProps({
14  initialCount: {
15    type: Number,
16    default: 0
17  }
18})
19
20const count = ref(props.initialCount)
21</script>

Le compteur garde son état dans un ref, et la prop n'est qu'une valeur de départ. Les classes des boutons serviront de sélecteurs aux tests.

Premier test

Un test se construit avec trois fonctions de Vitest : describe regroupe les cas sous un nom commun, it décrit un seul cas, et expect pose une condition sur le résultat à l'aide d'un matcher, par exemple toContain. La fonction mount de Vue Test Utils monte le composant et renvoie un wrapper - un objet grâce auquel tu lis le texte avec la méthode text() et cherches des éléments avec la méthode find() :

1// components/Counter.spec.js
2import { describe, it, expect } from 'vitest'
3import { mount } from '@vue/test-utils'
4import Counter from './Counter.vue'
5
6describe('Counter', () => {
7  it('renders count', () => {
8    const wrapper = mount(Counter)
9    expect(wrapper.text()).toContain('Count: 0')
10  })

Le test monte le compteur sans props et vérifie son texte - sans navigateur, dans un DOM simulé. Le deuxième cas simule un clic : find prend un sélecteur CSS, et trigger('click') envoie l'événement au bouton trouvé :

1  // Counter.spec.js - suite du describe('Counter')
2  it('increments count on button click', async () => {
3    const wrapper = mount(Counter)
4
5    await wrapper.find('button.increment').trigger('click')
6
7    expect(wrapper.text()).toContain('Count: 1')
8  })

Le mot-clé await est indispensable : Vue met à jour le DOM de manière asynchrone, et trigger renvoie une promesse qui ne se résout qu'après le nouveau rendu. Sans lui, l'assertion lirait encore l'ancien texte Count: 0. Le troisième cas transmet une prop dans le champ props de l'objet d'options, le deuxième argument de mount :

1  // Counter.spec.js - suite du describe('Counter')
2  it('accepts initial count prop', () => {
3    const wrapper = mount(Counter, {
4      props: {
5        initialCount: 10
6      }
7    })
8
9    expect(wrapper.text()).toContain('Count: 10')
10  })
11})

La dernière parenthèse ferme le bloc describe. Remarque ce que le test ne vérifie pas : il ne regarde pas la variable count, seulement le texte que voit l'utilisateur, donc il survit à un changement d'implémentation.

Tester les événements

Les composants émettent aussi des événements. La méthode emitted() renvoie un objet contenant tous les événements émis, et emitted('select') le tableau des émissions d'un seul événement, dans lequel chaque émission est un tableau d'arguments. Un simple wrapper.trigger clique sur l'élément racine du composant :

1import { mount } from '@vue/test-utils'
2import ArtworkCard from './ArtworkCard.vue'
3
4describe('ArtworkCard', () => {
5  it('emits select event on click', async () => {
6    const artwork = {
7      id: 1,
8      title: 'Mona Lisa',
9      artist: 'Leonardo'
10    }
11
12    const wrapper = mount(ArtworkCard, {
13      props: { artwork }
14    })
15
16    await wrapper.trigger('click')
17
18    expect(wrapper.emitted()).toHaveProperty('select')
19    expect(wrapper.emitted('select')[0]).toEqual([artwork])
20  })
21
22  it('emits favorite event on heart click', async () => {
23    const wrapper = mount(ArtworkCard, {
24      props: {
25        artwork: { id: 1, title: 'Test' }
26      }
27    })
28
29    await wrapper.find('.favorite-btn').trigger('click')
30
31    expect(wrapper.emitted('favorite')).toBeTruthy()
32  })
33})

L'expression emitted('select')[0] désigne la première émission, et le matcher toEqual compare son contenu, et non l'identité de l'objet - nous y reviendrons dans la prochaine leçon. Quand un événement n'a jamais été émis, emitted('favorite') renvoie undefined, donc toBeTruthy() vérifie qu'il a bien eu lieu. L'absence d'import de describe, it et expect ne pose pas de problème grâce à globals: true.

Tester les slots

Tu transmets le contenu des slots dans l'option slots : la clé est le nom du slot, et la valeur un fragment HTML. Un slot sans nom s'appelle default :

1import { mount } from '@vue/test-utils'
2import BaseCard from './BaseCard.vue'
3
4describe('BaseCard', () => {
5  it('renders default slot', () => {
6    const wrapper = mount(BaseCard, {
7      slots: {
8        default: '<p>Test content</p>'
9      }
10    })
11
12    expect(wrapper.html()).toContain('Test content')
13  })
14
15  it('renders named slots', () => {
16    const wrapper = mount(BaseCard, {
17      slots: {
18        header: '<h2>Header</h2>',
19        default: '<p>Body</p>',
20        footer: '<footer>Footer</footer>'
21      }
22    })
23
24    expect(wrapper.find('h2').text()).toBe('Header')
25    expect(wrapper.find('p').text()).toBe('Body')
26    expect(wrapper.find('footer').text()).toBe('Footer')
27  })
28})

Le premier cas vérifie tout le HTML avec la méthode html(), le second compare le texte d'éléments précis avec le matcher toBe. Le composant BaseCard ne sait pas qu'il tourne dans un test - il reçoit les slots exactement comme d'un parent.

Mon conseil : teste le comportement visible de l'extérieur - le texte, les événements et les slots - et non les variables internes du composant, car un tel test survit au refactoring. Dans la prochaine leçon, tu descendras sous le plancher du panneau pour tester les composables et les stores Pinia.

Retiens : un test, c'est une procédure de pré-lancement écrite en code - chaque module la passe avant de s'envoler vers Mars.

Code de cette leçon : App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - Démo de configuration du build
5const buildConfig = ref({
6  mode: 'production',
7  minify: true,
8  sourceMap: false,
9  chunkSize: 500,
10  compression: 'gzip'
11})
12
13const buildStats = computed(() => {
14  let size = 1200 // taille de base en Ko
15  if (buildConfig.value.minify) size *= 0.6
16  if (buildConfig.value.compression === 'gzip') size *= 0.3
17  if (buildConfig.value.compression === 'brotli') size *= 0.25
18  return Math.round(size)
19})
20
21const optimizationScore = computed(() => {
22  let score = 0
23  if (buildConfig.value.mode === 'production') score += 25
24  if (buildConfig.value.minify) score += 25
25  if (!buildConfig.value.sourceMap) score += 20
26  if (buildConfig.value.chunkSize <= 500) score += 15
27  if (buildConfig.value.compression !== 'none') score += 15
28  return score
29})
30
31function toggleMinify() {
32  buildConfig.value.minify = !buildConfig.value.minify
33}
34
35function toggleSourceMap() {
36  buildConfig.value.sourceMap = !buildConfig.value.sourceMap
37}
38
39function setCompression(type) {
40  buildConfig.value.compression = type
41}
42</script>
43
44<template>
45  <div class="launch-control">
46    <div class="header">
47      <h1>NOVA LAB - Configuration du build</h1>
48      <p>Zone de lancement</p>
49    </div>
50
51    <div class="config-panel">
52      <h2>Paramètres du build</h2>
53
54      <div class="setting">
55        <label>
56          <input type="checkbox" v-model="buildConfig.minify" @change="toggleMinify">
57          Minification (compression activée)
58        </label>
59      </div>
60
61      <div class="setting">
62        <label>
63          <input type="checkbox" v-model="buildConfig.sourceMap" @change="toggleSourceMap">
64          Source maps (mode débogage)
65        </label>
66      </div>
67
68      <div class="setting">
69        <label>Type de compression :</label>
70        <select v-model="buildConfig.compression">
71          <option value="none">Aucune</option>
72          <option value="gzip">GZIP</option>
73          <option value="brotli">Brotli</option>
74        </select>
75      </div>
76
77      <div class="setting">
78        <label>Limite de taille des chunks (Ko) :</label>
79        <input type="range" v-model.number="buildConfig.chunkSize" min="100" max="1000" step="50">
80        <span>{{ buildConfig.chunkSize }} Ko</span>
81      </div>
82    </div>
83
84    <div class="stats-panel">
85      <h2>Statistiques du build</h2>
86      <div class="stat">
87        <span>Taille du bundle :</span>
88        <strong :class="{ optimal: buildStats < 400 }">{{ buildStats }} Ko</strong>
89      </div>
90      <div class="stat">
91        <span>Score d'optimisation :</span>
92        <strong :class="{ optimal: optimizationScore >= 80 }">{{ optimizationScore }}/100</strong>
93      </div>
94      <div class="status" :class="optimizationScore >= 80 ? 'ready' : 'warning'">
95        {{ optimizationScore >= 80 ? 'Prêt pour le lancement !' : 'Optimisation nécessaire' }}
96      </div>
97    </div>
98  </div>
99</template>
100
101<style scoped>
102.launch-control {
103  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
104  color: #00ff88;
105  padding: 2rem;
106  min-height: 100vh;
107  font-family: 'Courier New', monospace;
108}
109
110.header {
111  text-align: center;
112  margin-bottom: 2rem;
113  border-bottom: 2px solid #00b4d8;
114  padding-bottom: 1rem;
115}
116
117.header h1 {
118  color: #00ff88;
119  text-shadow: 0 0 10px #00ff88;
120  margin: 0;
121}
122
123.header p {
124  color: #00b4d8;
125  margin: 0.5rem 0 0 0;
126}
127
128.config-panel, .stats-panel {
129  background: rgba(0, 180, 216, 0.1);
130  border: 1px solid #00b4d8;
131  border-radius: 8px;
132  padding: 1.5rem;
133  margin-bottom: 1.5rem;
134}
135
136.config-panel h2, .stats-panel h2 {
137  color: #00b4d8;
138  margin-top: 0;
139  font-size: 1.2rem;
140}
141
142.setting {
143  margin-bottom: 1rem;
144  padding: 0.75rem;
145  background: rgba(0, 255, 136, 0.05);
146  border-radius: 4px;
147}
148
149.setting label {
150  display: flex;
151  align-items: center;
152  gap: 0.5rem;
153  color: #00ff88;
154}
155
156.setting input[type="checkbox"] {
157  width: 20px;
158  height: 20px;
159}
160
161.setting select, .setting input[type="range"] {
162  margin-left: 0.5rem;
163  padding: 0.5rem;
164  background: #0a0e27;
165  border: 1px solid #00b4d8;
166  color: #00ff88;
167  border-radius: 4px;
168}
169
170.stat {
171  display: flex;
172  justify-content: space-between;
173  padding: 0.75rem;
174  margin-bottom: 0.5rem;
175  background: rgba(0, 255, 136, 0.05);
176  border-radius: 4px;
177}
178
179.stat strong {
180  color: #fff;
181  font-size: 1.2rem;
182}
183
184.stat strong.optimal {
185  color: #00ff88;
186  text-shadow: 0 0 5px #00ff88;
187}
188
189.status {
190  text-align: center;
191  padding: 1rem;
192  margin-top: 1rem;
193  border-radius: 4px;
194  font-size: 1.1rem;
195  font-weight: bold;
196}
197
198.status.ready {
199  background: rgba(0, 255, 136, 0.2);
200  color: #00ff88;
201  border: 2px solid #00ff88;
202}
203
204.status.warning {
205  background: rgba(255, 165, 0, 0.2);
206  color: #ffa500;
207  border: 2px solid #ffa500;
208}
209</style>

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Pourquoi les tests sont-ils essentiels dans les applications Vue ?

  2. 2. Pourquoi Vitest est-il le framework de test recommandé pour Vue + Vite ?

Voici 2 des 5 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Complète le fichier de configuration vitest.config.js pour paramétrer correctement l'environnement de test Vue.

  • Clique dans l'ordre

    Ordonne les éléments de la structure d'un test dans le bon ordre :

  • Éditeur de code

    Écris un test qui vérifie que le composant Counter s'affiche correctement et réagit à un clic.

  • Tri vertical

    Ordonne dans l'ordre les étapes de l'écriture d'un test de composant Vue :

  • Tri horizontal

    Ordonne la syntaxe pour monter un composant avec des props dans un test :

  • Éditeur de code

    Écris un test qui vérifie que le composant émet un événement 'select' au clic et transmet les données de l'œuvre.

  • Tri vertical

    Ordonne les types de tests du plus rapide et le moins coûteux au plus lent et le plus coûteux :

  • Clique dans l'ordre

    Ordonne le code qui simule un clic sur un bouton dans un test :