Vitest : tester Vue 3 et Nuxt sans doubler sa configuration
Mark Toledo
13 mars 2026

Ce qui m'a fait basculer de Jest à Vitest, ce n'est pas la vitesse. C'est d'avoir supprimé un fichier de configuration. Sur mon projet Nuxt de l'époque, je maintenais un jest.config.js avec ses alias de modules, sa transformation babel-jest, son moduleNameMapper pour les .vue et ses transformIgnorePatterns pour les dépendances en ESM. Quatre blocs de configuration qui décrivaient, en moins bien, ce que vite.config.ts savait déjà.
Vitest lit directement cette configuration. Les alias, les plugins, les variables d'environnement : tout est déjà résolu.
Pourquoi j'ai quitté Jest
Quatre différences comptent vraiment au quotidien.
La première est la configuration unique dont je viens de parler. La deuxième est le mode watch : Vitest s'appuie sur le HMR de Vite et ne re-exécute que les fichiers touchés par votre modification, là où Jest reconstruit son graphe de dépendances. Sur une suite de plusieurs centaines de tests, la différence se sent à chaque sauvegarde.
La troisième est le support ESM natif. Si vous avez déjà passé une soirée sur un Cannot use import statement outside a module en essayant de deviner quelle dépendance transitive publiait en ESM, vous savez ce que ça vaut.
La quatrième est la compatibilité d'API. describe, it, expect, vi.fn() : la migration depuis Jest se fait à coups de rechercher-remplacer sur jest. → vi.. J'ai converti une suite de 180 tests en une matinée, sans réécrire une seule assertion.
Il reste un mode UI intégré, lançable avec --ui, que j'utilise surtout pour comprendre pourquoi un test échoue en CI et pas en local.
Si votre projet tourne sur Vite, et c'est le cas de tout Vue 3 et de tout Nuxt 3, la question ne se pose plus vraiment.
Installation
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, 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')
})
Les composables, là où les tests deviennent vraiment rentables
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: 'alice@test.com' },
{ id: 2, name: 'Bob', email: 'bob@test.com' }
]
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
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')
)
})
Snapshots : puissants, et redoutables si vous les validez sans les lire
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
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/
Le cas Nuxt
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()
})
})
Mes règles après quelques centaines de tests écrits
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 :
- Testez le comportement, pas l'implémentation : vérifiez ce que l'utilisateur voit, pas les détails internes.
- Utilisez
data-testid: évitez de sélectionner par classes CSS qui changent souvent. - Un assert par test quand c'est possible : facilite le diagnostic des échecs.
- 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')
})
Par où commencer
Si vous partez de zéro, ne commencez pas par les composants. Je le dis parce que je l'ai fait dans le mauvais ordre : j'ai attaqué par les vues, je me suis noyé dans le montage, les stubs et les nextTick, et j'ai abandonné au bout de trois jours.
Commencez par vos composables et vos fonctions utilitaires. Ce sont des fonctions pures ou presque, elles se testent sans monter le moindre composant, et ce sont elles qui portent la logique métier que vous avez peur de casser. Une suite de trente tests sur les composables vous apportera plus de tranquillité que cent tests de rendu.
Ensuite seulement, remontez vers les composants, en testant ce que voit l'utilisateur plutôt que la structure interne.
Pour la suite, la documentation Vitest, celle de Vue Test Utils et le module @nuxt/test-utils couvrent les cas que ce guide n'aborde pas. Et quand vous voudrez couvrir des parcours complets plutôt que des unités, Playwright prend le relais : il ne remplace pas Vitest, il teste ce que Vitest ne peut pas voir.
