Cours Vue.js · Module 12: Tests et déploiement
Tests d'intégration et E2E
Dans cette leçon5
Les tests d'intégration vérifient la coopération des systèmes - comme les systèmes d'une fusée doivent coopérer avant le lancement vers Mars.
Chaque sous-ensemble du panneau a déjà passé ses essais de manière isolée : le compteur, le composable, le store des archives. Cela ne garantit pourtant pas qu'ils fonctionneront ensemble. Un composant peut lire dans le store un champ sous un autre nom que celui exposé par le store, et le router peut ne pas afficher la vue vers laquelle mène un lien. Aucun test unitaire ne détectera ces pannes, car elles n'apparaissent qu'aux jonctions. C'est pourquoi nous réassemblons maintenant les sous-ensembles.
Intégration contre tests unitaires
Un test unitaire vérifie une seule unité de manière isolée, et un test d'intégration la coopération de plusieurs parties à la fois : un composant avec un store, un composant avec un router, un parent avec ses enfants. C'est là qu'intervient la différence entre deux fonctions de Vue Test Utils. mount() rend l'arbre complet des composants avec les enfants, tandis que shallowMount() remplace les enfants par des stubs, qui apparaissent dans le HTML sous la forme <child-stub>. Dans un test d'intégration, tu utilises mount(), car les enfants doivent vraiment fonctionner.
Tests d'intégration de composants
Le premier test vérifie que la galerie affiche les données d'un store Pinia. L'option global.plugins installe des plugins dans l'application de test, comme le fait app.use() dans main.js :
1// ArtworkGallery.spec.js
2import { describe, it, expect, beforeEach } from 'vitest'
3import { mount } from '@vue/test-utils'
4import { createPinia, setActivePinia } from 'pinia'
5import ArtworkGallery from './ArtworkGallery.vue'
6import { useGalleryStore } from '@/stores/gallery'
7
8describe('ArtworkGallery Integration', () => {
9 beforeEach(() => {
10 setActivePinia(createPinia())
11 })
12
13 it('displays artworks from store', async () => {
14 const wrapper = mount(ArtworkGallery, {
15 global: {
16 plugins: [createPinia()]
17 }
18 })
19
20 const store = useGalleryStore()
21
22 store.artworks = [
23 { id: 1, title: 'Mona Lisa', artist: 'Leonardo' },
24 { id: 2, title: 'David', artist: 'Michelangelo' }
25 ]
26
27 await wrapper.vm.$nextTick()
28
29 const cards = wrapper.findAll('.artwork-card')
30 expect(cards).toHaveLength(2)
31 })Installer Pinia via global.plugins fait de cette instance l'instance active, donc useGalleryStore() dans le test et dans le composant atteignent le même store. Après avoir modifié les données, on attend $nextTick(), car Vue refait le rendu du DOM de manière asynchrone. findAll renvoie la liste des éléments correspondants, et toHaveLength(2) vérifie leur nombre. Le deuxième cas touche trois couches à la fois : le champ de recherche, le getter de filtrage du store et la liste des cartes. La méthode setValue saisit du texte dans le champ et envoie un événement input, exactement comme le ferait un technicien :
1 // ArtworkGallery.spec.js - suite du describe
2 it('filters artworks on search input', async () => {
3 const wrapper = mount(ArtworkGallery, {
4 global: {
5 plugins: [createPinia()]
6 }
7 })
8
9 const store = useGalleryStore()
10 store.artworks = [
11 { id: 1, title: 'Mona Lisa', artist: 'Leonardo' },
12 { id: 2, title: 'David', artist: 'Michelangelo' }
13 ]
14
15 await wrapper.vm.$nextTick()
16
17 const searchInput = wrapper.find('input[type="search"]')
18 await searchInput.setValue('mona')
19
20 await wrapper.vm.$nextTick()
21
22 const cards = wrapper.findAll('.artwork-card')
23 expect(cards).toHaveLength(1)
24 expect(cards[0].text()).toContain('Mona Lisa')
25 })
26})Le test n'appelle pas le getter directement ; il passe par l'interface, donc il vérifie aussi que le template est réellement branché sur le store. Le store lui-même n'a été remplacé par rien - c'est le même code qui partira vers Mars.
Intégration du router
Un composant avec <RouterView> a besoin d'un router qui fonctionne. Dans un test, tu le crées avec la fonction createRouter et l'historique createMemoryHistory, qui garde l'adresse en mémoire au lieu de la barre d'adresse du navigateur, puis tu montes l'application avec le router dans global.plugins :
1import { mount } from '@vue/test-utils'
2import { createRouter, createMemoryHistory } from 'vue-router'
3import App from './App.vue'
4
5describe('App with Router', () => {
6 it('navigates to gallery page', async () => {
7 const router = createRouter({
8 history: createMemoryHistory(),
9 routes: [
10 { path: '/', component: { template: '<div>Home</div>' } },
11 { path: '/gallery', component: { template: '<div>Gallery</div>' } }
12 ]
13 })
14
15 const wrapper = mount(App, {
16 global: {
17 plugins: [router]
18 }
19 })
20
21 await router.push('/gallery')
22 await router.isReady()
23
24 expect(wrapper.html()).toContain('Gallery')
25 })
26})router.push renvoie une promesse de navigation, et router.isReady() attend la fin de la navigation initiale, c'est pourquoi les deux appels ont un await. Des vues de remplacement écrites comme un objet avec un champ template suffisent dans un test ; dans une application construite par Vite, tu utiliseras des fichiers .vue, car la version navigateur par défaut de Vue ne compile pas les templates à la volée.
Tests E2E (Cypress/Playwright)
Les tests E2E travaillent un étage plus haut : ils lancent un vrai navigateur, ouvrent l'application à son adresse et cliquent comme un humain. Tu ne montes pas de composants ici - tu visites la page. La documentation de Vue recommande pour cela Playwright et Cypress, et l'outil de création create-vue propose Playwright dès la création du projet. Voici à quoi ressemble un scénario dans Cypress :
1// cypress/e2e/gallery.cy.js
2describe('Gallery E2E', () => {
3 beforeEach(() => {
4 cy.visit('/')
5 })
6
7 it('displays artworks', () => {
8 cy.get('.artwork-card').should('have.length.greaterThan', 0)
9 })
10
11 it('filters artworks by search', () => {
12 cy.get('input[type="search"]').type('mona')
13
14 cy.get('.artwork-card').should('have.length', 1)
15 cy.get('.artwork-card').first().should('contain', 'Mona Lisa')
16 })cy.visit('/') ouvre la page par rapport à l'adresse de base (baseUrl) de la configuration de Cypress. Les assertions should se répètent d'elles-mêmes jusqu'à ce que la condition soit remplie ou que le délai expire, donc tu n'attends pas le rendu à la main. Les cas suivants poursuivent le parcours de l'opérateur - vers le détail de l'œuvre et les favoris :
1 // cypress/e2e/gallery.cy.js - suite du describe
2 it('opens artwork detail on click', () => {
3 cy.get('.artwork-card').first().click()
4
5 cy.url().should('include', '/artwork/')
6 cy.get('.artwork-detail').should('be.visible')
7 })
8
9 it('adds artwork to favorites', () => {
10 cy.get('.artwork-card').first().find('.favorite-btn').click()
11
12 cy.get('.favorites-count').should('contain', '1')
13 })
14})Le test ne connaît aucun composant, aucun store ni aucune route - il voit seulement ce que voit l'utilisateur : l'adresse, les éléments et leur texte.
Exemple avec Playwright
Le même scénario dans Playwright. Chaque test reçoit un objet page, un onglet du navigateur, et les étapes sont toujours les mêmes : ouvrir l'application (page.goto), trouver un élément (page.locator), effectuer une action (click, fill) et vérifier le résultat (expect) :
1// tests/gallery.spec.js
2import { test, expect } from '@playwright/test'
3
4test.describe('Gallery', () => {
5 test('should display artworks', async ({ page }) => {
6 await page.goto('/')
7
8 const cards = page.locator('.artwork-card')
9 await expect(cards).toHaveCount(12)
10 })
11
12 test('should filter artworks', async ({ page }) => {
13 await page.goto('/')
14
15 await page.locator('input[type="search"]').fill('leonardo')
16
17 const cards = page.locator('.artwork-card')
18 await expect(cards).toHaveCount(2)
19 })Les méthodes page.fill() et page.click() qui prennent un sélecteur en argument existent toujours, mais la documentation de Playwright les marque comme déconseillées et recommande les actions sur les locators, comme locator.fill(). Les assertions telles que toHaveCount attendent d'elles-mêmes que la condition soit remplie. Le troisième test passe au détail : first() choisit le premier élément correspondant, et toHaveURL vérifie l'adresse avec une expression régulière :
1 // tests/gallery.spec.js - suite du test.describe
2 test('should navigate to artwork detail', async ({ page }) => {
3 await page.goto('/')
4
5 await page.locator('.artwork-card').first().click()
6
7 await expect(page).toHaveURL(/.*artwork\/\d+/)
8 await expect(page.locator('.artwork-detail')).toBeVisible()
9 })
10})L'assertion toBeVisible confirme que le panneau de détail est réellement visible. Le test de fumée le plus court vérifie le titre de l'onglet avec l'assertion toHaveTitle :
1test('shows the gallery title', async ({ page }) => {
2 await page.goto('/')
3 await expect(page).toHaveTitle('Gallery')
4})Le titre vient de l'élément <title> de index.html, donc quand ce test échoue, l'application n'a très probablement pas démarré du tout. Tous les tests appellent page.goto('/') avec un chemin relatif, ce qui ne fonctionne qu'avec une adresse de base dans playwright.config.js :
1// playwright.config.js
2import { defineConfig } from '@playwright/test'
3
4export default defineConfig({
5 use: {
6 baseURL: 'http://localhost:4173'
7 },
8 webServer: {
9 command: 'npm run build && npm run preview',
10 url: 'http://localhost:4173'
11 }
12})La section webServer demande à Playwright de construire lui-même l'application et de lancer vite preview, dont le port par défaut est 4173 - tu testes donc exactement la charge utile qui partira en production.
Mon conseil : respecte les proportions de la pyramide - des dizaines de tests unitaires, quelques tests d'intégration pour les connexions clés et une poignée de tests E2E pour les parcours sans lesquels la mission perd son sens. Le rapport de couverture de Vitest t'indiquera quelles parties du code aucun test n'a encore visitées. Quand tout est au vert, il reste à construire la charge utile à expédier - nous nous en occuperons dans la prochaine leçon.
Retiens : les tests unitaires vérifient les vannes, les tests d'intégration les installations complètes, et l'E2E est un vol d'essai de toute la fusée.
Code de cette leçon : App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - Checklist du build de production
5const checklist = ref([
6 { id: 1, task: 'Supprimer les appels console.log', completed: false, critical: true },
7 { id: 2, task: 'Activer le mode production', completed: false, critical: true },
8 { id: 3, task: 'Optimiser les images et les assets', completed: false, critical: false },
9 { id: 4, task: 'Activer HTTPS/SSL', completed: false, critical: true },
10 { id: 5, task: 'Configurer le suivi des erreurs', completed: false, critical: false },
11 { id: 6, task: 'Configurer le CDN', completed: false, critical: false },
12 { id: 7, task: 'Activer la compression (gzip/brotli)', completed: false, critical: true },
13 { id: 8, task: 'Tester sur plusieurs appareils', completed: false, critical: false },
14 { id: 9, task: 'Mettre en place le monitoring', completed: false, critical: false },
15 { id: 10, task: 'Configurer les en-têtes de cache', completed: false, critical: true },
16 { id: 11, task: 'Vérifier les en-têtes de sécurité', completed: false, critical: true },
17 { id: 12, task: 'Sauvegarder les données de production', completed: false, critical: true }
18])
19
20const completedCount = computed(() => {
21 return checklist.value.filter(item => item.completed).length
22})
23
24const criticalCompleted = computed(() => {
25 const critical = checklist.value.filter(item => item.critical)
26 const completed = critical.filter(item => item.completed)
27 return completed.length
28})
29
30const criticalTotal = computed(() => {
31 return checklist.value.filter(item => item.critical).length
32})
33
34const progress = computed(() => {
35 return Math.round((completedCount.value / checklist.value.length) * 100)
36})
37
38const isReadyForLaunch = computed(() => {
39 return criticalCompleted.value === criticalTotal.value
40})
41
42function toggleTask(id) {
43 const task = checklist.value.find(item => item.id === id)
44 if (task) {
45 task.completed = !task.completed
46 }
47}
48
49function resetChecklist() {
50 checklist.value.forEach(item => {
51 item.completed = false
52 })
53}
54</script>
55
56<template>
57 <div class="checklist-app">
58 <div class="header">
59 <h1>Checklist avant lancement</h1>
60 <p>Déploiement en production NOVA LAB</p>
61 </div>
62
63 <div class="progress-panel">
64 <div class="progress-bar">
65 <div class="progress-fill" :style="{ width: progress + '%' }"></div>
66 </div>
67 <div class="progress-stats">
68 <span>Total : {{ completedCount }}/{{ checklist.length }}</span>
69 <span>Critiques : {{ criticalCompleted }}/{{ criticalTotal }}</span>
70 <span>{{ progress }}%</span>
71 </div>
72 </div>
73
74 <div class="launch-status" :class="isReadyForLaunch ? 'ready' : 'not-ready'">
75 <template v-if="isReadyForLaunch">
76 TOUS LES SYSTÈMES SONT PRÊTS ! Prêt pour le déploiement !
77 </template>
78 <template v-else>
79 {{ criticalTotal - criticalCompleted }} tâche(s) critique(s) restante(s)
80 </template>
81 </div>
82
83 <div class="checklist">
84 <div
85 v-for="item in checklist"
86 :key="item.id"
87 class="checklist-item"
88 :class="{
89 completed: item.completed,
90 critical: item.critical && !item.completed
91 }"
92 @click="toggleTask(item.id)"
93 >
94 <div class="checkbox">
95 <span v-if="item.completed">x</span>
96 </div>
97 <div class="task-content">
98 <span class="task-text">{{ item.task }}</span>
99 <span v-if="item.critical" class="badge">CRITIQUE</span>
100 </div>
101 </div>
102 </div>
103
104 <div class="actions">
105 <button @click="resetChecklist" class="reset-btn">
106 Réinitialiser la checklist
107 </button>
108 </div>
109 </div>
110</template>
111
112<style scoped>
113.checklist-app {
114 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
115 color: #00ff88;
116 padding: 2rem;
117 min-height: 100vh;
118 font-family: 'Courier New', monospace;
119}
120
121.header {
122 text-align: center;
123 margin-bottom: 2rem;
124 border-bottom: 2px solid #00b4d8;
125 padding-bottom: 1rem;
126}
127
128.header h1 {
129 color: #00ff88;
130 text-shadow: 0 0 10px #00ff88;
131 margin: 0;
132}
133
134.header p {
135 color: #00b4d8;
136 margin: 0.5rem 0 0 0;
137}
138
139.progress-panel {
140 background: rgba(0, 180, 216, 0.1);
141 border: 1px solid #00b4d8;
142 border-radius: 8px;
143 padding: 1.5rem;
144 margin-bottom: 1.5rem;
145}
146
147.progress-bar {
148 height: 30px;
149 background: rgba(0, 255, 136, 0.1);
150 border-radius: 15px;
151 overflow: hidden;
152 margin-bottom: 1rem;
153 border: 1px solid #00b4d8;
154}
155
156.progress-fill {
157 height: 100%;
158 background: linear-gradient(90deg, #00b4d8, #00ff88);
159 transition: width 0.5s ease;
160 box-shadow: 0 0 10px #00ff88;
161}
162
163.progress-stats {
164 display: flex;
165 justify-content: space-between;
166 color: #00b4d8;
167 font-size: 0.9rem;
168}
169
170.launch-status {
171 text-align: center;
172 padding: 1.5rem;
173 margin-bottom: 1.5rem;
174 border-radius: 8px;
175 font-size: 1.2rem;
176 font-weight: bold;
177}
178
179.launch-status.ready {
180 background: rgba(0, 255, 136, 0.2);
181 color: #00ff88;
182 border: 2px solid #00ff88;
183 box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
184}
185
186.launch-status.not-ready {
187 background: rgba(255, 165, 0, 0.2);
188 color: #ffa500;
189 border: 2px solid #ffa500;
190}
191
192.checklist {
193 display: flex;
194 flex-direction: column;
195 gap: 0.75rem;
196 margin-bottom: 2rem;
197}
198
199.checklist-item {
200 display: flex;
201 align-items: center;
202 gap: 1rem;
203 padding: 1rem;
204 background: rgba(0, 180, 216, 0.1);
205 border: 1px solid #00b4d8;
206 border-radius: 8px;
207 cursor: pointer;
208 transition: all 0.3s;
209}
210
211.checklist-item:hover {
212 background: rgba(0, 180, 216, 0.2);
213 transform: translateX(5px);
214}
215
216.checklist-item.completed {
217 background: rgba(0, 255, 136, 0.1);
218 border-color: #00ff88;
219}
220
221.checklist-item.critical {
222 border-color: #ffa500;
223 border-width: 2px;
224}
225
226.checkbox {
227 width: 24px;
228 height: 24px;
229 border: 2px solid #00b4d8;
230 border-radius: 4px;
231 display: flex;
232 align-items: center;
233 justify-content: center;
234 font-size: 1.2rem;
235 flex-shrink: 0;
236}
237
238.completed .checkbox {
239 background: #00ff88;
240 border-color: #00ff88;
241 color: #0a0e27;
242}
243
244.task-content {
245 flex: 1;
246 display: flex;
247 justify-content: space-between;
248 align-items: center;
249}
250
251.task-text {
252 color: #fff;
253}
254
255.completed .task-text {
256 text-decoration: line-through;
257 opacity: 0.7;
258}
259
260.badge {
261 background: #ffa500;
262 color: #0a0e27;
263 padding: 0.25rem 0.75rem;
264 border-radius: 12px;
265 font-size: 0.75rem;
266 font-weight: bold;
267}
268
269.actions {
270 text-align: center;
271}
272
273.reset-btn {
274 padding: 1rem 2rem;
275 background: rgba(0, 180, 216, 0.2);
276 border: 2px solid #00b4d8;
277 color: #00b4d8;
278 font-size: 1rem;
279 font-family: 'Courier New', monospace;
280 border-radius: 8px;
281 cursor: pointer;
282 transition: all 0.3s;
283}
284
285.reset-btn:hover {
286 background: #00b4d8;
287 color: #0a0e27;
288 box-shadow: 0 0 20px #00b4d8;
289}
290</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. En quoi les tests d'intégration diffèrent-ils des tests unitaires ?
2. Quelle est la différence entre mount() et shallowMount() dans Vue Test Utils ?
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Écris un test d'intégration pour un composant avec un store Pinia - vérifie que le composant affiche les données du store.
- Tri vertical
Ordonne dans l'ordre les étapes d'un test de bout en bout :
- Clique dans l'ordre
Ordonne le code Playwright qui ouvre une page et vérifie son titre :
- Éditeur de code
Écris un test Playwright qui vérifie le filtrage de la galerie - saisir du texte dans la recherche et vérifier les résultats.
- Tri horizontal
Ordonne le code pour monter un composant avec le router dans un test :