Retour au cours

frontend / vuejs

Tests avec Vitest & Vue Testing Library

Leçon 181 exercice

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-utils et 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.

OutilNiveau d'accèsPhilosophie
@vue/test-utilsBas niveau, instance du composantAccès technique précis
@testing-library/vueHaut niveau, DOM réelCentré 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.

bash
npm install -D vitest @vue/test-utils @testing-library/vue jsdom
js
// 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
  },
})
js
// 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 }
}
js
// 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)
  })
})
js
// 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])
  })
})
js
// 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' })
  })
})
js
// 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.mock isole les dépendances externes (API, dates, timers) pour des tests déterministes et rapides.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →