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

Tester les composables et les stores

24 min de lecture
Dans cette leçon13

Dans la leçon précédente, tu montais des composants et tu cliquais sur leurs boutons comme un technicien qui contrôle un pupitre avant le décollage : il appuie sur un bouton, il regarde si le bon voyant s'allume. C'est une excellente méthode pour tout ce qui se voit sur la façade. L'ennui, c'est que dans le panneau de télémétrie de NOVA LAB, l'essentiel du travail se passe sous le plancher - dans les composables qui calculent et récupèrent les données, et dans le store Pinia qui garde l'état de toute la mission. Pour contrôler un seul convertisseur, il te faudrait monter le panneau entier, attendre le rendu, repêcher un nombre dans le HTML et en déduire seulement ensuite si le calcul tombe juste.

Autant allumer la fusée pour vérifier une seule vanne. Dans le hall d'essais de la station, on procède autrement : on démonte l'organe, on le pose sur un banc de mesure et on mesure directement. C'est exactement ce que nous allons faire avec la logique du panneau - dans toute cette leçon, nous ne monterons pas un seul composant.

Un composable est une fonction ordinaire, pas une magie du framework

Avant d'écrire le premier test, nommons honnêtement la chose que nous allons examiner. Un composable n'est pas un être à part du framework : c'est une fonction JavaScript ordinaire qui crée quelques refs, définit quelques fonctions et les renvoie dans un seul objet. Il n'a besoin ni de template, ni d'instance d'application, ni de navigateur. La recette tient donc en deux gestes : tu importes le composable et tu l'appelles directement dans le test, exactement comme tu l'appelles dans le bloc script setup d'un composant, puis tu vérifies les valeurs qu'il a renvoyées. Commençons par l'instrument le plus simple du laboratoire : un compteur de cycles de pompe, qui possède une valeur initiale, une valeur doublée et deux actions qui modifient l'état.

1// composables/useCounter.js
2import { ref, computed } from 'vue'
3
4export function useCounter(initialValue = 0) {
5  const count = ref(initialValue)
6  const doubled = computed(() => count.value * 2)
7
8  function increment() {
9    count.value++
10  }
11
12  function decrement() {
13    count.value--
14  }
15
16  return { count, doubled, increment, decrement }
17}

Regarde bien ce que cette fonction livre à l'extérieur. La valeur count est un ref, donc dans le test tu liras count.value et non count tout seul. La valeur doubled est un computed, c'est-à-dire un ref elle aussi, mais un ref que tu n'assignes jamais à la main, puisque Vue le calcule à partir de count. Pas une seule ligne de ce fichier ne touche au DOM, ne va chercher window ni ne se sert du cycle de vie d'un composant. Voilà pourquoi le banc de mesure suffira ici dans sa forme la plus dépouillée.

Puisque nous avons affaire à une fonction ordinaire, démolissons tout de suite trois croyances qui circulent dans les équipages. Il est faux qu'un composable ne puisse être vérifié qu'à l'intérieur d'un composant monté - le montage ne devient nécessaire que lorsque le composable se sert de hooks du cycle de vie du genre onMounted, ou du mécanisme provide et inject. Il est faux également que les composables échappent aux tests unitaires : au contraire, dans tout le projet ce sont eux qui s'y prêtent le mieux, car ils n'ont ni template ni styles. Et enfin les tests E2E, qui lèvent un vrai navigateur et parcourent tout le trajet de l'opérateur, sont ici un canon braqué sur une mouche - ils coûtent des secondes au lieu de millisecondes et ne te diront pas quelle ligne s'est trompée.

Les trois mots dont un test est fait

Vitest, que tu as configuré dans la leçon précédente, met à ta disposition un jeu de fonctions importées du paquet vitest. La fonction describe regroupe des tests sous un nom commun : elle met de l'ordre dans le rapport et délimite la portée des préparatifs partagés, sur lesquels nous reviendrons plus loin. La fonction it décrit un cas : son premier argument est une phrase qui dit ce qui doit se produire, le second une fonction qui le vérifie. La fonction expect prend une valeur et permet de poser une condition dessus à l'aide d'un matcher, c'est-à-dire d'une méthode qui exprime une attente. Le matcher le plus simple s'appelle toBe et contrôle que la valeur est exactement celle que tu attends. Cela suffit à examiner les deux chemins d'initialisation du compteur.

1// composables/useCounter.spec.js
2import { describe, it, expect } from 'vitest'
3import { useCounter } from './useCounter'
4
5describe('useCounter', () => {
6  it('initializes with default value', () => {
7    const { count } = useCounter()
8
9    expect(count.value).toBe(0)
10  })
11
12  it('initializes with custom value', () => {
13    const { count } = useCounter(10)
14
15    expect(count.value).toBe(10)
16  })
17})

