Cours Vue.js · Module 9: Composables et VueUse

Tester les composables

9 min de lecture
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

  1. Isolation - chaque test doit être indépendant
  2. Mocking - toujours mocker les dépendances externes
  3. Asynchronisme - utilise nextTick pour les watchers et les mises à jour du DOM, et flushPromises pour les promesses en attente
  4. Nettoyage - vérifie que le composable libère correctement ses ressources, par exemple avec app.unmount()
  5. 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. 1. Pourquoi avons-nous besoin de l'aide withSetup pour tester les composables ?

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