Frameworks

Vitest : Guide Complet pour Tester Vos Applications Vue et Nuxt

Mark Toledo

Mark Toledo

13 mars 2026

Vitest : Guide Complet pour Tester Vos Applications Vue et Nuxt

Tester ses applications Vue et Nuxt n'est plus une option : c'est une nécessité pour livrer du code fiable et maintenable. Vitest s'est imposé comme le framework de test de référence dans l'écosystème Vue, remplaçant progressivement Jest grâce à sa vitesse fulgurante et son intégration native avec Vite. Dans ce guide, je vous montre comment configurer Vitest, écrire des tests de composants, tester vos composables et atteindre une couverture de code solide.

Pourquoi Vitest plutôt que Jest ?

Avant de plonger dans la configuration, comprenons pourquoi Vitest a conquis la communauté Vue :

  • Compatibilité Vite : Vitest réutilise la configuration Vite de votre projet. Pas de double configuration.
  • Rapidité : le mode watch est quasi instantané grâce au HMR de Vite.
  • API compatible Jest : describe, it, expect, vi.fn() — la migration est transparente.
  • Support ESM natif : fini les problèmes de transformation de modules.
  • Mode UI intégré : une interface web pour explorer vos tests visuellement.

En résumé, si votre projet utilise Vite (ce qui est le cas de Vue 3 et Nuxt 3), Vitest est le choix naturel.

Installation et Configuration

Projet Vue 3 avec Vite

Commençons par un projet Vue 3 classique :

npm install -D vitest @vue/test-utils happy-dom

Ajoutez la configuration Vitest dans votre vite.config.ts :

/// <reference types="vitest" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'happy-dom',
    include: ['src/**/*.{test,spec}.{js,ts}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{vue,ts}'],
      exclude: ['src/**/*.spec.ts', 'src/**/*.test.ts']
    }
  }
})

Ajoutez les scripts dans votre package.json :

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

Projet Nuxt 3

Pour Nuxt 3, l'installation est encore plus simple grâce au module dédié :

npx nuxi module add @nuxt/test-utils
npm install -D vitest @vue/test-utils happy-dom

Créez un fichier vitest.config.ts à la racine :

import { defineVitestConfig } from '@nuxt/test-utils/config'

export default defineVitestConfig({
  test: {
    globals: true,
    environment: 'nuxt',
    environmentOptions: {
      nuxt: {
        domEnvironment: 'happy-dom'
      }
    }
  }
})

Le module @nuxt/test-utils gère automatiquement les auto-imports, le routing et les composables Nuxt dans vos tests.

Tester des Composants Vue 3

Premier test de composant

Prenons un composant simple :

<!-- src/components/Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
</script>

<template>
  <div class="counter">
    <button data-testid="decrement" @click="decrement">-</button>
    <span data-testid="count">{{ count }}</span>
    <button data-testid="increment" @click="increment">+</button>
  </div>
</template>

Voici le test correspondant :

// src/components/__tests__/Counter.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'

describe('Counter', () => {
  it('affiche le compteur initial à 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
  })

  it('incrémente le compteur au clic sur +', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('[data-testid="increment"]').trigger('click')
    expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
  })

  it('décrémente le compteur au clic sur -', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('[data-testid="increment"]').trigger('click')
    await wrapper.find('[data-testid="increment"]').trigger('click')
    await wrapper.find('[data-testid="decrement"]').trigger('click')
    expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
  })
})

Tester un composant avec props et événements

Les composants réels reçoivent des props et émettent des événements :

<!-- src/components/SearchBar.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{
  placeholder?: string
  minLength?: number
}>()

const emit = defineEmits<{
  search: [query: string]
}>()

const query = ref('')

const handleSearch = () => {
  if (query.value.length >= (props.minLength ?? 3)) {
    emit('search', query.value)
  }
}
</script>