Lance les tests et tu verras deux lignes vertes, chacune en quelques millisecondes. Le plus intéressant, pourtant, c'est ce qui manque dans ce fichier : pas de mount, pas de template, pas d'attente de rendu. Chaque it appelle useCounter de zéro, donc chaque cas reçoit son propre compteur tout neuf - tu n'as rien à remettre à zéro entre les tests, parce qu'entre les tests il n'y a rien à remettre à zéro. La déstructuration est sûre elle aussi : en extrayant count de l'objet, tu emportes le ref, c'est-à-dire l'objet qui enveloppe la valeur, et non une copie prise à l'instant.

Une action et une valeur dérivée sur le même banc

Contrôler la valeur initiale n'est qu'une mesure au repos. La vraie question est ailleurs : après l'appel d'une action, l'état change-t-il comme la fiche technique de l'organe le promet. Le scénario sera toujours le même et tu le répéteras jusqu'à la fin de la leçon : prends ce que le composable a renvoyé, appelle l'action, vérifie le résultat. Nous en profiterons pour examiner doubled, car une valeur computed est recalculée paresseusement, au moment seulement de la lecture - et la lecture a lieu à l'instant où tu vas chercher doubled.value dans l'assertion.

1// composables/useCounter.spec.js
2import { describe, it, expect } from 'vitest'
3import { useCounter } from './useCounter'
4
5describe('useCounter actions', () => {
6  it('increments the counter and refreshes the computed value', () => {
7    const { count, doubled, increment } = useCounter()
8
9    expect(doubled.value).toBe(0)
10
11    increment()
12
13    expect(count.value).toBe(1)
14    expect(doubled.value).toBe(2)
15  })
16})

Le test est passé sans un seul await, et c'est ici l'observation capitale. Dans la leçon précédente, tu attendais la mise à jour du DOM après un clic, parce que Vue repousse à plus tard le redessin de l'interface. Ici, il n'y a aucune interface : tu lis la valeur directement dans le ref, et celle-ci change immédiatement après increment. Remarque aussi ce qui est resté intact - le fichier useCounter.js n'a pas gagné une seule ligne ajoutée pour les besoins du test. Un bon test n'exige rien du code de production. Nous n'avons volontairement pas contrôlé la fonction decrement, car c'est ton travail dans l'exercice pratique qui t'attend dans un instant.

toBe ou toEqual, deux questions différentes

Jusqu'ici nous comparions des nombres et toBe suffisait amplement. Nous allons bientôt comparer des objets et des tableaux, et là ce même matcher sait réserver de mauvaises surprises. Le matcher toBe interroge l'identité : est-ce exactement la même valeur, la même case mémoire, le même objet. Pour les nombres et les chaînes il se comporte de façon intuitive, mais deux objets au contenu identique sont pour lui deux choses différentes, car ils logent à deux adresses différentes. Le matcher toEqual pose une autre question : la structure et les valeurs sont-elles les mêmes, champ par champ, jusqu'au fond des objets imbriqués. Un préfixe te servira encore, not, qui renverse n'importe quelle attente.

1import { describe, it, expect } from 'vitest'
2
3describe('toBe versus toEqual', () => {
4  it('compares frames by value and by identity', () => {
5    const first = { id: 1, title: 'Olympus Mons' }
6    const second = { id: 1, title: 'Olympus Mons' }
7
8    expect(first).toEqual(second)
9    expect(first).not.toBe(second)
10    expect(first).toBe(first)
11  })
12})

Les trois assertions de ce test passent et racontent ensemble toute l'histoire : les mêmes données, deux objets différents, et l'identité ne tient que lorsque tu compares quelque chose avec lui-même. Retiens cette différence avec précision, car elle est source d'erreurs. Il est faux que les deux matchers fassent la même chose, faux aussi que toEqual soit déprécié et toBe plus rapide - les deux sont d'actualité, les deux sont à employer, simplement pour des questions différentes. Il est faux enfin que l'un s'occupe des nombres et l'autre des chaînes : ces deux valeurs, tu les vérifieras avec l'un comme avec l'autre, car pour les valeurs primitives le résultat est identique. Ma recommandation est simple : pour les nombres, les chaînes et les booléens prends toBe, pour les objets et les tableaux toujours toEqual, et n'emploie toBe sur un objet que si tu veux vraiment prouver qu'il s'agit du même exemplaire.

Un composable qui va chercher les données du serveur de mission

Le compteur était un patient facile, car il n'avait besoin de rien de l'extérieur. Un vrai panneau de télémétrie a une autre allure : pour afficher quoi que ce soit, il doit interroger le serveur de mission. Écrivons donc un composable qui récupère une ressource à l'adresse indiquée et expose trois refs : les données reçues, l'information disant si la récupération est en cours, et l'erreur éventuelle. Nous enfermerons l'appel réseau dans un bloc try et catch, et nous éteindrons l'interrupteur de chargement dans le bloc finally, pour qu'il s'éteigne que la requête ait réussi ou non.

