Cours Vue.js · Module 9: Composables et VueUse
Tester les composables
Dans cette leçon6
Chaque système critique de la station NOVA LAB doit passer des tests rigoureux avant son déploiement. Il en va de même pour les composables - nous devons être sûrs qu'ils fonctionnent correctement dans tous les scénarios. Pense à l'échelle : un composable travaille dans dix composants, donc un seul bug en lui casse dix écrans à la fois. Un test écrit au niveau du composable attrape ce bug en une seule exécution, avant que l'équipage ne le voie.
Préparer l'environnement de test
Pour tester les composables, on utilise Vitest, l'outil de test de l'écosystème Vite, et des fonctions d'aide comme flushPromises viennent du paquet @vue/test-utils. La documentation de Vue distingue deux cas. Un composable qui n'utilise que ref, computed et watch peut simplement être appelé dans un test. Un composable avec des hooks de cycle de vie ou avec inject a besoin d'un composant hôte, et c'est une fonction d'aide qui le fournit en l'exécutant à l'intérieur d'un composant de test :
1// test-utils.ts
2import { createApp, defineComponent } from 'vue'
3
4// Aide : exécute un composable dans un composant isolé
5export function withSetup(composable) {
6 let result
7
8 const TestComponent = defineComponent({
9 setup() {
10 result = composable()
11 return () => {} // rendu vide
12 }
13 })
14
15 const app = createApp(TestComponent)
16 app.mount(document.createElement('div'))
17
18 return { result, app }
19}Le composant de test appelle le composable dans setup, rend une fonction vide et est monté dans un élément qui ne figure sur aucune page. document existe dans le test grâce à une simulation du DOM, donc dans la configuration de Vitest, règle test.environment sur 'happy-dom' ou 'jsdom'. Le app retourné servira au démontage.
Tester un composable simple
Commençons par un compteur. Dans le fichier de test, tu importes les fonctions de Vitest, le composable lui-même et la fonction d'aide :
1import { describe, it, expect } from 'vitest'
2import { ref } from 'vue'
3import { withSetup } from './test-utils'
4
5// Composable à tester
6function useCounter(initial = 0) {
7 const count = ref(initial)
8 const increment = () => count.value++
9 const decrement = () => count.value--
10 const reset = () => { count.value = initial }
11 return { count, increment, decrement, reset }
12}describe regroupe les tests d'un composable, chaque it est un scénario distinct, et expect(...).toBe(...) compare le résultat à la valeur attendue :
1describe('useCounter', () => {
2 it('démarre avec la valeur initiale', () => {
3 const { result } = withSetup(() => useCounter(10))
4 expect(result.count.value).toBe(10)
5 })
6
7 it('démarre à 0 par défaut', () => {
8 const { result } = withSetup(() => useCounter())
9 expect(result.count.value).toBe(0)
10 })
11
12 it('incrémente la valeur', () => {
13 const { result } = withSetup(() => useCounter())
14 result.increment()
15 expect(result.count.value).toBe(1)
16 result.increment()
17 expect(result.count.value).toBe(2)
18 })
19
20 it('décrémente la valeur', () => {
21 const { result } = withSetup(() => useCounter(5))
22 result.decrement()
23 expect(result.count.value).toBe(4)
24 })
25
26 it('réinitialise à la valeur initiale', () => {
27 const { result } = withSetup(() => useCounter(10))
28 result.increment()
29 result.increment()
30 result.reset()
31 expect(result.count.value).toBe(10)
32 })
33})Chaque test crée un compteur neuf, donc le résultat de l'un n'influence pas l'autre. Ce composable n'utilise que ref, il fonctionnerait donc aussi sans la fonction d'aide ; on l'utilise par cohérence avec les exemples suivants.
Mocker les dépendances réactives
Quand un composable dépend de sources de données externes, on les mocke. Le plus simple, c'est quand la dépendance arrive en paramètre - ici la fonction fetchFn :
1import { describe, it, expect, vi } from 'vitest'
2import { ref, nextTick } from 'vue'
3import { withSetup } from './test-utils'
4
5// Composable avec une dépendance fetch
6function useSensorData(fetchFn) {
7 const data = ref(null)
8 const error = ref(null)
9 const loading = ref(false)
10
11 async function load(sensorId) {
12 loading.value = true
13 error.value = null
14 try {
15 data.value = await fetchFn(sensorId)
16 } catch (e) {
17 error.value = e.message
18 } finally {
19 loading.value = false
20 }
21 }
22
23 return { data, error, loading, load }
24}vi.fn() crée une fausse fonction qui mémorise ses appels. mockResolvedValue lui fait retourner une promesse résolue avec l'objet donné, et mockRejectedValue - une promesse rejetée avec une erreur :
1describe('useSensorData', () => {
2 it('charge les données du serveur', async () => {
3 // Mock de la fonction fetch
4 const mockFetch = vi.fn().mockResolvedValue({
5 id: 'T-01',
6 temperature: 22,
7 status: 'active'
8 })
9
10 const { result } = withSetup(() => useSensorData(mockFetch))
11
12 expect(result.loading.value).toBe(false)
13
14 await result.load('T-01')
15
16 expect(mockFetch).toHaveBeenCalledWith('T-01')
17 expect(result.data.value).toEqual({
18 id: 'T-01',
19 temperature: 22,
20 status: 'active'
21 })
22 expect(result.loading.value).toBe(false)
23 })
24
25 it('gère les erreurs', async () => {
26 const mockFetch = vi.fn().mockRejectedValue(
27 new Error('Serveur indisponible')
28 )
29
30 const { result } = withSetup(() => useSensorData(mockFetch))
31
32 await result.load('T-01')
33
34 expect(result.error.value).toBe('Serveur indisponible')
35 expect(result.data.value).toBeNull()
36 })
37})toHaveBeenCalledWith vérifie les arguments de l'appel, et toEqual compare les objets champ par champ, car toBe exigerait la même référence. Aucun test n'a touché un vrai serveur.
Tester des composables avec watch
C'est ici que se cache une différence importante. computed est recalculé de façon synchrone au moment de la lecture, tandis que Vue reporte le callback de watch au tick suivant, c'est-à-dire au moment où le code synchrone en cours se termine. C'est pourquoi on ajoute au filtre un compteur de recherches géré par watch :
1import { describe, it, expect } from 'vitest'
2import { ref, watch, nextTick, computed } from 'vue'
3import { withSetup } from './test-utils'
4
5function useSearchFilter(items) {
6 const query = ref('')
7 const searchCount = ref(0)
8
9 const filtered = computed(() => {
10 if (!query.value) return items.value
11 return items.value.filter(item =>
12 item.name.toLowerCase().includes(query.value.toLowerCase())
13 )
14 })
15
16 // watch compte les changements de requête - son callback attend le tick suivant
17 watch(query, () => {
18 searchCount.value++
19 })
20
21 return { query, filtered, searchCount }
22}Les deux chaînes sont comparées en minuscules, donc la casse n'a pas d'importance - tu écriras le test avec la requête 'TEMP' dans la tâche pratique. Le troisième test ci-dessous montre à quoi sert nextTick :
1describe('useSearchFilter', () => {
2 it('filtre les éléments selon la requête', async () => {
3 const items = ref([
4 { name: 'Capteur de température' },
5 { name: 'Capteur de pression' },
6 { name: 'Capteur de radiation' }
7 ])
8
9 const { result } = withSetup(() => useSearchFilter(items))
10
11 // Au départ tous les éléments
12 expect(result.filtered.value).toHaveLength(3)
13
14 // Filtrage
15 result.query.value = 'temp'
16 await nextTick()
17
18 expect(result.filtered.value).toHaveLength(1)
19 expect(result.filtered.value[0].name).toBe('Capteur de température')
20 })
21
22 it('renvoie tout pour une requête vide', async () => {
23 const items = ref([{ name: 'A' }, { name: 'B' }])
24 const { result } = withSetup(() => useSearchFilter(items))
25
26 result.query.value = 'A'
27 await nextTick()
28 expect(result.filtered.value).toHaveLength(1)
29
30 result.query.value = ''
31 await nextTick()
32 expect(result.filtered.value).toHaveLength(2)
33 })
34
35 it('compte les changements de requête seulement après nextTick', async () => {
36 const items = ref([{ name: 'A' }])
37 const { result } = withSetup(() => useSearchFilter(items))
38
39 result.query.value = 'A'
40 expect(result.searchCount.value).toBe(0) // watch toujours en attente
41
42 await nextTick()
43 expect(result.searchCount.value).toBe(1)
44 })
45})Dans les deux premiers tests, filtered donnerait le bon résultat même sans await nextTick(), car c'est un computed. Dans le troisième, sans attente, le compteur reste à zéro. flushPromises de @vue/test-utils va un cran plus loin : il attend que toutes les promesses en attente soient résolues.
Tester des composables asynchrones
On teste un composable qui interroge le serveur toutes les quelques secondes avec les faux minuteurs de Vitest, pour que le test n'attende pas réellement. Voici le code testé :
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2import { ref, onMounted, onUnmounted } from 'vue'
3import { withSetup } from './test-utils'
4
5function usePolling(fetchFn, interval = 5000) {
6 const data = ref(null)
7 const isPolling = ref(false)
8 let timerId = null
9
10 function startPolling() {
11 isPolling.value = true
12 timerId = setInterval(async () => {
13 data.value = await fetchFn()
14 }, interval)
15 }
16
17 function stopPolling() {
18 isPolling.value = false
19 if (timerId) {
20 clearInterval(timerId)
21 timerId = null
22 }
23 }
24
25 onUnmounted(stopPolling)
26
27 return { data, isPolling, startPolling, stopPolling }
28}vi.useFakeTimers() remplace setInterval par une horloge qu'on avance à la main, et vi.useRealTimers() restaure la vraie. advanceTimersByTimeAsync avance le temps et attend aussi les promesses retournées par les minuteurs :
1describe('usePolling', () => {
2 beforeEach(() => {
3 vi.useFakeTimers()
4 })
5
6 afterEach(() => {
7 vi.useRealTimers()
8 })
9
10 it('interroge le serveur aux intervalles donnés', async () => {
11 let callCount = 0
12 const mockFetch = vi.fn().mockImplementation(() => {
13 callCount++
14 return Promise.resolve({ reading: callCount * 10 })
15 })
16
17 const { result } = withSetup(() => usePolling(mockFetch, 1000))
18
19 result.startPolling()
20 expect(result.isPolling.value).toBe(true)
21
22 // Simule le passage du temps (la version Async attend aussi les promesses)
23 await vi.advanceTimersByTimeAsync(3000)
24
25 expect(mockFetch).toHaveBeenCalledTimes(3)
26 expect(result.data.value).toEqual({ reading: 30 })
27
28 result.stopPolling()
29 expect(result.isPolling.value).toBe(false)
30 })Après trois secondes simulées, le serveur a répondu trois fois, et data contient le dernier relevé. La version synchrone advanceTimersByTime appellerait aussi mockFetch trois fois, mais data resterait vide. Pour finir, un test de nettoyage, dans lequel app.unmount() déclenche onUnmounted :
1 it('nettoie le minuteur après le démontage', async () => {
2 const mockFetch = vi.fn().mockResolvedValue({ reading: 1 })
3 const { result, app } = withSetup(() => usePolling(mockFetch, 1000))
4
5 result.startPolling()
6 app.unmount() // déclenche onUnmounted -> stopPolling
7
8 await vi.advanceTimersByTimeAsync(5000)
9 expect(mockFetch).not.toHaveBeenCalled()
10 expect(result.isPolling.value).toBe(false)
11 })
12})Cinq secondes après le démontage, personne n'a interrogé le serveur - le minuteur a vraiment disparu.
Bonnes pratiques de test
- Isolation - chaque test doit être indépendant
- Mocking - toujours mocker les dépendances externes
- Asynchronisme - utilise
nextTickpour les watchers et les mises à jour du DOM, etflushPromisespour les promesses en attente - Nettoyage - vérifie que le composable libère correctement ses ressources, par exemple avec
app.unmount() - Cas limites - teste les données vides, les erreurs, les valeurs limites
Mon conseil : conçois tes composables pour que leurs dépendances arrivent en paramètres, comme fetchFn. Un mock tient alors en une ligne, et le test n'a pas besoin de remplacer le fetch global. Tu étendras tes tests aux composants sur la plateforme de lancement. Dans l'éditeur ci-dessous, un mini-lanceur de tests tourne dans le navigateur : il vérifie useCounter, useSearchFilter et useAsyncData, et affiche les résultats sur le panneau.
Retiens : un test, c'est l'essai de pression d'un module avant le décollage - mieux vaut trouver la fuite au laboratoire qu'en orbite.
Code de cette leçon : App.vue
1<script setup>
2import { ref, computed, watch, nextTick } from 'vue'
3
4// ===== DÉMO : TESTER LES COMPOSABLES =====
5
6// Composable : useCounter (testé)
7function useCounter(initial = 0) {
8 const count = ref(initial)
9 const doubled = computed(() => count.value * 2)
10 const increment = () => count.value++
11 const decrement = () => count.value--
12 const reset = () => { count.value = initial }
13 return { count, doubled, increment, decrement, reset }
14}
15
16// Composable : useSearchFilter (testé)
17function useSearchFilter(items) {
18 const query = ref('')
19 const filtered = computed(() => {
20 if (!query.value) return items.value
21 return items.value.filter(item =>
22 item.name.toLowerCase().includes(query.value.toLowerCase())
23 )
24 })
25 return { query, filtered }
26}
27
28// Composable : useAsyncData (testé)
29function useAsyncData(fetchFn) {
30 const data = ref(null)
31 const error = ref(null)
32 const loading = ref(false)
33
34 async function load(...args) {
35 loading.value = true
36 error.value = null
37 try {
38 data.value = await fetchFn(...args)
39 } catch (e) {
40 error.value = e.message
41 } finally {
42 loading.value = false
43 }
44 }
45
46 return { data, error, loading, load }
47}
48
49// === Exécuteur de tests ===
50const testResults = ref([])
51const totalPassed = computed(() => testResults.value.filter(t => t.passed).length)
52const totalFailed = computed(() => testResults.value.filter(t => !t.passed).length)
53
54function assert(condition, testName) {
55 testResults.value.push({
56 name: testName,
57 passed: condition,
58 timestamp: new Date().toLocaleTimeString()
59 })
60}
61
62async function runAllTests() {
63 testResults.value = []
64
65 // Test useCounter
66 const counter = useCounter(10)
67 assert(counter.count.value === 10, 'useCounter : démarre avec la valeur initiale')
68 counter.increment()
69 assert(counter.count.value === 11, 'useCounter : increment fonctionne')
70 assert(counter.doubled.value === 22, 'useCounter : le computed doubled fonctionne')
71 counter.decrement()
72 counter.decrement()
73 assert(counter.count.value === 9, 'useCounter : decrement fonctionne')
74 counter.reset()
75 assert(counter.count.value === 10, 'useCounter : reset revient à la valeur initiale')
76
77 const defaultCounter = useCounter()
78 assert(defaultCounter.count.value === 0, 'useCounter : la valeur initiale par défaut est 0')
79
80 // Test useSearchFilter
81 const items = ref([
82 { name: 'Temperature Sensor' },
83 { name: 'Pressure Sensor' },
84 { name: 'Radiation Detector' }
85 ])
86 const filter = useSearchFilter(items)
87 assert(filter.filtered.value.length === 3, 'useSearchFilter : affiche tout au départ')
88
89 filter.query.value = 'sensor'
90 await nextTick()
91 assert(filter.filtered.value.length === 2, 'useSearchFilter : filtre par requête')
92
93 filter.query.value = 'radiation'
94 await nextTick()
95 assert(filter.filtered.value.length === 1, 'useSearchFilter : correspondance exacte')
96 assert(filter.filtered.value[0].name === 'Radiation Detector', 'useSearchFilter : bon élément')
97
98 filter.query.value = ''
99 await nextTick()
100 assert(filter.filtered.value.length === 3, 'useSearchFilter : requête vide affiche tout')
101
102 // Test useAsyncData
103 const successData = useAsyncData(async () => ({ temp: 22, status: 'OK' }))
104 assert(successData.loading.value === false, 'useAsyncData : pas de chargement au départ')
105 assert(successData.data.value === null, 'useAsyncData : aucune donnée au départ')
106
107 await successData.load()
108 assert(successData.data.value !== null, 'useAsyncData : a des données après load')
109 assert(successData.data.value.temp === 22, 'useAsyncData : données correctes')
110 assert(successData.error.value === null, 'useAsyncData : aucune erreur en cas de succès')
111
112 const failData = useAsyncData(async () => { throw new Error('Connection lost') })
113 await failData.load()
114 assert(failData.error.value === 'Connection lost', 'useAsyncData : capture bien l erreur')
115 assert(failData.data.value === null, 'useAsyncData : aucune donnee en cas d erreur')
116}
117</script>
118
119<template>
120 <div class="test-lab">
121 <h1>Tester les composables - NOVA LAB</h1>
122
123 <div class="controls">
124 <button @click="runAllTests" class="run-btn">Lancer tous les tests</button>
125 </div>
126
127 <div v-if="testResults.length" class="summary">
128 <span class="passed">Réussis : {{ totalPassed }}</span>
129 <span class="failed">Échoués : {{ totalFailed }}</span>
130 <span class="total">Total : {{ testResults.length }}</span>
131 </div>
132
133 <div class="results">
134 <div v-for="(test, i) in testResults" :key="i"
135 class="test-result" :class="{ pass: test.passed, fail: !test.passed }">
136 <span class="icon">{{ test.passed ? 'PASS' : 'FAIL' }}</span>
137 <span class="test-name">{{ test.name }}</span>
138 </div>
139 </div>
140
141 <div v-if="!testResults.length" class="empty">
142 Clique sur « Lancer tous les tests » pour exécuter la suite de tests
143 </div>
144 </div>
145</template>
146
147<style scoped>
148.test-lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; }
149h1 { color: #00ff88; text-align: center; margin-bottom: 2rem; }
150.controls { text-align: center; margin: 1rem 0; }
151.run-btn { background: #00ff88; color: #0a0e27; border: none; padding: 0.8rem 2rem; border-radius: 6px; font-size: 1.1rem; font-weight: bold; cursor: pointer; font-family: inherit; }
152.run-btn:hover { background: #00b4d8; color: #fff; }
153.summary { display: flex; justify-content: center; gap: 2rem; padding: 1rem; background: rgba(0,0,0,0.3); border-radius: 8px; margin: 1rem 0; font-size: 1.1rem; }
154.passed { color: #00ff88; }
155.failed { color: #ff0055; }
156.total { color: #00b4d8; }
157.results { margin: 1rem 0; }
158.test-result { display: flex; align-items: center; gap: 0.8rem; padding: 0.5rem 0.8rem; margin: 0.2rem 0; border-radius: 4px; font-size: 0.9rem; }
159.test-result.pass { background: rgba(0,255,136,0.08); border-left: 3px solid #00ff88; }
160.test-result.fail { background: rgba(255,0,85,0.08); border-left: 3px solid #ff0055; }
161.icon { font-weight: bold; font-size: 0.8rem; padding: 0.15rem 0.4rem; border-radius: 3px; }
162.pass .icon { background: #00ff88; color: #0a0e27; }
163.fail .icon { background: #ff0055; color: #fff; }
164.test-name { color: #ccc; }
165.empty { text-align: center; color: #666; padding: 3rem; font-style: italic; }
166</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 avons-nous besoin de l'aide withSetup pour tester les composables ?
2. Comment mocke-t-on les dépendances externes dans les tests de composables ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Crée un composant de test avec useSensorData. Mocke fetchFn avec vi.fn(). Vérifie loading, data, error après l'appel de load().
- Tri vertical
Ordonne les étapes d'un test unitaire de composable, de la préparation à l'assertion :
- Clique dans l'ordre
Ordonne une assertion correcte vérifiant la valeur d'une ref :
- Éditeur de code
Teste useSearchFilter : filtrage par requête, requête vide qui retourne tous les éléments, correspondance insensible à la casse.
- Tri horizontal
Ordonne la création d'une fonction fetch mockée :