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

Tests d'intégration et E2E

8 min de lecture
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. 1. En quoi les tests d'intégration diffèrent-ils des tests unitaires ?

  2. 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 :