1// composables/useFetch.js
2import { ref } from 'vue'
3
4export function useFetch(url) {
5  const data = ref(null)
6  const loading = ref(false)
7  const error = ref(null)
8
9  async function fetchData() {
10    loading.value = true
11    error.value = null
12
13    try {
14      const response = await fetch(url)
15      data.value = await response.json()
16    } catch (e) {
17      error.value = e
18    } finally {
19      loading.value = false
20    }
21  }
22
23  return { data, loading, error, fetchData }
24}

Ce composable est un objet d'étude bien plus intéressant que le compteur, car il a deux chemins : celui qui réussit et celui qui échoue. Il a aussi un sérieux problème du côté des tests. Il appelle fetch, la fonction intégrée à l'environnement qui envoie une vraie requête réseau. Un test qui frappe pour de bon à la porte du serveur de mission est lent, exige un serveur en marche et sait passer au rouge à cause d'une liaison saturée plutôt qu'à cause de ton erreur. Sur un banc d'essais, personne ne branche l'organe sur une vraie antenne - on le branche sur un simulateur de signal.

La fonction factice, autrement dit vi.fn

Ce simulateur, dans Vitest, s'appelle vi : c'est un objet outil importé du même paquet que describe et it. Sa méthode la plus importante, vi.fn, crée une fonction factice, autrement dit un mock : une fonction qui ne fait rien, mais qui retient chacun de ses appels avec les arguments reçus et sait renvoyer ce que tu lui ordonnes de renvoyer. Pour contrôler les appels enregistrés, il existe deux matchers, toHaveBeenCalledTimes, qui les compte, et toHaveBeenCalledWith, qui examine les arguments. Regarde cela sur l'exemple d'une fonction qui prévient l'officier de garde du contrôle de mission.

1import { describe, it, expect, vi } from 'vitest'
2
3describe('vi.fn', () => {
4  it('records every call it receives', () => {
5    const notify = vi.fn()
6
7    notify('telemetry ready')
8
9    expect(notify).toHaveBeenCalledTimes(1)
10    expect(notify).toHaveBeenCalledWith('telemetry ready')
11  })
12})

Remarque ce que la fonction factice n'a pas fait : elle n'a établi aucune connexion, n'a rien envoyé et n'est allée regarder nulle part. C'est là son unique raison d'être - elle prend la place d'une vraie dépendance pour que le code examiné croie parler au monde extérieur alors qu'il parle à un bouchon. Il vaut la peine d'en finir ici avec trois malentendus. vi.fn ne crée pas de vraies connexions à des API, car il fait exactement l'inverse : il les retire du test. Il n'a rien à voir non plus avec le formatage de la sortie dans la console, car c'est Vitest lui-même qui se charge d'un rapport lisible, et non les fonctions factices. Et il ne corrige surtout rien dans ton code - aucun outil de test ne répare les erreurs, les tests se contentent de montrer du doigt l'endroit où elles se cachent.

Substituer le fetch global

Puisqu'une fonction factice sait imiter n'importe quelle fonction, elle sait aussi imiter fetch. Dans l'environnement de test, fetch est une propriété ordinaire de l'objet global, accessible sous le nom global, tu peux donc tout simplement l'écraser. L'écriture se lit de gauche à droite : tu désignes d'abord la cible du remplacement par global.fetch =, puis tu crées la fonction factice par l'appel vi.fn(), puis tu lui accroches .mockResolvedValue({, c'est-à-dire la déclaration qu'elle doit renvoyer une promesse résolue avec la valeur indiquée, et pour finir tu décris la réponse simulée. Une vraie réponse du serveur possède une méthode json qui renvoie une promesse contenant les données ; la fonction factice doit donc la posséder elle aussi - d'où l'écriture json: async () => mockData }) qui referme le tout.

1// composables/useFetch.spec.js
2import { describe, it, expect, vi, beforeEach } from 'vitest'
3import { useFetch } from './useFetch'
4
5describe('useFetch', () => {
6  beforeEach(() => {
7    global.fetch = vi.fn()
8  })
9
10  it('stores the payload returned by the mission server', async () => {
11    const mockData = { id: 1, title: 'Olympus Mons' }
12
13    global.fetch.mockResolvedValue({
14      json: async () => mockData
15    })
16
17    const { data, loading, error, fetchData } = useFetch('/api/frames/1')
18
19    expect(data.value).toBe(null)
20
21    await fetchData()
22
23    expect(data.value).toEqual(mockData)
24    expect(loading.value).toBe(false)
25    expect(error.value).toBe(null)
26  })
27})

