Table of Contents
Tests unitaires Vue.js : Guide complet avec Jest et Vue Test Utils
Introduction : Pourquoi les tests unitaires sont essentiels
Les tests unitaires constituent la premiere ligne de defense contre les bugs dans vos applications Vue.js. Contrairement aux tests d’integration ou aux tests end-to-end, les tests unitaires se concentrent sur des unites isolees de code - generalement des composants individuels ou des fonctions specifiques.
Les avantages des tests unitaires
Detection precoce des bugs : Les tests unitaires permettent de detecter les problemes des les premieres phases du developpement, quand ils sont encore faciles et peu couteux a corriger.
Documentation vivante : Des tests bien ecrits servent de documentation executable, montrant comment chaque composant est cense fonctionner.
Refactoring en confiance : Avec une bonne couverture de tests, vous pouvez refactoriser votre code en sachant immediatement si quelque chose cesse de fonctionner.
Conception amelioree : Ecrire des tests vous pousse naturellement vers une architecture plus modulaire et decouplée.
Le cout de l’absence de tests
Sans tests unitaires, chaque modification du code devient un risque. Les regressions passent inapercues jusqu’a ce qu’un utilisateur les signale. Le cout de correction d’un bug en production peut etre 100 fois superieur a celui d’un bug detecte pendant le developpement.
Installation et configuration complete de Jest
Installation des dependances
Pour configurer Jest avec Vue 3 et TypeScript, installez les packages necessaires :
# Installation de Jest et ses dependances
npm install --save-dev jest @types/jest ts-jest
# Installation de Vue Test Utils
npm install --save-dev @vue/test-utils
# Installation des transformateurs
npm install --save-dev @vue/vue3-jest babel-jest
# Installation de l'environnement DOM
npm install --save-dev jest-environment-jsdom
Configuration complete de jest.config.js
Creez un fichier jest.config.js a la racine de votre projet :
module.exports = {
// Preset pour TypeScript
preset: 'ts-jest',
// Extensions de fichiers supportees
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'vue', 'json'],
// Transformations des fichiers
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.(js|jsx)$': 'babel-jest',
'^.+\\.(ts|tsx)$': 'ts-jest'
},
// Environnement de test (DOM simule)
testEnvironment: 'jsdom',
// Pattern pour les fichiers de test
testMatch: [
'**/tests/unit/**/*.spec.[jt]s?(x)',
'**/__tests__/**/*.[jt]s?(x)'
],
// Alias de modules (doit correspondre a tsconfig.json)
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@stores/(.*)$': '<rootDir>/src/stores/$1'
},
// Fichiers de setup executes avant chaque test
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
// Seuils de couverture de code
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// Fichiers a inclure dans la couverture
collectCoverageFrom: [
'src/**/*.{js,ts,vue}',
'!src/main.ts',
'!src/**/*.d.ts',
'!src/router/**',
'!**/node_modules/**'
],
// Limite de temps pour les tests
testTimeout: 10000,
// Options pour les tests en parallele
maxWorkers: '50%'
};
Configuration du fichier setup.ts
Creez tests/setup.ts pour configurer l’environnement de test :
import { config } from '@vue/test-utils';
// Configuration globale de Vue Test Utils
config.global.stubs = {
// Stuber les composants de transition
transition: false,
'transition-group': false
};
// Mock de ResizeObserver (non disponible dans jsdom)
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// Mock de IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
observe() { return null; }
unobserve() { return null; }
disconnect() { return null; }
};
// Nettoyer les mocks apres chaque test
afterEach(() => {
jest.clearAllMocks();
});
Scripts npm pour les tests
Ajoutez ces scripts dans votre package.json :
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2",
"test:update": "jest --updateSnapshot"
}
}
shallowMount vs mount : Comprendre la difference
Vue Test Utils offre deux methodes principales pour monter un composant : mount et shallowMount. Comprendre leurs differences est crucial pour ecrire des tests efficaces.
mount : Rendu complet
mount cree un rendu complet du composant, incluant tous ses composants enfants :
import { mount } from '@vue/test-utils';
import ParentComponent from '@/components/ParentComponent.vue';
describe('ParentComponent avec mount', () => {
it('rend tous les composants enfants', () => {
const wrapper = mount(ParentComponent);
// Les composants enfants sont entierement rendus
expect(wrapper.findComponent(ChildComponent).exists()).toBe(true);
expect(wrapper.find('.child-element').exists()).toBe(true);
});
});
Avantages de mount :
- Test du comportement reel de l’arborescence de composants
- Verification des interactions entre parent et enfants
- Tests d’integration au niveau du composant
Inconvenients de mount :
- Plus lent (tous les enfants sont rendus)
- Tests plus fragiles (dependent des implementations des enfants)
- Necessite de mocker les dependances des enfants
shallowMount : Rendu superficiel
shallowMount ne rend que le composant lui-meme, en remplacant les composants enfants par des stubs :
import { shallowMount } from '@vue/test-utils';
import ParentComponent from '@/components/ParentComponent.vue';
describe('ParentComponent avec shallowMount', () => {
it('stub les composants enfants', () => {
const wrapper = shallowMount(ParentComponent);
// Les composants enfants sont stubbes
expect(wrapper.findComponent(ChildComponent).exists()).toBe(true);
// Mais leur contenu interne n'est pas rendu
expect(wrapper.find('.child-internal-element').exists()).toBe(false);
});
});
Avantages de shallowMount :
- Plus rapide (pas de rendu des enfants)
- Tests isoles (ne testent que le composant cible)
- Moins de mocks necessaires
Inconvenients de shallowMount :
- Peut masquer des bugs d’integration
- Les slots ne sont pas toujours rendus correctement
Quand utiliser chaque methode
| Situation | Methode recommandee |
|---|---|
| Test d’un composant simple sans enfants | shallowMount |
| Test des props et emits d’un composant | shallowMount |
| Test d’interaction parent-enfant | mount |
| Test de slots avec contenu complexe | mount |
| Tests de performance (nombreux tests) | shallowMount |
| Tests d’accessibilite | mount |
Simulation des evenements avec trigger
La methode trigger permet de simuler des evenements DOM sur les elements d’un composant.
Evenements de base
import { mount } from '@vue/test-utils';
import ButtonComponent from '@/components/ButtonComponent.vue';
describe('ButtonComponent', () => {
it('emet un evenement click', async () => {
const wrapper = mount(ButtonComponent);
const button = wrapper.find('button');
await button.trigger('click');
expect(wrapper.emitted()).toHaveProperty('click');
expect(wrapper.emitted('click')).toHaveLength(1);
});
});
Evenements avec modificateurs
describe('Evenements avec modificateurs', () => {
it('detecte Ctrl+Click', async () => {
const wrapper = mount(AdvancedButton);
await wrapper.find('button').trigger('click', {
ctrlKey: true
});
expect(wrapper.emitted('ctrl-click')).toBeTruthy();
});
it('detecte la touche Enter', async () => {
const wrapper = mount(SearchInput);
await wrapper.find('input').trigger('keydown.enter');
expect(wrapper.emitted('search')).toBeTruthy();
});
it('detecte la touche Escape', async () => {
const wrapper = mount(Modal);
await wrapper.find('.modal').trigger('keydown', {
key: 'Escape'
});
expect(wrapper.emitted('close')).toBeTruthy();
});
});
Evenements de formulaire complets
describe('FormComponent', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(FormComponent);
});
it('gere la saisie de texte', async () => {
const input = wrapper.find('input[name="username"]');
await input.setValue('john_doe');
expect(wrapper.vm.formData.username).toBe('john_doe');
});
it('gere la selection dans un dropdown', async () => {
const select = wrapper.find('select');
await select.setValue('option2');
expect(wrapper.vm.selectedOption).toBe('option2');
});
it('gere les checkboxes', async () => {
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
expect(wrapper.vm.isChecked).toBe(true);
});
it('gere la soumission du formulaire', async () => {
// Remplir le formulaire
await wrapper.find('input[name="email"]').setValue('test@example.com');
await wrapper.find('input[name="password"]').setValue('secret123');
// Soumettre
await wrapper.find('form').trigger('submit.prevent');
expect(wrapper.emitted('submit')).toBeTruthy();
expect(wrapper.emitted('submit')[0][0]).toEqual({
email: 'test@example.com',
password: 'secret123'
});
});
});
Simulation d’evenements de souris avances
describe('DragDropComponent', () => {
it('gere le drag and drop', async () => {
const wrapper = mount(DragDropComponent);
const draggable = wrapper.find('.draggable');
const dropzone = wrapper.find('.dropzone');
// Debut du drag
await draggable.trigger('dragstart', {
dataTransfer: {
setData: jest.fn(),
getData: jest.fn().mockReturnValue('item-1')
}
});
// Survol de la zone de drop
await dropzone.trigger('dragover');
expect(dropzone.classes()).toContain('drag-over');
// Drop
await dropzone.trigger('drop', {
dataTransfer: {
getData: jest.fn().mockReturnValue('item-1')
}
});
expect(wrapper.emitted('item-dropped')).toBeTruthy();
});
});
Test des props et emits
Test des props
import { mount } from '@vue/test-utils';
import UserCard from '@/components/UserCard.vue';
describe('UserCard props', () => {
const defaultProps = {
user: {
id: 1,
name: 'John Doe',
email: 'john@example.com',
avatar: '/avatars/john.jpg'
},
showEmail: true,
variant: 'default'
};
it('affiche les informations utilisateur', () => {
const wrapper = mount(UserCard, {
props: defaultProps
});
expect(wrapper.find('.user-name').text()).toBe('John Doe');
expect(wrapper.find('.user-email').text()).toBe('john@example.com');
expect(wrapper.find('img').attributes('src')).toBe('/avatars/john.jpg');
});
it('masque l\'email quand showEmail est false', () => {
const wrapper = mount(UserCard, {
props: {
...defaultProps,
showEmail: false
}
});
expect(wrapper.find('.user-email').exists()).toBe(false);
});
it('applique la variante de style', () => {
const wrapper = mount(UserCard, {
props: {
...defaultProps,
variant: 'compact'
}
});
expect(wrapper.classes()).toContain('user-card--compact');
});
it('valide les props requises', () => {
// Teste que le composant gere correctement les props manquantes
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
mount(UserCard, {
props: {} // Props manquantes
});
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
});
Test des emits
import { mount } from '@vue/test-utils';
import ActionButton from '@/components/ActionButton.vue';
describe('ActionButton emits', () => {
it('emet l\'evenement action avec la payload', async () => {
const wrapper = mount(ActionButton, {
props: {
actionType: 'delete',
itemId: 42
}
});
await wrapper.find('button').trigger('click');
// Verifie que l'evenement a ete emis
expect(wrapper.emitted('action')).toBeTruthy();
// Verifie la payload de l'evenement
const emittedEvents = wrapper.emitted('action');
expect(emittedEvents[0]).toEqual([{
type: 'delete',
id: 42
}]);
});
it('emet plusieurs evenements dans l\'ordre', async () => {
const wrapper = mount(WizardStep, {
props: { step: 1 }
});
await wrapper.find('.next-button').trigger('click');
// Verifie l'ordre des evenements emis
const emitted = wrapper.emitted();
expect(Object.keys(emitted)).toEqual(['validate', 'next']);
});
it('n\'emet pas d\'evenement si la validation echoue', async () => {
const wrapper = mount(ValidatedForm, {
props: {
rules: { required: true }
}
});
// Soumission sans remplir le champ requis
await wrapper.find('form').trigger('submit.prevent');
expect(wrapper.emitted('submit')).toBeFalsy();
expect(wrapper.emitted('validation-error')).toBeTruthy();
});
});
Test des v-model
describe('InputComponent v-model', () => {
it('met a jour la valeur via v-model', async () => {
const wrapper = mount(InputComponent, {
props: {
modelValue: 'initial',
'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
}
});
expect(wrapper.find('input').element.value).toBe('initial');
await wrapper.find('input').setValue('updated');
expect(wrapper.emitted('update:modelValue')).toBeTruthy();
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['updated']);
});
});
Mocking des dependances
Mocking de Pinia Store
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import UserProfile from '@/components/UserProfile.vue';
import { useUserStore } from '@/stores/user';
describe('UserProfile avec Pinia', () => {
it('affiche les donnees du store', () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
initialState: {
user: {
currentUser: {
id: 1,
name: 'Test User',
email: 'test@example.com'
},
isAuthenticated: true
}
}
})
]
}
});
expect(wrapper.find('.user-name').text()).toBe('Test User');
});
it('appelle les actions du store', async () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
stubActions: false
})
]
}
});
const store = useUserStore();
await wrapper.find('.logout-button').trigger('click');
expect(store.logout).toHaveBeenCalled();
});
it('reagit aux changements du store', async () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [createTestingPinia()]
}
});
const store = useUserStore();
// Etat initial
expect(wrapper.find('.login-prompt').exists()).toBe(true);
// Simuler une connexion
store.isAuthenticated = true;
store.currentUser = { name: 'New User' };
await wrapper.vm.$nextTick();
expect(wrapper.find('.login-prompt').exists()).toBe(false);
expect(wrapper.find('.user-name').text()).toBe('New User');
});
});
Mocking de Vue Router
import { mount, flushPromises } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import NavigationComponent from '@/components/NavigationComponent.vue';
describe('NavigationComponent avec Router', () => {
let router;
beforeEach(async () => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{ path: '/about', name: 'about', component: { template: '<div>About</div>' } },
{ path: '/user/:id', name: 'user', component: { template: '<div>User</div>' } }
]
});
await router.push('/');
await router.isReady();
});
it('navigue vers une nouvelle route', async () => {
const wrapper = mount(NavigationComponent, {
global: {
plugins: [router]
}
});
await wrapper.find('a[href="/about"]').trigger('click');
await flushPromises();
expect(router.currentRoute.value.name).toBe('about');
});
it('passe les parametres de route', async () => {
const wrapper = mount(NavigationComponent, {
global: {
plugins: [router]
}
});
await wrapper.find('.user-link').trigger('click');
await flushPromises();
expect(router.currentRoute.value.params.id).toBe('123');
});
it('utilise les query params', () => {
const mockRoute = {
query: { search: 'test', page: '2' }
};
const wrapper = mount(SearchResults, {
global: {
mocks: {
$route: mockRoute
}
}
});
expect(wrapper.vm.searchQuery).toBe('test');
expect(wrapper.vm.currentPage).toBe(2);
});
});
Mocking d’API et services externes
import { mount, flushPromises } from '@vue/test-utils';
import axios from 'axios';
import UserList from '@/components/UserList.vue';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('UserList avec API mockee', () => {
const mockUsers = [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' },
{ id: 3, name: 'User 3' }
];
beforeEach(() => {
jest.clearAllMocks();
});
it('charge et affiche les utilisateurs', async () => {
mockedAxios.get.mockResolvedValueOnce({ data: mockUsers });
const wrapper = mount(UserList);
// Attendre que la promesse soit resolue
await flushPromises();
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users');
expect(wrapper.findAll('.user-item')).toHaveLength(3);
});
it('affiche un message d\'erreur en cas d\'echec', async () => {
mockedAxios.get.mockRejectedValueOnce(new Error('Network Error'));
const wrapper = mount(UserList);
await flushPromises();
expect(wrapper.find('.error-message').text()).toContain('Network Error');
});
it('affiche un loader pendant le chargement', async () => {
let resolvePromise;
mockedAxios.get.mockImplementation(() => new Promise(resolve => {
resolvePromise = resolve;
}));
const wrapper = mount(UserList);
expect(wrapper.find('.loading-spinner').exists()).toBe(true);
resolvePromise({ data: mockUsers });
await flushPromises();
expect(wrapper.find('.loading-spinner').exists()).toBe(false);
});
});
Tests avec snapshots
Les snapshots permettent de detecter les changements inattendus dans le rendu de vos composants.
Snapshot basique
import { mount } from '@vue/test-utils';
import Card from '@/components/Card.vue';
describe('Card snapshots', () => {
it('correspond au snapshot de base', () => {
const wrapper = mount(Card, {
props: {
title: 'Mon Titre',
content: 'Mon contenu de carte'
}
});
expect(wrapper.html()).toMatchSnapshot();
});
it('correspond au snapshot avec slot', () => {
const wrapper = mount(Card, {
props: { title: 'Carte avec Slot' },
slots: {
default: '<p>Contenu personnalise</p>',
footer: '<button>Action</button>'
}
});
expect(wrapper.html()).toMatchSnapshot();
});
});
Snapshots inline
describe('Badge snapshots inline', () => {
it('rend un badge de succes', () => {
const wrapper = mount(Badge, {
props: { variant: 'success', label: 'Actif' }
});
expect(wrapper.html()).toMatchInlineSnapshot(`
"<span class=\\"badge badge--success\\">Actif</span>"
`);
});
it('rend un badge d\'erreur', () => {
const wrapper = mount(Badge, {
props: { variant: 'error', label: 'Echec' }
});
expect(wrapper.html()).toMatchInlineSnapshot(`
"<span class=\\"badge badge--error\\">Echec</span>"
`);
});
});
Bonnes pratiques pour les snapshots
describe('Bonnes pratiques snapshots', () => {
// Bon : snapshot d'un element specifique
it('snapshot du header uniquement', () => {
const wrapper = mount(PageLayout);
expect(wrapper.find('header').html()).toMatchSnapshot();
});
// Bon : serializer personnalise pour ignorer les attributs dynamiques
it('snapshot sans IDs dynamiques', () => {
const wrapper = mount(DynamicComponent);
const html = wrapper.html().replace(/id="[^"]*"/g, 'id="DYNAMIC"');
expect(html).toMatchSnapshot();
});
// Mauvais : snapshot de composant trop large
// it('snapshot de toute la page', () => { ... }) // A eviter
});
Tests asynchrones et flushPromises
Utilisation de flushPromises
import { mount, flushPromises } from '@vue/test-utils';
import AsyncComponent from '@/components/AsyncComponent.vue';
describe('AsyncComponent', () => {
it('attend la resolution de toutes les promesses', async () => {
const wrapper = mount(AsyncComponent);
// flushPromises attend que toutes les promesses pending soient resolues
await flushPromises();
expect(wrapper.find('.loaded-content').exists()).toBe(true);
});
it('gere plusieurs cycles async', async () => {
const wrapper = mount(MultiStepAsync);
// Premier cycle : chargement initial
await flushPromises();
expect(wrapper.vm.step).toBe(1);
// Declencher le prochain chargement
await wrapper.find('.next').trigger('click');
await flushPromises();
expect(wrapper.vm.step).toBe(2);
});
});
Test avec nextTick
describe('Tests avec nextTick', () => {
it('attend la mise a jour du DOM', async () => {
const wrapper = mount(Counter);
// Incrementer le compteur
wrapper.vm.count++;
// Le DOM n'est pas encore mis a jour
expect(wrapper.find('.count').text()).toBe('0');
// Attendre la mise a jour du DOM
await wrapper.vm.$nextTick();
expect(wrapper.find('.count').text()).toBe('1');
});
});
Test de debounce et throttle
describe('Tests avec timers', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('debounce la recherche', async () => {
const searchFn = jest.fn();
const wrapper = mount(SearchInput, {
props: { onSearch: searchFn }
});
// Saisie rapide (multiple keystrokes)
await wrapper.find('input').setValue('t');
await wrapper.find('input').setValue('te');
await wrapper.find('input').setValue('tes');
await wrapper.find('input').setValue('test');
// La fonction n'a pas encore ete appelee (debounce)
expect(searchFn).not.toHaveBeenCalled();
// Avancer le temps de 300ms (duree du debounce)
jest.advanceTimersByTime(300);
// Maintenant la fonction est appelee une seule fois
expect(searchFn).toHaveBeenCalledTimes(1);
expect(searchFn).toHaveBeenCalledWith('test');
});
it('throttle les clics', async () => {
const clickHandler = jest.fn();
const wrapper = mount(ThrottledButton, {
props: { onClick: clickHandler }
});
const button = wrapper.find('button');
// Premier clic : passe
await button.trigger('click');
expect(clickHandler).toHaveBeenCalledTimes(1);
// Clics rapides : bloques par throttle
await button.trigger('click');
await button.trigger('click');
expect(clickHandler).toHaveBeenCalledTimes(1);
// Attendre la fin du throttle (1 seconde)
jest.advanceTimersByTime(1000);
// Nouveau clic : passe
await button.trigger('click');
expect(clickHandler).toHaveBeenCalledTimes(2);
});
});
Test de watchers asynchrones
describe('Tests de watchers', () => {
it('declenche le watcher apres changement de prop', async () => {
const wrapper = mount(WatcherComponent, {
props: { userId: 1 }
});
// Changer la prop
await wrapper.setProps({ userId: 2 });
// Attendre le watcher et ses effets async
await flushPromises();
expect(wrapper.vm.userData.id).toBe(2);
});
});
Couverture de code
Configuration de la couverture
// Dans jest.config.js
module.exports = {
// ... autres configs
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
collectCoverageFrom: [
'src/**/*.{js,ts,vue}',
'!src/main.ts',
'!src/**/*.d.ts',
'!src/router/index.ts',
'!src/**/*.stories.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
// Seuils specifiques par fichier/dossier
'./src/components/': {
branches: 90,
functions: 90
},
'./src/utils/': {
lines: 100
}
}
};
Interpretation du rapport de couverture
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 85.71 | 78.57 | 90.00 | 85.71 |
Button.vue | 100.0 | 100.0 | 100.0 | 100.0 |
Form.vue | 75.00 | 66.67 | 80.00 | 75.00 | 45-52,78
Modal.vue | 82.14 | 68.75 | 90.00 | 82.14 | 23,67-70
-------------|---------|----------|---------|---------|-------------------
| Metrique | Description |
|---|---|
| Statements | Pourcentage d’instructions executees |
| Branches | Pourcentage de branches conditionnelles couvertes |
| Functions | Pourcentage de fonctions appelees |
| Lines | Pourcentage de lignes executees |
Ameliorer la couverture
// Exemple : couvrir toutes les branches d'une fonction
function getUserStatus(user: User): string {
if (!user) return 'unknown'; // Branche 1
if (user.isAdmin) return 'admin'; // Branche 2
if (user.isPremium) return 'premium'; // Branche 3
return 'standard'; // Branche 4
}
describe('getUserStatus - couverture complete', () => {
it('retourne unknown si user est null', () => {
expect(getUserStatus(null)).toBe('unknown');
});
it('retourne admin si user est admin', () => {
expect(getUserStatus({ isAdmin: true })).toBe('admin');
});
it('retourne premium si user est premium', () => {
expect(getUserStatus({ isPremium: true })).toBe('premium');
});
it('retourne standard par defaut', () => {
expect(getUserStatus({ })).toBe('standard');
});
});
Exemples de tests complets
Test d’un composant de formulaire complet
import { mount, flushPromises } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import ContactForm from '@/components/ContactForm.vue';
import { useNotificationStore } from '@/stores/notifications';
describe('ContactForm - Suite complete', () => {
let wrapper;
let notificationStore;
const createWrapper = (options = {}) => {
return mount(ContactForm, {
global: {
plugins: [createTestingPinia()],
stubs: {
teleport: true
}
},
...options
});
};
beforeEach(() => {
wrapper = createWrapper();
notificationStore = useNotificationStore();
});
describe('Rendu initial', () => {
it('affiche tous les champs du formulaire', () => {
expect(wrapper.find('input[name="name"]').exists()).toBe(true);
expect(wrapper.find('input[name="email"]').exists()).toBe(true);
expect(wrapper.find('select[name="subject"]').exists()).toBe(true);
expect(wrapper.find('textarea[name="message"]').exists()).toBe(true);
expect(wrapper.find('button[type="submit"]').exists()).toBe(true);
});
it('desactive le bouton submit initialement', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined();
});
});
describe('Validation', () => {
it('affiche une erreur pour un email invalide', async () => {
await wrapper.find('input[name="email"]').setValue('invalid-email');
await wrapper.find('input[name="email"]').trigger('blur');
expect(wrapper.find('.error-email').text()).toContain('Email invalide');
});
it('affiche une erreur si le nom est trop court', async () => {
await wrapper.find('input[name="name"]').setValue('A');
await wrapper.find('input[name="name"]').trigger('blur');
expect(wrapper.find('.error-name').text()).toContain('minimum 2 caracteres');
});
it('active le bouton quand le formulaire est valide', async () => {
await wrapper.find('input[name="name"]').setValue('John Doe');
await wrapper.find('input[name="email"]').setValue('john@example.com');
await wrapper.find('select[name="subject"]').setValue('general');
await wrapper.find('textarea[name="message"]').setValue('Mon message');
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined();
});
});
describe('Soumission', () => {
const fillForm = async () => {
await wrapper.find('input[name="name"]').setValue('John Doe');
await wrapper.find('input[name="email"]').setValue('john@example.com');
await wrapper.find('select[name="subject"]').setValue('general');
await wrapper.find('textarea[name="message"]').setValue('Mon message de test');
};
it('envoie les donnees correctement', async () => {
await fillForm();
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
expect(wrapper.emitted('submit')).toBeTruthy();
expect(wrapper.emitted('submit')[0][0]).toEqual({
name: 'John Doe',
email: 'john@example.com',
subject: 'general',
message: 'Mon message de test'
});
});
it('affiche un loader pendant l\'envoi', async () => {
await fillForm();
// Simuler un envoi lent
const submitPromise = wrapper.find('form').trigger('submit.prevent');
expect(wrapper.find('.loading-spinner').exists()).toBe(true);
await submitPromise;
await flushPromises();
expect(wrapper.find('.loading-spinner').exists()).toBe(false);
});
it('affiche une notification de succes', async () => {
await fillForm();
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
expect(notificationStore.success).toHaveBeenCalledWith('Message envoye avec succes');
});
it('reinitialise le formulaire apres envoi reussi', async () => {
await fillForm();
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
expect(wrapper.find('input[name="name"]').element.value).toBe('');
expect(wrapper.find('input[name="email"]').element.value).toBe('');
});
});
describe('Accessibilite', () => {
it('associe les labels aux inputs', () => {
const nameInput = wrapper.find('input[name="name"]');
const nameLabel = wrapper.find(`label[for="${nameInput.attributes('id')}"]`);
expect(nameLabel.exists()).toBe(true);
});
it('affiche les erreurs avec aria-describedby', async () => {
await wrapper.find('input[name="email"]').setValue('invalid');
await wrapper.find('input[name="email"]').trigger('blur');
const errorId = wrapper.find('.error-email').attributes('id');
expect(wrapper.find('input[name="email"]').attributes('aria-describedby')).toBe(errorId);
});
});
});
Test d’un composant de liste avec pagination
import { mount, flushPromises } from '@vue/test-utils';
import PaginatedList from '@/components/PaginatedList.vue';
describe('PaginatedList', () => {
const mockItems = Array.from({ length: 25 }, (_, i) => ({
id: i + 1,
title: `Item ${i + 1}`
}));
const createWrapper = (props = {}) => {
return mount(PaginatedList, {
props: {
items: mockItems,
itemsPerPage: 10,
...props
}
});
};
describe('Affichage', () => {
it('affiche le bon nombre d\'items par page', () => {
const wrapper = createWrapper();
expect(wrapper.findAll('.list-item')).toHaveLength(10);
});
it('affiche les controles de pagination', () => {
const wrapper = createWrapper();
expect(wrapper.find('.pagination').exists()).toBe(true);
expect(wrapper.find('.page-prev').exists()).toBe(true);
expect(wrapper.find('.page-next').exists()).toBe(true);
});
it('affiche le numero de page actuel', () => {
const wrapper = createWrapper();
expect(wrapper.find('.page-current').text()).toContain('1');
expect(wrapper.find('.page-total').text()).toContain('3');
});
});
describe('Navigation', () => {
it('passe a la page suivante', async () => {
const wrapper = createWrapper();
await wrapper.find('.page-next').trigger('click');
expect(wrapper.vm.currentPage).toBe(2);
expect(wrapper.findAll('.list-item')[0].text()).toContain('Item 11');
});
it('revient a la page precedente', async () => {
const wrapper = createWrapper();
// Aller a la page 2
await wrapper.find('.page-next').trigger('click');
// Revenir a la page 1
await wrapper.find('.page-prev').trigger('click');
expect(wrapper.vm.currentPage).toBe(1);
expect(wrapper.findAll('.list-item')[0].text()).toContain('Item 1');
});
it('desactive prev sur la premiere page', () => {
const wrapper = createWrapper();
expect(wrapper.find('.page-prev').attributes('disabled')).toBeDefined();
});
it('desactive next sur la derniere page', async () => {
const wrapper = createWrapper();
// Aller a la derniere page
await wrapper.find('.page-next').trigger('click');
await wrapper.find('.page-next').trigger('click');
expect(wrapper.find('.page-next').attributes('disabled')).toBeDefined();
});
});
describe('Props reactives', () => {
it('reinitialise la page quand les items changent', async () => {
const wrapper = createWrapper();
// Aller a la page 2
await wrapper.find('.page-next').trigger('click');
expect(wrapper.vm.currentPage).toBe(2);
// Changer les items
await wrapper.setProps({
items: mockItems.slice(0, 5)
});
// Doit revenir a la page 1
expect(wrapper.vm.currentPage).toBe(1);
});
});
});
Jest vs Vitest : Tableau comparatif
| Critere | Jest | Vitest |
|---|---|---|
| Performance | Bon | Excellent (3-5x plus rapide) |
| Configuration | Plus de setup requis | Configuration minimale avec Vite |
| Hot Module Replacement | Non | Oui |
| ESM natif | Support partiel | Support complet |
| API | Mature et stable | Compatible Jest |
| Ecosysteme | Tres large | En croissance |
| Integration Vue 3 | Via vue3-jest | Native |
| Watch mode | Fonctionnel | Ultra-rapide |
| Snapshots | Oui | Oui |
| Coverage | Istanbul / v8 | v8 (plus rapide) |
| Mocking | Jest.mock() | vi.mock() (identique) |
| TypeScript | Via ts-jest | Natif |
| Debugging | Chrome DevTools | UI Vitest + DevTools |
Migration de Jest vers Vitest
// Avant (Jest)
import { jest } from '@jest/globals';
jest.mock('./api');
jest.useFakeTimers();
// Apres (Vitest)
import { vi } from 'vitest';
vi.mock('./api');
vi.useFakeTimers();
Recommandation
- Nouveau projet Vue 3 + Vite : Vitest
- Projet existant avec Jest : Rester sur Jest (sauf si migration justifiee)
- Projet Vue 2 : Jest
- Besoin de compatibilite Node.js : Jest
Bonnes pratiques
1. Suivre le pattern AAA (Arrange, Act, Assert)
it('calcule le total correctement', () => {
// Arrange : preparer les donnees
const wrapper = mount(Cart, {
props: {
items: [
{ name: 'Item 1', price: 10 },
{ name: 'Item 2', price: 20 }
]
}
});
// Act : executer l'action
const total = wrapper.vm.calculateTotal();
// Assert : verifier le resultat
expect(total).toBe(30);
});
2. Un test = une assertion principale
// Bon : un test par comportement
it('affiche le nom de l\'utilisateur', () => {
expect(wrapper.find('.user-name').text()).toBe('John');
});
it('affiche l\'email de l\'utilisateur', () => {
expect(wrapper.find('.user-email').text()).toBe('john@example.com');
});
// Mauvais : trop d'assertions
it('affiche les informations utilisateur', () => {
expect(wrapper.find('.user-name').text()).toBe('John');
expect(wrapper.find('.user-email').text()).toBe('john@example.com');
expect(wrapper.find('.user-avatar').exists()).toBe(true);
// ... 10 autres assertions
});
3. Utiliser des factories pour les donnees de test
// factories/user.ts
export const createUser = (overrides = {}) => ({
id: 1,
name: 'Default User',
email: 'default@example.com',
role: 'user',
...overrides
});
// Dans les tests
import { createUser } from '@/tests/factories/user';
it('affiche un badge admin', () => {
const wrapper = mount(UserBadge, {
props: {
user: createUser({ role: 'admin' })
}
});
expect(wrapper.find('.admin-badge').exists()).toBe(true);
});
4. Eviter les selecteurs fragiles
// Mauvais : selecteurs fragiles
wrapper.find('div > span.text')
wrapper.find('.mt-4.px-2')
// Bon : selecteurs semantiques
wrapper.find('[data-testid="user-name"]')
wrapper.find('.user-name')
wrapper.findComponent(UserName)
5. Tester le comportement, pas l’implementation
// Mauvais : teste l'implementation
it('appelle la methode interne', async () => {
const wrapper = mount(Component);
const spy = jest.spyOn(wrapper.vm, '_internalMethod');
await wrapper.find('button').trigger('click');
expect(spy).toHaveBeenCalled();
});
// Bon : teste le comportement
it('affiche le resultat apres clic', async () => {
const wrapper = mount(Component);
await wrapper.find('button').trigger('click');
expect(wrapper.find('.result').text()).toBe('Success');
});
6. Organiser les tests en suites logiques
describe('LoginForm', () => {
describe('rendu initial', () => {
it('affiche les champs email et password', () => {});
it('desactive le bouton submit', () => {});
});
describe('validation', () => {
it('valide le format email', () => {});
it('require un mot de passe', () => {});
});
describe('soumission', () => {
it('envoie les credentials', () => {});
it('affiche une erreur si echec', () => {});
});
});
Pieges courants a eviter
1. Oublier await sur les operations asynchrones
// Mauvais : le test passe meme si l'assertion est fausse
it('met a jour apres clic', () => {
wrapper.find('button').trigger('click'); // Manque await !
expect(wrapper.vm.count).toBe(1);
});
// Bon : attend que l'evenement soit traite
it('met a jour apres clic', async () => {
await wrapper.find('button').trigger('click');
expect(wrapper.vm.count).toBe(1);
});
2. Ne pas nettoyer les mocks entre les tests
// Mauvais : les mocks s'accumulent
describe('Component', () => {
it('test 1', () => {
axios.get.mockResolvedValue({ data: [] });
// ...
});
it('test 2', () => {
// axios.get garde le mock precedent !
});
});
// Bon : nettoyer systematiquement
describe('Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ou utiliser afterEach pour cleanup
});
3. Tester les details d’implementation de Vue
// Mauvais : teste le fonctionnement interne de Vue
it('observe les changements de props', () => {
expect(wrapper.vm.$options.watch.propName).toBeDefined();
});
// Bon : teste l'effet observable
it('reagit aux changements de props', async () => {
await wrapper.setProps({ value: 'new' });
expect(wrapper.find('.display').text()).toBe('new');
});
4. Tests trop couples au DOM
// Mauvais : casse si le HTML change
it('trouve l\'element', () => {
expect(wrapper.find('div.container > ul > li:first-child').text()).toBe('Item 1');
});
// Bon : selecteur stable
it('trouve l\'element', () => {
expect(wrapper.find('[data-testid="first-item"]').text()).toBe('Item 1');
});
5. Ignorer les erreurs de console dans les tests
// Bon : capturer et verifier les erreurs attendues
it('affiche une erreur pour props invalides', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
mount(Component, {
props: { invalidProp: true }
});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid prop')
);
consoleSpy.mockRestore();
});
Conclusion
Les tests unitaires sont un investissement essentiel pour la qualite et la maintenabilite de vos applications Vue.js. Avec Jest et Vue Test Utils, vous disposez d’outils puissants pour :
- Isoler chaque composant et tester son comportement de maniere independante
- Simuler les interactions utilisateur (clics, saisies, soumissions de formulaires)
- Mocker les dependances externes (stores, router, API)
- Verifier que vos composants emettent les bons evenements avec les bonnes donnees
- Detecter les regressions grace aux snapshots
Points cles a retenir
-
Commencez simple : Testez d’abord le rendu et les interactions basiques avant de vous attaquer aux cas complexes.
-
Privilegiez shallowMount pour des tests unitaires rapides et isoles. Utilisez mount quand vous avez besoin de tester les interactions parent-enfant.
-
Mockez intelligemment : Ne mockez que ce qui est necessaire. Un test avec trop de mocks perd de sa valeur.
-
Visez une couverture significative : 80% de couverture est un bon objectif, mais privilegiez la qualite des tests a la quantite.
-
Maintenez vos tests : Des tests obsoletes sont pires que pas de tests. Mettez-les a jour quand le code change.
Les tests ne sont pas une corvee supplementaire, mais un outil qui vous fait gagner du temps sur le long terme. Un bon test detecte un bug en quelques millisecondes, alors que le meme bug en production peut prendre des heures a identifier et corriger.
Ressources complementaires
In-Article Ad
Dev Mode
Mahmoud DEVO
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Guide complet : Tester vos composants Vue.js avec Mocha et Chai
Decouvrez comment ecrire des tests unitaires pour Vue.js avec Mocha et Chai. Testez formulaires, evenements et interactions utilisateur.
Mocker les appels API avec Sinon et Chai dans vos tests Vue.js
Guide complet pour utiliser Sinon et Chai afin de mocker les actions Vuex et tester vos composants Vue.js avec des donnees simulees.
Vue.js et TypeScript : Guide Complet pour la Composition API avec Typage Statique
Combinez Vue 3, TypeScript et la Composition API pour un code plus sur. Configuration, typage et bonnes pratiques pour vos projets Vue.