Cours Vue.js · Module 12: Tests et déploiement
Tester les composants Vue
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-domL'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. Pourquoi les tests sont-ils essentiels dans les applications Vue ?
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 :