Une fonction nouvelle est apparue : beforeEach exécute le code indiqué avant chaque test du groupe, si bien que chaque cas démarre avec une fonction factice fraîche, qui ne se souvient encore de rien. Remarque que nous avons découpé l'écriture en deux temps - dans beforeEach naît la fonction factice elle-même, et c'est seulement dans le test que nous lui disons quoi renvoyer. Si tu préfères tout tenir sur une ligne, tu peux écrire global.fetch = vi.fn().mockResolvedValue({ json: async () => mockData }) directement dans le test et l'effet sera identique. Je te recommande la version en deux temps partout où un même groupe rassemble plusieurs tests aux réponses différentes, car la préparation commune reste alors à un seul endroit.

Après l'appel de fetchData, les données ont atterri dans le ref, l'interrupteur de chargement est revenu à faux grâce au bloc finally, et l'erreur est restée vide. La comparaison des données passe par toEqual, donc le test réussira que la fonction factice ait rendu exactement le même objet ou une copie fidèle - et c'est très bien ainsi, car dans ce test c'est le contenu de la réponse qui nous intéresse, pas son adresse en mémoire. Ce qui n'a pas changé au passage : le fichier useFetch.js n'est au courant de rien. Nous ne lui avons ajouté ni interrupteur de mode test, ni paramètre recevant une fonction de remplacement. Nous avons substitué l'environnement autour du code, et non le code lui-même.

Avant d'aller plus loin, encore trois phrases sur la façon de ne pas s'y prendre. Importer une vraie bibliothèque du genre node-fetch ne résout rien, car tu continues d'envoyer de vraies requêtes, simplement avec un autre outil. Vitest ne mocke pas non plus les requêtes HTTP de lui-même - tant que tu n'as pas mis de fonction factice à la place, fetch fonctionne pour de bon dans le test. Et il est faux que fetch soit intouchable et qu'il faille donc se rabattre sur une vraie API. Tu viens justement de le remplacer en une ligne. Il existe encore une méthode vi.stubGlobal, qui fait la même chose et sait restaurer l'original automatiquement, mais l'affectation à global.fetch est l'écriture que tu croiseras dans la plupart des projets, et c'est avec elle que nous resterons.

Le chemin de secours mérite aussi sa mesure

Un test qui ne contrôle que le scénario heureux décrit un laboratoire, pas une mission. Sur Mars, la liaison sait se taire, examinons donc ce que fera le composable quand la requête échouera. C'est le rôle de mockRejectedValue, la méthode jumelle de la fonction factice, qui renvoie une promesse rejetée avec l'erreur indiquée au lieu d'une promesse tenue. Nous vérifierons deux choses d'un coup : que les données sont restées vides et que le ref d'erreur a bien reçu un objet de la classe Error. Pour cette seconde question, le matcher idéal est toBeInstanceOf, qui examine si une valeur est un exemplaire de la classe indiquée.

1// composables/useFetch.spec.js
2import { describe, it, expect, vi, beforeEach } from 'vitest'
3import { useFetch } from './useFetch'
4
5describe('useFetch on a broken link', () => {
6  beforeEach(() => {
7    global.fetch = vi.fn()
8  })
9
10  it('keeps data empty and records the error', async () => {
11    global.fetch.mockRejectedValue(new Error('Network error'))
12
13    const { data, error, fetchData } = useFetch('/api/frames/1')
14
15    await fetchData()
16
17    expect(data.value).toBe(null)
18    expect(error.value).toBeInstanceOf(Error)
19  })
20})

La panne s'est déroulée de façon maîtrisée et c'est précisément le résultat qui t'importe : malgré la promesse rejetée, le test n'a pas explosé sur une exception, car le composable l'a interceptée dans le bloc catch. Remarque ce qui n'a pas changé malgré l'erreur - le ref de données tient toujours la valeur vide, donc le panneau n'affichera ni une demi-image ni les restes de la récupération précédente. Si, dans le composable, tu écrivais dans le ref non pas l'erreur entière mais son seul message, le matcher toBeInstanceOf n'aurait plus rien à examiner et tu comparerais une simple chaîne. C'est un choix de conception assumé, et tu vas voir dans un instant exactement cette seconde variante dans le store.

L'entrepôt de mission que nous allons mettre sur l'établi

Les composables ont une propriété commode : chaque appel crée un exemplaire nouveau et indépendant. Le store Pinia est différent par principe, car toute la station doit se servir d'un unique entrepôt de données commun. Avant de lui écrire des tests, regardons l'entrepôt lui-même. Ce sera une archive de clichés de la surface : une liste d'enregistrements dans artworks, un interrupteur isLoading, un ref pour l'erreur et un objet filters avec un unique champ de recherche. À cela s'ajoute un getter, filteredArtworks, qui ne renvoie que les enregistrements dont le titre contient la phrase cherchée, sans égard à la casse.

