Cours Vue.js · Module 12: Tests et déploiement
Tester les composables et les stores
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. Comment tester un composable avec Vitest ?
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 :