frontend / vuejs
Tests avec Vitest & Vue Testing Library
Explication
Ce que vous allez apprendre
- Tester un composable en isolation, sans jamais monter de composant ni de DOM
- Monter un composant avec
@vue/test-utilset vérifier son rendu et ses événements émis - Écrire des tests centrés utilisateur avec
@testing-library/vue - Isoler une dépendance externe (comme un appel API) grâce à
vi.mock - Choisir le bon niveau de test selon ce qu'on cherche réellement à vérifier
Dans quel contexte ?
Une équipe doit garantir qu'un composant FormulaireConnexion affiche bien une erreur pour un email invalide, et qu'un composant Compteur incrémente correctement sa valeur au clic, sans avoir à tester manuellement ces comportements à chaque changement de code. Vitest, aligné sur Vite, permet d'automatiser ces vérifications avec une exécution très rapide, intégrée nativement au même outillage que le projet.
D'abord, tester la logique sans jamais toucher au DOM
La façon la plus rapide et la plus simple de tester un composable est de l'appeler directement, sans monter de composant : useCompteur(5) retourne un objet avec des refs et des fonctions, testables comme n'importe quelle fonction JavaScript classique. Ce niveau de test s'exécute en une fraction de seconde et ne dépend d'aucune simulation de navigateur.
Prérequis
Il faut avoir compris les composables (leçon 15) : cette leçon montre comment les tester efficacement, séparément des composants qui les utilisent.
Ensuite, tester un composant monté avec un accès bas niveau
@vue/test-utils (mount, wrapper.find, wrapper.emitted) donne un accès direct à l'instance du composant : on peut simuler un clic (wrapper.find('button').trigger('click')), vérifier le texte affiché, et vérifier précisément quels événements ont été émis avec quelle charge utile.
| Outil | Niveau d'accès | Philosophie |
|---|---|---|
@vue/test-utils | Bas niveau, instance du composant | Accès technique précis |
@testing-library/vue | Haut niveau, DOM réel | Centré utilisateur et accessibilité |
Il reste une approche différente, centrée sur l'expérience réelle de l'utilisateur
@testing-library/vue (render, screen, fireEvent) encourage une philosophie différente : au lieu de cibler des détails d'implémentation (une classe CSS, une structure interne), les tests interrogent le DOM comme le ferait un vrai utilisateur — par le texte visible, le label d'un champ, le rôle ARIA d'un bouton. Cette approche rend les tests plus résistants aux refactorisations internes qui ne changent pas le comportement observable.
Astuce
screen.getByLabelText('Email') échoue si le champ n'a pas de label correctement associé — ce type de test a le bénéfice collatéral de vérifier indirectement l'accessibilité du formulaire, en plus de son bon fonctionnement.
Maintenant, isoler le composant testé de ses dépendances externes
vi.mock('../api/produits', () => ({ recupererProduits: vi.fn().mockResolvedValue([...]) })) remplace un module entier par une version simulée, garantissant que le test ne dépend jamais d'une vraie API (indisponible, lente, ou aux données changeantes). flushPromises() attend que toutes les promesses en attente se résolvent avant de vérifier le résultat final.
Piège fréquent
Oublier flushPromises() après avoir monté un composant qui charge des données de façon asynchrone fait échouer le test de façon intermittente : l'assertion s'exécute parfois avant que la promesse simulée n'ait eu le temps de se résoudre, un bug de test classique difficile à diagnostiquer sans connaître cette astuce.
Maintenant que l'application est bien testée, la prochaine leçon aborde un sujet tout aussi crucial en production : comment garder une application Vue rapide à mesure qu'elle grossit, avec le lazy loading, <KeepAlive> et v-memo.
Commandes & code
Tests avec Vitest & Vue Testing Library
Vitest (moteur de test aligné sur Vite) est la référence pour tester des projets Vue modernes.
npm install -D vitest @vue/test-utils @testing-library/vue jsdom// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom', // simule un DOM en Node pour tester des composants
globals: true, // expose describe/it/expect sans les importer partout
},
})// composables/useCompteur.js — logique testée isolément, sans monter de composant
import { ref } from 'vue'
export function useCompteur(depart = 0) {
const valeur = ref(depart)
const incrementer = (pas = 1) => { valeur.value += pas }
const reinitialiser = () => { valeur.value = depart }
return { valeur, incrementer, reinitialiser }
}// composables/useCompteur.test.js — test unitaire pur, rapide, sans DOM
import { describe, it, expect } from 'vitest'
import { useCompteur } from './useCompteur'
describe('useCompteur', () => {
it('démarre à la valeur initiale', () => {
const { valeur } = useCompteur(5)
expect(valeur.value).toBe(5)
})
it('incrémente du pas donné', () => {
const { valeur, incrementer } = useCompteur(0)
incrementer(3)
expect(valeur.value).toBe(3)
})
it('réinitialise à la valeur de départ', () => {
const { valeur, incrementer, reinitialiser } = useCompteur(10)
incrementer(5)
reinitialiser()
expect(valeur.value).toBe(10)
})
})// components/Compteur.test.js — test de composant avec @vue/test-utils (bas niveau, accès à l'instance)
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Compteur from './Compteur.vue'
describe('Compteur.vue', () => {
it('affiche la valeur initiale et incrémente au clic', async () => {
const wrapper = mount(Compteur, { props: { depart: 0 } })
expect(wrapper.text()).toContain('0')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
it('émet un événement au seuil atteint', async () => {
const wrapper = mount(Compteur, { props: { depart: 9, seuil: 10 } })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('seuil-atteint')).toBeTruthy()
expect(wrapper.emitted('seuil-atteint')[0]).toEqual([10])
})
})// components/FormulaireConnexion.test.js — Testing Library : approche centrée utilisateur (a11y, DOM réel)
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/vue'
import FormulaireConnexion from './FormulaireConnexion.vue'
describe('FormulaireConnexion', () => {
it("affiche une erreur si l'email est invalide", async () => {
render(FormulaireConnexion)
const champEmail = screen.getByLabelText('Email')
await fireEvent.update(champEmail, 'pas-un-email')
await fireEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(await screen.findByText(/adresse email invalide/i)).toBeInTheDocument()
})
it('appelle onSubmit avec les identifiants valides', async () => {
const surSoumission = vi.fn()
render(FormulaireConnexion, { props: { onSubmit: surSoumission } })
await fireEvent.update(screen.getByLabelText('Email'), 'ada@exemple.com')
await fireEvent.update(screen.getByLabelText('Mot de passe'), 'motdepasse123')
await fireEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(surSoumission).toHaveBeenCalledWith({ email: 'ada@exemple.com', motDePasse: 'motdepasse123' })
})
})// Mock d'un module (ex: appel API) avec vi.mock
import { vi, describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ListeProduits from './ListeProduits.vue'
vi.mock('../api/produits', () => ({
recupererProduits: vi.fn().mockResolvedValue([{ id: 1, nom: 'Clavier' }]),
}))
describe('ListeProduits', () => {
it('affiche les produits chargés depuis l\'API', async () => {
const wrapper = mount(ListeProduits)
await flushPromises() // attend la résolution des promesses en attente
expect(wrapper.text()).toContain('Clavier')
})
})Résumé
- Tester les composables en isolation (sans DOM) est plus rapide et plus simple que via un composant monté.
@vue/test-utils(mount,wrapper.find,wrapper.emitted) pour un accès bas niveau à l'instance.@testing-library/vue(render,screen,fireEvent) encourage des tests centrés utilisateur/accessibilité.vi.mockisole les dépendances externes (API, dates, timers) pour des tests déterministes et rapides.
Exercices pratiques
Mission : un test qui passe une fois sur deux
Objectif : Diagnostiquer un test asynchrone intermittent lié à un mock d'API, puis écrire un test manquant pour un composant testé au mauvais niveau.
Contexte
Le test ListeProduits.test.js mocke recupererProduits avec vi.fn().mockResolvedValue([{ id: 1, nom: 'Clavier' }]), monte le composant avec mount(ListeProduits), puis vérifie immédiatement expect(wrapper.text()).toContain('Clavier') sans rien entre les deux. Sur la CI, ce test échoue environ une fois sur trois avec un message indiquant que "Clavier" n'apparaît pas dans le texte du composant, alors qu'en local il semble toujours passer.