1// stores/gallery.js
2import { defineStore } from 'pinia'
3import { ref, computed } from 'vue'
4
5export const useGalleryStore = defineStore('gallery', () => {
6  const artworks = ref([])
7  const isLoading = ref(false)
8  const error = ref(null)
9  const filters = ref({ search: '' })
10
11  const filteredArtworks = computed(() => {
12    const phrase = filters.value.search.toLowerCase()
13
14    return artworks.value.filter(
15      (item) => item.title.toLowerCase().includes(phrase)
16    )
17  })
18
19  return { artworks, isLoading, error, filters, filteredArtworks }
20})

C'est la même écriture de store que celle découverte en explorant Pinia - la fonction passée à defineStore crée des refs et des computed, puis les renvoie dans un objet. Puisque le getter est un computed ordinaire, il se comportera dans le test exactement comme doubled du compteur : il se recalculera au moment de la lecture, sans la moindre attente. Il manque encore l'action qui remplira l'archive avec les données du serveur de mission. Nous l'ajouterons à l'intérieur de cette même fonction, à côté du getter, et n'oublie pas de mettre son nom dans l'objet renvoyé, sans quoi elle restera privée.

1// stores/gallery.js - a l'interieur du callback defineStore
2async function fetchArtworks() {
3  isLoading.value = true
4  error.value = null
5
6  try {
7    const response = await fetch('/api/frames')
8
9    if (!response.ok) {
10      throw new Error('Mission API refused the request')
11    }
12
13    artworks.value = await response.json()
14  } catch (e) {
15    error.value = e.message
16  } finally {
17    isLoading.value = false
18  }
19}

Cette action diffère de fetchData du composable par deux détails qui auront une influence directe sur les tests. D'abord elle contrôle la propriété ok de la réponse, c'est-à-dire l'information disant si le serveur a répondu par un code de succès - dans le cas contraire, elle lève elle-même une exception. Ensuite, dans le ref d'erreur elle n'écrit que le message, et non l'objet d'erreur entier. La première différence signifie que la réponse simulée dans le test devra posséder un champ ok mis à vrai, faute de quoi l'action jugera la récupération manquée avant même d'aller chercher les données.

Un store sans application, autrement dit setActivePinia

Si tu écrivais maintenant un test qui appelle simplement useGalleryStore(), tu récolterais une erreur signalant l'absence d'instance active de Pinia. Ce n'est pas une avarie, c'est la conséquence du fonctionnement de Pinia. Dans l'application, tu appelles quelque part au démarrage app.use(createPinia()) et, à partir de là, la bibliothèque sait dans quel entrepôt garder l'état. Dans le test, il n'y a pas d'application, il faut donc le lui dire explicitement. Deux fonctions importées du paquet pinia servent à cela : createPinia crée une instance neuve et vide, et setActivePinia la désigne comme instance courante.

Retiens l'ordre de ces étapes, car une méprise sur cet ordre donne précisément l'erreur ci-dessus. D'abord tu importes setActivePinia et createPinia. Ensuite, dans beforeEach, tu appelles setActivePinia(createPinia()) pour que chaque test reçoive une instance propre. Alors seulement, dans le test, tu crées une instance du store. Puis tu appelles une action du store. Et pour finir tu vérifies son état avec des assertions. Voyons cette séquence dans le test le plus simple possible, celui qui examine l'état initial de l'archive.

1// stores/gallery.spec.js
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import { useGalleryStore } from './gallery'
5
6describe('Gallery store', () => {
7  beforeEach(() => {
8    setActivePinia(createPinia())
9  })
10
11  it('starts with an empty archive', () => {
12    const store = useGalleryStore()
13
14    expect(store.artworks).toEqual([])
15    expect(store.isLoading).toBe(false)
16  })
17})

Un tableau vide est un objet, nous le comparons donc par toEqual - toBe chercherait le même exemplaire de tableau et passerait au rouge malgré un code parfaitement correct. Remarque aussi que nous lisons l'état par un point, directement depuis store, sans déstructuration et sans storeToRefs. Dans un composant, tu passais par storeToRefs pour ne pas perdre la réactivité en découpant le store en morceaux. Dans un test, tu ne découpes rien et tu ne rends rien, la lecture par un point est donc à la fois la plus courte et la plus lisible. Une instance de Pinia neuve avant chaque test garantit du même coup que les données d'un cas ne fuiront pas vers le suivant.

L'action fetchArtworks sous observation

Nous allons maintenant réunir les deux savoir-faire de cette leçon : l'instance active de Pinia et la fonction factice substituée à fetch. Le scénario est celui de tout à l'heure - prépare la réponse simulée, crée le store, appelle l'action, vérifie l'état. N'oublie pas le champ ok dans la réponse simulée, car l'action l'examine avant d'aller chercher les données. Je montre chaque bloc sous forme de fichier complet, pour que tu puisses le copier et l'exécuter sans avoir à deviner ce qui doit figurer en haut.