<template>
  <form @submit.prevent="handleSearch">
    <input
      v-model="query"
      :placeholder="placeholder ?? 'Rechercher...'"
      data-testid="search-input"
    />
    <button type="submit" data-testid="search-button">Chercher</button>
  </form>
</template>
// src/components/__tests__/SearchBar.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchBar from '../SearchBar.vue'

describe('SearchBar', () => {
  it('affiche le placeholder par défaut', () => {
    const wrapper = mount(SearchBar)
    const input = wrapper.find('[data-testid="search-input"]')
    expect(input.attributes('placeholder')).toBe('Rechercher...')
  })

  it('affiche un placeholder personnalisé', () => {
    const wrapper = mount(SearchBar, {
      props: { placeholder: 'Tapez ici...' }
    })
    const input = wrapper.find('[data-testid="search-input"]')
    expect(input.attributes('placeholder')).toBe('Tapez ici...')
  })

  it('émet l\'événement search avec la requête', async () => {
    const wrapper = mount(SearchBar)
    const input = wrapper.find('[data-testid="search-input"]')

    await input.setValue('vue testing')
    await wrapper.find('form').trigger('submit')

    expect(wrapper.emitted('search')).toHaveLength(1)
    expect(wrapper.emitted('search')![0]).toEqual(['vue testing'])
  })

  it('n\'émet pas si la requête est trop courte', async () => {
    const wrapper = mount(SearchBar, {
      props: { minLength: 5 }
    })
    const input = wrapper.find('[data-testid="search-input"]')

    await input.setValue('vue')
    await wrapper.find('form').trigger('submit')

    expect(wrapper.emitted('search')).toBeUndefined()
  })
})

Tester avec des slots

import { mount } from '@vue/test-utils'
import Card from '../Card.vue'

it('affiche le contenu du slot par défaut', () => {
  const wrapper = mount(Card, {
    slots: {
      default: '<p>Contenu de la carte</p>',
      header: '<h2>Titre</h2>'
    }
  })

  expect(wrapper.html()).toContain('Contenu de la carte')
  expect(wrapper.html()).toContain('Titre')
})

Tester des Composables

Les composables sont au cœur de Vue 3 et méritent des tests dédiés. Vitest excelle dans ce domaine.

Composable simple

// src/composables/useLocalStorage.ts
import { ref, watch } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key)
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)

  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })

  const remove = () => {
    localStorage.removeItem(key)
    data.value = defaultValue
  }

  return { data, remove }
}
// src/composables/__tests__/useLocalStorage.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useLocalStorage } from '../useLocalStorage'
import { nextTick } from 'vue'

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear()
  })

  it('retourne la valeur par défaut si rien n\'est stocké', () => {
    const { data } = useLocalStorage('theme', 'light')
    expect(data.value).toBe('light')
  })

  it('lit la valeur existante dans localStorage', () => {
    localStorage.setItem('lang', JSON.stringify('fr'))
    const { data } = useLocalStorage('lang', 'en')
    expect(data.value).toBe('fr')
  })

  it('persiste les changements dans localStorage', async () => {
    const { data } = useLocalStorage('counter', 0)
    data.value = 42
    await nextTick()
    expect(JSON.parse(localStorage.getItem('counter')!)).toBe(42)
  })

  it('supprime la clé avec remove()', async () => {
    localStorage.setItem('temp', JSON.stringify('value'))
    const { data, remove } = useLocalStorage('temp', 'default')
    remove()
    await nextTick()
    expect(data.value).toBe('default')
    expect(localStorage.getItem('temp')).toBeNull()
  })
})

Composable avec appel API

Les composables qui font des appels réseau nécessitent du mocking :

// src/composables/useFetchUsers.ts
import { ref } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

