Tests unitaires Vue.js : Simuler les interactions utilisateur avec Jest

Apprenez a tester vos composants Vue.js avec Jest et Vue Test Utils. Guide pratique pour simuler les saisies et soumissions de formulaires.

Mahmoud DEVO
Mahmoud DEVO
December 27, 2025 11 min read
Tests unitaires Vue.js : Simuler les interactions utilisateur avec Jest
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

SituationMethode recommandee
Test d’un composant simple sans enfantsshallowMount
Test des props et emits d’un composantshallowMount
Test d’interaction parent-enfantmount
Test de slots avec contenu complexemount
Tests de performance (nombreux tests)shallowMount
Tests d’accessibilitemount

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
-------------|---------|----------|---------|---------|-------------------
MetriqueDescription
StatementsPourcentage d’instructions executees
BranchesPourcentage de branches conditionnelles couvertes
FunctionsPourcentage de fonctions appelees
LinesPourcentage 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

CritereJestVitest
PerformanceBonExcellent (3-5x plus rapide)
ConfigurationPlus de setup requisConfiguration minimale avec Vite
Hot Module ReplacementNonOui
ESM natifSupport partielSupport complet
APIMature et stableCompatible Jest
EcosystemeTres largeEn croissance
Integration Vue 3Via vue3-jestNative
Watch modeFonctionnelUltra-rapide
SnapshotsOuiOui
CoverageIstanbul / v8v8 (plus rapide)
MockingJest.mock()vi.mock() (identique)
TypeScriptVia ts-jestNatif
DebuggingChrome DevToolsUI 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

  1. Commencez simple : Testez d’abord le rendu et les interactions basiques avant de vous attaquer aux cas complexes.

  2. Privilegiez shallowMount pour des tests unitaires rapides et isoles. Utilisez mount quand vous avez besoin de tester les interactions parent-enfant.

  3. Mockez intelligemment : Ne mockez que ce qui est necessaire. Un test avec trop de mocks perd de sa valeur.

  4. Visez une couverture significative : 80% de couverture est un bon objectif, mais privilegiez la qualite des tests a la quantite.

  5. 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

Advertisement

In-Article Ad

Dev Mode

Share this article

Mahmoud DEVO

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