1// stores/gallery.spec.js
2import { describe, it, expect, beforeEach, vi } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import { useGalleryStore } from './gallery'
5
6describe('Gallery store fetching', () => {
7  beforeEach(() => {
8    setActivePinia(createPinia())
9  })
10
11  it('loads frames from the mission API', async () => {
12    const mockArtworks = [
13      { id: 1, title: 'Olympus Mons', artist: 'Rover Ares' },
14      { id: 2, title: 'Valles Marineris', artist: 'Orbiter Nova' }
15    ]
16
17    global.fetch = vi.fn().mockResolvedValue({
18      ok: true,
19      json: async () => mockArtworks
20    })
21
22    const store = useGalleryStore()
23
24    await store.fetchArtworks()
25
26    expect(store.artworks).toEqual(mockArtworks)
27    expect(store.isLoading).toBe(false)
28  })
29})

Nous vérifions ici deux choses à la fois et les deux comptent. La première assertion dit que les données de la réponse ont rejoint l'état du store sans la moindre altération en chemin. La seconde, en apparence anodine, veille sur le bloc finally : si quelqu'un supprimait un jour de l'action l'extinction de l'interrupteur, le panneau de télémétrie resterait à jamais avec sa roue de chargement qui tourne, et ce test le signalerait aussitôt. Remarque que nous n'avons indiqué nulle part l'adresse du serveur - la fonction factice répond de la même façon à chaque appel de fetch, donc le test ne dépend pas du nom de la ressource, /api/frames ou autre.

Le getter sur le banc de mesure

Pour le tableau complet, il manque encore le filtrage, et celui-ci a dans un test une propriété agréable : il n'a besoin ni du réseau ni d'une fonction factice. Puisque artworks est un ref ordinaire exposé par le store, tu peux lui affecter une liste toute prête directement dans le test, sans appeler la moindre action. Ensuite tu poses la phrase dans filters.search et tu lis filteredArtworks. Pour contrôler la longueur de la liste, le matcher toHaveLength sera utile : il se lit mieux qu'une comparaison entre un champ de longueur et un nombre.

1// stores/gallery.spec.js
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import { useGalleryStore } from './gallery'
5
6describe('Gallery store filtering', () => {
7  beforeEach(() => {
8    setActivePinia(createPinia())
9  })
10
11  it('narrows the archive down to matching titles', () => {
12    const store = useGalleryStore()
13
14    store.artworks = [
15      { id: 1, title: 'Olympus Mons', artist: 'Rover Ares' },
16      { id: 2, title: 'Valles Marineris', artist: 'Orbiter Nova' },
17      { id: 3, title: 'Olympus Ridge', artist: 'Rover Ares' }
18    ]
19
20    store.filters.search = 'olympus'
21
22    expect(store.filteredArtworks).toHaveLength(2)
23    expect(store.filteredArtworks[0].title).toBe('Olympus Mons')
24  })
25})

Le getter a réagi immédiatement après le changement de phrase, sans await et sans rafraîchir quoi que ce soit, exactement comme doubled dans le compteur. Le plus précieux, pourtant, c'est ce que ce test n'a pas touché : la liste dans store.artworks compte toujours trois positions. Le filtrage crée un nouveau tableau pour les besoins de la lecture et ne retire rien de l'état - si un jour il se mettait à en retirer, cette leçon et ce test cesseraient de concorder, et c'est justement le signal que quelque chose a déraillé dans le code. Poser les données à la main, en contournant l'action, est délibéré dans les tests de getters : tu examines un comportement à la fois, tu n'entraînes donc pas tout le trajet réseau dans le test.

Couper les modules voisins avec vi.mock

Reste le dernier cas issu d'un vrai projet. Notre store d'archive dépend parfois d'un voisin - par exemple du store d'authentification, dont il tire le jeton d'accès au serveur de mission. Dans le test de l'archive, tu ne veux pas passer par la connexion, puisque tu examines autre chose. Pour remplacer un module entier, et non une seule fonction, il y a vi.mock. Son premier argument est le chemin du module, compté par rapport au fichier de test, et le second une fonction fabrique qui renvoie un objet contenant ce que ce module doit faire semblant d'exporter.

1import { vi } from 'vitest'
2
3vi.mock('./auth', () => ({
4  useAuthStore: () => ({
5    token: 'fake-token',
6    isAuthenticated: true
7  })
8}))