export function useFetchUsers() {
  const users = ref<User[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  const fetchUsers = async () => {
    loading.value = true
    error.value = null
    try {
      const response = await fetch('/api/users')
      if (!response.ok) throw new Error('Erreur réseau')
      users.value = await response.json()
    } catch (e) {
      error.value = (e as Error).message
    } finally {
      loading.value = false
    }
  }

  return { users, loading, error, fetchUsers }
}
// src/composables/__tests__/useFetchUsers.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFetchUsers } from '../useFetchUsers'

describe('useFetchUsers', () => {
  beforeEach(() => {
    vi.restoreAllMocks()
  })

  it('charge les utilisateurs avec succès', async () => {
    const mockUsers = [
      { id: 1, name: 'Alice', email: '[email protected]' },
      { id: 2, name: 'Bob', email: '[email protected]' }
    ]

    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUsers)
    }))

    const { users, loading, error, fetchUsers } = useFetchUsers()

    expect(loading.value).toBe(false)
    const promise = fetchUsers()
    expect(loading.value).toBe(true)

    await promise

    expect(users.value).toEqual(mockUsers)
    expect(loading.value).toBe(false)
    expect(error.value).toBeNull()
  })

  it('gère les erreurs réseau', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: false,
      status: 500
    }))

    const { error, fetchUsers } = useFetchUsers()
    await fetchUsers()

    expect(error.value).toBe('Erreur réseau')
  })
})

Mocking Avancé avec Vitest

Mocker un module entier

import { vi } from 'vitest'

// Mock automatique de tout le module
vi.mock('../services/analytics', () => ({
  trackEvent: vi.fn(),
  trackPageView: vi.fn()
}))

Mocker partiellement un module

import { vi } from 'vitest'

vi.mock('../utils/helpers', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../utils/helpers')>()
  return {
    ...actual,
    formatDate: vi.fn(() => '13/03/2026') // seule cette fonction est mockée
  }
})

Mocker les timers

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('exécute la fonction après le délai', () => {
    const fn = vi.fn()
    const debounced = debounce(fn, 300)

    debounced()
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(300)
    expect(fn).toHaveBeenCalledOnce()
  })
})

Spy sur des méthodes

it('appelle console.warn pour les props invalides', () => {
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

  mount(MonComposant, {
    props: { value: -1 }
  })

  expect(warnSpy).toHaveBeenCalledWith(
    expect.stringContaining('valeur négative')
  )
})

Snapshot Testing

Les snapshots sont utiles pour détecter les régressions visuelles dans le rendu HTML de vos composants.

Snapshot du HTML rendu

import { mount } from '@vue/test-utils'
import UserProfile from '../UserProfile.vue'

it('correspond au snapshot', () => {
  const wrapper = mount(UserProfile, {
    props: {
      name: 'Marie Dupont',
      role: 'Développeuse',
      avatar: '/images/marie.webp'
    }
  })

  expect(wrapper.html()).toMatchSnapshot()
})

Le premier lancement crée un fichier __snapshots__/UserProfile.spec.ts.snap. Les lancements suivants comparent le rendu au snapshot enregistré.

Snapshot inline

Pour les petits composants, les snapshots inline sont plus lisibles :

it('génère le badge correct', () => {
  const wrapper = mount(Badge, {
    props: { status: 'active' }
  })

  expect(wrapper.html()).toMatchInlineSnapshot(`
    "<span class=\\"badge badge-active\\">Actif</span>"
  `)
})

Mettre à jour les snapshots

Quand un changement est intentionnel :

# Met à jour tous les snapshots obsolètes
npx vitest run --update

Couverture de Code

Configurer la couverture

Vitest supporte deux providers de couverture : v8 (rapide, natif) et istanbul (plus précis). Pour la plupart des projets, v8 suffit :

npm install -D @vitest/coverage-v8
// vite.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{vue,ts}'],
      exclude: [
        'src/**/*.spec.ts',
        'src/**/*.test.ts',
        'src/types/**',
        'src/main.ts'
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80
      }
    }
  }
})

Lancer les rapports de couverture

npx vitest run --coverage