À partir de cet instant, tout import du module auth dans ce fichier de test recevra la fonction factice au lieu du vrai store, si bien que le code examiné verra un utilisateur connecté muni d'un jeton tout prêt. Deux choses valent la peine d'être retenues. D'abord, Vitest déplace les appels à vi.mock tout en haut du fichier, au-dessus des imports - c'est pourquoi cela fonctionne quel que soit l'endroit où tu l'écris, mais c'est aussi pourquoi la fabrique ne peut pas se servir de variables déclarées plus bas dans le fichier. Ensuite, l'objet renvoyé doit être enveloppé dans une paire de parenthèses, sans quoi la fonction fléchée prendrait l'accolade pour le début de son corps et non pour un objet. Emploie vi.mock avec parcimonie et seulement pour de véritables dépendances entre modules, car plus tu simules de modules, moins ton test dit quelque chose du comportement réel du système.

Tu disposes maintenant de la panoplie complète du hall d'essais : le composable, tu l'appelles directement et tu lis ses refs ; le réseau, tu le remplaces par une fonction factice ; le store, tu le réveilles par setActivePinia(createPinia()) ; et les modules voisins, tu les coupes avec vi.mock. Dans la prochaine leçon, nous rassemblerons ces organes et nous contrôlerons qu'ils coopèrent dans un panneau monté.

Retiens ceci : un composable et un store sont des organes que le hall d'essais examine séparément - on n'allume la fusée que lorsque chacun d'eux a déjà donné le bon relevé sur son banc.

Code de cette leçon : App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - Gestion des variables d'environnement
5const environments = ref({
6  development: {
7    API_URL: 'http://localhost:3000',
8    MARS_API_KEY: 'dev_key_12345',
9    DEBUG_MODE: 'true',
10    LOG_LEVEL: 'debug'
11  },
12  staging: {
13    API_URL: 'https://staging-mars.novalab.com',
14    MARS_API_KEY: 'staging_key_98765',
15    DEBUG_MODE: 'true',
16    LOG_LEVEL: 'info'
17  },
18  production: {
19    API_URL: 'https://mars.novalab.com',
20    MARS_API_KEY: 'prod_key_******',
21    DEBUG_MODE: 'false',
22    LOG_LEVEL: 'error'
23  }
24})
25
26const currentEnv = ref('development')
27const selectedVar = ref(null)
28
29const currentConfig = computed(() => {
30  return environments.value[currentEnv.value]
31})
32
33const securityScore = computed(() => {
34  const config = currentConfig.value
35  let score = 100
36
37  if (config.DEBUG_MODE === 'true' && currentEnv.value === 'production') {
38    score -= 30
39  }
40  if (config.MARS_API_KEY.includes('dev') && currentEnv.value === 'production') {
41    score -= 40
42  }
43  if (config.API_URL.includes('localhost') && currentEnv.value !== 'development') {
44    score -= 30
45  }
46
47  return Math.max(0, score)
48})
49
50function switchEnvironment(env) {
51  currentEnv.value = env
52  selectedVar.value = null
53}
54
55function showVarInfo(varName) {
56  selectedVar.value = varName
57}
58</script>
59
60<template>
61  <div class="env-control">
62    <div class="header">
63      <h1>Contrôle des variables d'environnement</h1>
64      <p>Configuration de la mission NOVA LAB</p>
65    </div>
66
67    <div class="env-selector">
68      <button
69        v-for="env in ['development', 'staging', 'production']"
70        :key="env"
71        @click="switchEnvironment(env)"
72        :class="{ active: currentEnv === env }"
73      >
74        {{ env.toUpperCase() }}
75      </button>
76    </div>
77
78    <div class="config-display">
79      <h2>Configuration {{ currentEnv.toUpperCase() }}</h2>
80
81      <div class="var-list">
82        <div
83          v-for="(value, key) in currentConfig"
84          :key="key"
85          class="var-item"
86          @click="showVarInfo(key)"
87          :class="{ selected: selectedVar === key }"
88        >
89          <span class="var-name">{{ key }}</span>
90          <span class="var-value">{{ value }}</span>
91        </div>
92      </div>
93
94      <div v-if="selectedVar" class="var-info">
95        <h3>{{ selectedVar }}</h3>
96        <p class="info-text">
97          <template v-if="selectedVar === 'API_URL'">
98            URL de base des endpoints de l'API de la mission Mars. Change selon l'environnement.
99          </template>
100          <template v-else-if="selectedVar === 'MARS_API_KEY'">
101            Clé d'authentification pour l'accès aux données de Mars. Doit rester secrète en production !
102          </template>
103          <template v-else-if="selectedVar === 'DEBUG_MODE'">
104            Active la journalisation détaillée. Doit être à false en production.
105          </template>
106          <template v-else-if="selectedVar === 'LOG_LEVEL'">
107            Niveau de log minimum : debug, info, warn, error.
108          </template>
109        </p>
110      </div>
111    </div>
112
113    <div class="security-panel">
114      <h2>État de sécurité</h2>
115      <div class="security-score" :class="securityScore >= 80 ? 'safe' : 'danger'">
116        <span>Score de sécurité :</span>
117        <strong>{{ securityScore }}/100</strong>
118      </div>
119      <div class="status-message">
120        <template v-if="securityScore === 100">
121          La configuration est sécurisée et optimisée !
122        </template>
123        <template v-else-if="securityScore >= 70">
124          Problèmes de sécurité mineurs détectés
125        </template>
126        <template v-else>
127          Problèmes de sécurité critiques ! Vérifie ta configuration !
128        </template>
129      </div>
130    </div>
131  </div>
132</template>
133
134<style scoped>
135.env-control {
136  background: #0a0e27;
137  color: #00ff88;
138  padding: 2rem;
139  min-height: 100vh;
140  font-family: 'Courier New', monospace;
141}
142
143.header {
144  text-align: center;
145  margin-bottom: 2rem;
146  border-bottom: 2px solid #00b4d8;
147  padding-bottom: 1rem;
148}
149
150.header h1 {
151  color: #00ff88;
152  text-shadow: 0 0 10px #00ff88;
153  margin: 0;
154}
155
156.header p {
157  color: #00b4d8;
158  margin: 0.5rem 0 0 0;
159}
160
161.env-selector {
162  display: flex;
163  gap: 1rem;
164  justify-content: center;
165  margin-bottom: 2rem;
166}
167
168.env-selector button {
169  padding: 0.75rem 2rem;
170  background: rgba(0, 180, 216, 0.1);
171  border: 2px solid #00b4d8;
172  color: #00b4d8;
173  cursor: pointer;
174  border-radius: 4px;
175  font-family: 'Courier New', monospace;
176  font-size: 1rem;
177  transition: all 0.3s;
178}
179
180.env-selector button.active {
181  background: #00b4d8;
182  color: #0a0e27;
183  box-shadow: 0 0 20px #00b4d8;
184}
185
186.config-display, .security-panel {
187  background: rgba(0, 180, 216, 0.1);
188  border: 1px solid #00b4d8;
189  border-radius: 8px;
190  padding: 1.5rem;
191  margin-bottom: 1.5rem;
192}
193
194.config-display h2, .security-panel h2 {
195  color: #00b4d8;
196  margin-top: 0;
197}
198
199.var-list {
200  display: flex;
201  flex-direction: column;
202  gap: 0.75rem;
203}
204
205.var-item {
206  display: flex;
207  justify-content: space-between;
208  padding: 1rem;
209  background: rgba(0, 255, 136, 0.05);
210  border: 1px solid transparent;
211  border-radius: 4px;
212  cursor: pointer;
213  transition: all 0.3s;
214}
215
216.var-item:hover, .var-item.selected {
217  border-color: #00ff88;
218  background: rgba(0, 255, 136, 0.1);
219}
220
221.var-name {
222  color: #00b4d8;
223  font-weight: bold;
224}
225
226.var-value {
227  color: #00ff88;
228  font-family: monospace;
229}
230
231.var-info {
232  margin-top: 1.5rem;
233  padding: 1rem;
234  background: rgba(0, 255, 136, 0.1);
235  border-left: 4px solid #00ff88;
236  border-radius: 4px;
237}
238
239.var-info h3 {
240  color: #00ff88;
241  margin-top: 0;
242}
243
244.info-text {
245  color: #fff;
246  line-height: 1.6;
247}
248
249.security-score {
250  display: flex;
251  justify-content: space-between;
252  padding: 1rem;
253  border-radius: 4px;
254  margin-bottom: 1rem;
255}
256
257.security-score.safe {
258  background: rgba(0, 255, 136, 0.2);
259  border: 2px solid #00ff88;
260}
261
262.security-score.danger {
263  background: rgba(255, 0, 0, 0.2);
264  border: 2px solid #ff0000;
265}
266
267.security-score strong {
268  font-size: 1.5rem;
269}
270
271.status-message {
272  text-align: center;
273  padding: 1rem;
274  font-size: 1.1rem;
275}
276</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. Comment tester un composable avec Vitest ?

  2. 2. À quoi sert vi.fn() dans Vitest ?

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 des tests pour le composable useCounter qui vérifient l'initialisation, l'incrémentation et la valeur calculée doubled.

  • Tri vertical

    Ordonne dans l'ordre les étapes pour tester un store Pinia :

  • Clique dans l'ordre

    Ordonne la syntaxe pour mocker un module :

  • Éditeur de code

    Écris des tests pour un store Pinia qui vérifient l'état initial, l'action fetchArtworks et le filtrage.

  • Tri horizontal

    Ordonne la syntaxe pour mocker une réponse fetch réussie :