Le rapport HTML est généré dans ./coverage/index.html. Ouvrez-le dans votre navigateur pour voir les fichiers non couverts et les lignes manquées.

Intégrer la couverture en CI

# .github/workflows/test.yml
- name: Tests avec couverture
  run: npx vitest run --coverage

- name: Upload couverture
  uses: actions/upload-artifact@v4
  with:
    name: coverage-report
    path: coverage/

Tester des Composants Nuxt Spécifiques

Composant avec useFetch

// tests/components/ArticleList.spec.ts
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import ArticleList from '~/components/ArticleList.vue'

describe('ArticleList', () => {
  it('affiche la liste des articles', async () => {
    const wrapper = await mountSuspended(ArticleList)
    expect(wrapper.findAll('article').length).toBeGreaterThan(0)
  })
})

Tester une page Nuxt

import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import IndexPage from '~/pages/index.vue'

describe('Page d\'accueil', () => {
  it('contient le titre principal', async () => {
    const wrapper = await mountSuspended(IndexPage)
    expect(wrapper.find('h1').exists()).toBe(true)
  })
})

Tester le routing

import { describe, it, expect } from 'vitest'
import { renderSuspended } from '@nuxt/test-utils/runtime'
import { screen } from '@testing-library/vue'
import App from '~/app.vue'

describe('Navigation', () => {
  it('navigue vers la page à propos', async () => {
    const { router } = await renderSuspended(App, {
      route: '/a-propos'
    })

    expect(router.currentRoute.value.path).toBe('/a-propos')
    expect(screen.getByText('À propos')).toBeDefined()
  })
})

Bonnes Pratiques

Structurer vos tests

Adoptez une convention claire pour l'emplacement des fichiers de test :

src/
├── components/
│   ├── Counter.vue
│   └── __tests__/
│       └── Counter.spec.ts
├── composables/
│   ├── useAuth.ts
│   └── __tests__/
│       └── useAuth.spec.ts
└── utils/
    ├── format.ts
    └── __tests__/
        └── format.spec.ts

Écrire des tests maintenables

Quelques principes pour des tests durables :

  1. Testez le comportement, pas l'implémentation : vérifiez ce que l'utilisateur voit, pas les détails internes.
  2. Utilisez data-testid : évitez de sélectionner par classes CSS qui changent souvent.
  3. Un assert par test quand c'est possible : facilite le diagnostic des échecs.
  4. Nommez clairement : le nom du test doit décrire le scénario et le résultat attendu.
// ❌ Fragile — dépend de la structure CSS
wrapper.find('.btn-primary.large')

// ✅ Résilient — attribut dédié aux tests
wrapper.find('[data-testid="submit-button"]')

Fixtures et factories

Pour les données de test répétitives, créez des factories :

// tests/factories/user.ts
interface UserFixture {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

let nextId = 1

export function createUser(overrides: Partial<UserFixture> = {}): UserFixture {
  return {
    id: nextId++,
    name: 'Utilisateur Test',
    email: `user-${nextId}@test.com`,
    role: 'user',
    ...overrides
  }
}
// Utilisation dans les tests
import { createUser } from '../factories/user'

it('affiche le badge admin', () => {
  const admin = createUser({ role: 'admin' })
  const wrapper = mount(UserBadge, { props: { user: admin } })
  expect(wrapper.text()).toContain('Administrateur')
})

Conclusion

Vitest transforme l'expérience de test dans l'écosystème Vue et Nuxt. Sa vitesse, son intégration native avec Vite et sa compatibilité avec l'API Jest en font un outil indispensable. L'investissement dans les tests se rentabilise dès le premier bug intercepté avant la production.

Pour aller plus loin, explorez la documentation officielle de Vitest et expérimentez avec les tests E2E via Playwright, qui complète parfaitement Vitest pour les scénarios d'intégration. Commencez par tester vos composables et utilitaires — ce sont les tests les plus simples à écrire et les plus rentables en termes de confiance dans votre code.