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.

Mahmoud DEVO
Mahmoud DEVO
December 28, 2025 11 min read
Mocker les appels API avec Sinon et Chai dans vos tests Vue.js

Unit Testing: Comment utiliser Sinon et Chai pour tester votre application Vue.js

Introduction

Lorsque vous developpez une application Vue.js moderne, vos composants interagissent inevitablement avec des services externes : APIs REST, bases de donnees, services d’authentification, ou encore des fonctionnalites du navigateur comme les timers. Ces dependances externes posent un defi majeur pour les tests unitaires : comment isoler le code que vous testez des services dont il depend ?

C’est la que le mocking entre en jeu. Le mocking consiste a remplacer les dependances reelles par des versions controlees qui simulent leur comportement. Cette technique vous permet de :

  • Isoler le code teste : Votre test se concentre uniquement sur la logique du composant, pas sur le comportement de l’API
  • Controler les scenarios : Vous pouvez simuler des reponses de succes, des erreurs, des timeouts, etc.
  • Accelerer les tests : Plus besoin d’attendre les reponses reseau
  • Rendre les tests deterministes : Les resultats sont previsibles et reproductibles

Dans cet article, nous allons explorer en profondeur comment utiliser Sinon.js et Chai pour creer des tests robustes et maintenables pour vos applications Vue.js.

Pourquoi Sinon et Chai ?

Sinon.js est une bibliotheque de test JavaScript qui fournit des fonctionnalites puissantes pour creer des spies, stubs et mocks. Elle est framework-agnostique, ce qui signifie qu’elle fonctionne aussi bien avec Jest, Mocha, Vitest ou tout autre runner de tests.

Chai est une bibliotheque d’assertions qui offre trois styles differents : should, expect et assert. Combinee avec le plugin sinon-chai, elle permet d’ecrire des assertions elegantes et expressives sur les comportements des mocks.


Installation et Configuration

Installation des dependances

Commencez par installer les packages necessaires dans votre projet Vue.js :

# Avec npm
npm install --save-dev sinon chai sinon-chai @types/sinon @types/chai

# Avec yarn
yarn add -D sinon chai sinon-chai @types/sinon @types/chai

# Avec pnpm
pnpm add -D sinon chai sinon-chai @types/sinon @types/chai

Voici la configuration complete dans votre package.json :

{
  "devDependencies": {
    "sinon": "^17.0.0",
    "chai": "^4.3.10",
    "sinon-chai": "^3.7.0",
    "@types/sinon": "^17.0.0",
    "@types/chai": "^4.3.11",
    "@vue/test-utils": "^2.4.0",
    "vitest": "^1.0.0"
  }
}

Configuration avec Vitest

Creez ou modifiez votre fichier vitest.config.ts :

import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html']
    }
  }
});

Fichier de setup des tests

Creez un fichier tests/setup.ts pour configurer Sinon et Chai globalement :

import { expect } from 'chai';
import sinonChai from 'sinon-chai';
import * as sinon from 'sinon';

// Integrer sinon-chai pour des assertions plus expressives
chai.use(sinonChai);

// Nettoyer automatiquement les stubs apres chaque test
afterEach(() => {
  sinon.restore();
});

// Exporter pour utilisation dans les tests
export { expect, sinon };

Stubs vs Spies vs Mocks : Comprendre les Differences

Sinon propose trois types d’objets de test, chacun avec un usage specifique. Comprendre leurs differences est essentiel pour ecrire des tests efficaces.

Spies : Observer sans modifier

Un spy enregistre les informations sur les appels de fonction sans modifier son comportement. C’est utile quand vous voulez verifier qu’une fonction a ete appelee, avec quels arguments, combien de fois, etc.

import sinon from 'sinon';

describe('Spies - Observation des appels', () => {
  it('devrait enregistrer les appels de fonction', () => {
    const callback = sinon.spy();

    // Simuler des appels
    callback('premier appel');
    callback('deuxieme appel', { data: 123 });

    // Verifications
    expect(callback.calledTwice).to.be.true;
    expect(callback.firstCall.args[0]).to.equal('premier appel');
    expect(callback.secondCall.args[1]).to.deep.equal({ data: 123 });
  });

  it('devrait espionner une methode existante', () => {
    const user = {
      getName() {
        return 'John Doe';
      }
    };

    const spy = sinon.spy(user, 'getName');

    // La methode fonctionne normalement
    const name = user.getName();

    expect(name).to.equal('John Doe');
    expect(spy.calledOnce).to.be.true;

    // Toujours restaurer
    spy.restore();
  });
});

Stubs : Remplacer le comportement

Un stub remplace completement une fonction par une version controlee. Vous pouvez definir exactement ce qu’elle retourne ou fait quand elle est appelee.

import sinon from 'sinon';

describe('Stubs - Remplacement de comportement', () => {
  it('devrait retourner une valeur predefinee', () => {
    const stub = sinon.stub().returns('valeur mockee');

    const result = stub();

    expect(result).to.equal('valeur mockee');
  });

  it('devrait gerer differents scenarios selon les arguments', () => {
    const apiStub = sinon.stub();

    // Configurer differentes reponses
    apiStub.withArgs('user', 1).returns({ id: 1, name: 'Alice' });
    apiStub.withArgs('user', 2).returns({ id: 2, name: 'Bob' });
    apiStub.withArgs('user', 999).throws(new Error('Utilisateur non trouve'));

    expect(apiStub('user', 1).name).to.equal('Alice');
    expect(apiStub('user', 2).name).to.equal('Bob');
    expect(() => apiStub('user', 999)).to.throw('Utilisateur non trouve');
  });

  it('devrait simuler un comportement asynchrone', async () => {
    const fetchStub = sinon.stub();

    fetchStub.resolves({ data: [1, 2, 3] });

    const result = await fetchStub();

    expect(result.data).to.deep.equal([1, 2, 3]);
  });

  it('devrait rejeter une promesse pour simuler une erreur', async () => {
    const fetchStub = sinon.stub();

    fetchStub.rejects(new Error('Erreur reseau'));

    try {
      await fetchStub();
      expect.fail('Devrait avoir lance une erreur');
    } catch (error) {
      expect(error.message).to.equal('Erreur reseau');
    }
  });
});

Mocks : Attentes predefinies

Un mock est similaire a un stub, mais avec des attentes predefinies sur la facon dont il sera appele. Si ces attentes ne sont pas satisfaites, le test echoue.

import sinon from 'sinon';

describe('Mocks - Attentes predefinies', () => {
  it('devrait verifier les attentes sur les appels', () => {
    const api = {
      fetchUser(id: number) {
        // Implementation reelle
      }
    };

    const mock = sinon.mock(api);

    // Definir les attentes AVANT l'execution
    mock.expects('fetchUser')
      .once()
      .withArgs(42)
      .returns({ id: 42, name: 'Test User' });

    // Executer le code
    const result = api.fetchUser(42);

    // Verifier que les attentes sont satisfaites
    mock.verify();

    expect(result.name).to.equal('Test User');

    // Restaurer
    mock.restore();
  });
});

Tableau comparatif

CaracteristiqueSpyStubMock
Observe les appelsOuiOuiOui
Modifie le comportementNonOuiOui
Retourne des valeurs personnaliseesNonOuiOui
Attentes predefiniesNonNonOui
Echec automatique si attentes non satisfaitesNonNonOui
Cas d’usage principalVerificationSimulationContrat strict

Mocking des Appels HTTP

Mocker fetch

Dans les applications Vue.js modernes, fetch est souvent utilise pour les appels API. Voici comment le mocker efficacement :

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

describe('UserProfile - Appels API avec fetch', () => {
  let fetchStub: sinon.SinonStub;

  beforeEach(() => {
    // Remplacer fetch globalement
    fetchStub = sinon.stub(global, 'fetch');
  });

  afterEach(() => {
    sinon.restore();
  });

  it('devrait afficher les donnees utilisateur apres chargement', async () => {
    // Configurer la reponse mockee
    const mockUser = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com'
    };

    fetchStub.resolves({
      ok: true,
      json: () => Promise.resolve(mockUser)
    });

    const wrapper = mount(UserProfile, {
      props: { userId: 1 }
    });

    // Attendre que le composant se mette a jour
    await wrapper.vm.$nextTick();
    await new Promise(resolve => setTimeout(resolve, 0));

    expect(wrapper.text()).to.contain('John Doe');
    expect(wrapper.text()).to.contain('john@example.com');
    expect(fetchStub.calledOnce).to.be.true;
    expect(fetchStub.calledWith('/api/users/1')).to.be.true;
  });

  it('devrait afficher une erreur en cas d\'echec', async () => {
    fetchStub.resolves({
      ok: false,
      status: 404,
      statusText: 'Not Found'
    });

    const wrapper = mount(UserProfile, {
      props: { userId: 999 }
    });

    await wrapper.vm.$nextTick();
    await new Promise(resolve => setTimeout(resolve, 0));

    expect(wrapper.text()).to.contain('Utilisateur non trouve');
  });

  it('devrait gerer les erreurs reseau', async () => {
    fetchStub.rejects(new Error('Network error'));

    const wrapper = mount(UserProfile, {
      props: { userId: 1 }
    });

    await wrapper.vm.$nextTick();
    await new Promise(resolve => setTimeout(resolve, 0));

    expect(wrapper.text()).to.contain('Erreur de connexion');
  });
});

Mocker Axios

Si vous utilisez Axios, le mocking est similaire mais avec quelques specificites :

import { mount } from '@vue/test-utils';
import sinon from 'sinon';
import axios from 'axios';
import ProductList from '@/components/ProductList.vue';

describe('ProductList - Appels API avec Axios', () => {
  let axiosGetStub: sinon.SinonStub;
  let axiosPostStub: sinon.SinonStub;

  beforeEach(() => {
    axiosGetStub = sinon.stub(axios, 'get');
    axiosPostStub = sinon.stub(axios, 'post');
  });

  afterEach(() => {
    sinon.restore();
  });

  it('devrait charger et afficher la liste des produits', async () => {
    const mockProducts = [
      { id: 1, name: 'Produit A', price: 29.99 },
      { id: 2, name: 'Produit B', price: 49.99 },
      { id: 3, name: 'Produit C', price: 19.99 }
    ];

    axiosGetStub.resolves({ data: mockProducts, status: 200 });

    const wrapper = mount(ProductList);

    await wrapper.vm.$nextTick();

    const productItems = wrapper.findAll('.product-item');
    expect(productItems.length).to.equal(3);
    expect(wrapper.text()).to.contain('Produit A');
    expect(wrapper.text()).to.contain('29.99');
  });

  it('devrait envoyer une requete POST pour ajouter un produit', async () => {
    const newProduct = { name: 'Nouveau Produit', price: 39.99 };

    axiosPostStub.resolves({
      data: { id: 4, ...newProduct },
      status: 201
    });

    const wrapper = mount(ProductList);

    // Simuler l'ajout d'un produit
    await wrapper.find('input[name="productName"]').setValue('Nouveau Produit');
    await wrapper.find('input[name="productPrice"]').setValue('39.99');
    await wrapper.find('form').trigger('submit');

    expect(axiosPostStub.calledOnce).to.be.true;
    expect(axiosPostStub.calledWith('/api/products', newProduct)).to.be.true;
  });

  it('devrait gerer la pagination des produits', async () => {
    // Premier appel - page 1
    axiosGetStub.onFirstCall().resolves({
      data: {
        products: [{ id: 1, name: 'Produit 1' }],
        totalPages: 3,
        currentPage: 1
      }
    });

    // Deuxieme appel - page 2
    axiosGetStub.onSecondCall().resolves({
      data: {
        products: [{ id: 2, name: 'Produit 2' }],
        totalPages: 3,
        currentPage: 2
      }
    });

    const wrapper = mount(ProductList);
    await wrapper.vm.$nextTick();

    expect(wrapper.text()).to.contain('Produit 1');

    // Naviguer vers la page 2
    await wrapper.find('.pagination-next').trigger('click');
    await wrapper.vm.$nextTick();

    expect(wrapper.text()).to.contain('Produit 2');
    expect(axiosGetStub.calledTwice).to.be.true;
  });
});

Mocking du Store Vuex

Tester des composants qui dependent d’un store Vuex necessite un mocking soigneux des getters, mutations et actions.

Configuration de base du store mocke

import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import sinon from 'sinon';
import WeatherContainer from '@/components/WeatherContainer.vue';

describe('WeatherContainer.vue - Store Vuex', () => {
  let store: any;
  let actions: any;
  let getters: any;
  let mutations: any;

  const createMockStore = (overrides = {}) => {
    actions = {
      fetchWeather: sinon.stub().resolves(),
      updateLocation: sinon.stub().resolves(),
      ...overrides.actions
    };

    mutations = {
      SET_LOADING: sinon.stub(),
      SET_WEATHER: sinon.stub(),
      SET_ERROR: sinon.stub(),
      ...overrides.mutations
    };

    getters = {
      loading: () => false,
      weather: () => ({
        city: 'Paris',
        description: 'Ensoleille',
        temperature: 22,
        humidity: 45
      }),
      error: () => null,
      ...overrides.getters
    };

    return createStore({
      state: () => ({
        loading: false,
        weather: null,
        error: null
      }),
      actions,
      mutations,
      getters
    });
  };

  beforeEach(() => {
    store = createMockStore();
  });

  afterEach(() => {
    sinon.restore();
  });

  it('devrait afficher les donnees meteo', () => {
    const wrapper = mount(WeatherContainer, {
      global: {
        plugins: [store]
      }
    });

    expect(wrapper.text()).to.contain('Paris');
    expect(wrapper.text()).to.contain('Ensoleille');
    expect(wrapper.text()).to.contain('22');
  });

  it('devrait afficher le loader pendant le chargement', () => {
    store = createMockStore({
      getters: {
        loading: () => true,
        weather: () => null
      }
    });

    const wrapper = mount(WeatherContainer, {
      global: {
        plugins: [store]
      }
    });

    expect(wrapper.find('.loader').exists()).to.be.true;
    expect(wrapper.find('.weather-data').exists()).to.be.false;
  });

  it('devrait appeler fetchWeather au montage', async () => {
    mount(WeatherContainer, {
      global: {
        plugins: [store]
      }
    });

    expect(actions.fetchWeather.calledOnce).to.be.true;
  });

  it('devrait passer les bons arguments a l\'action', async () => {
    const wrapper = mount(WeatherContainer, {
      global: {
        plugins: [store]
      },
      props: {
        city: 'Lyon'
      }
    });

    await wrapper.find('.refresh-button').trigger('click');

    expect(actions.fetchWeather.calledWith(
      sinon.match.any,
      { city: 'Lyon' }
    )).to.be.true;
  });

  it('devrait afficher une erreur en cas d\'echec', () => {
    store = createMockStore({
      getters: {
        loading: () => false,
        weather: () => null,
        error: () => 'Impossible de charger les donnees meteo'
      }
    });

    const wrapper = mount(WeatherContainer, {
      global: {
        plugins: [store]
      }
    });

    expect(wrapper.find('.error-message').exists()).to.be.true;
    expect(wrapper.text()).to.contain('Impossible de charger');
  });
});

Tester les modules Vuex

Pour les applications plus complexes avec des modules Vuex :

describe('Store avec modules', () => {
  const createModularStore = () => {
    return createStore({
      modules: {
        user: {
          namespaced: true,
          state: () => ({ profile: null }),
          actions: {
            fetchProfile: sinon.stub().resolves()
          },
          getters: {
            isAuthenticated: () => true,
            profile: () => ({ name: 'Alice', role: 'admin' })
          }
        },
        products: {
          namespaced: true,
          state: () => ({ items: [] }),
          actions: {
            loadProducts: sinon.stub().resolves()
          },
          getters: {
            allProducts: () => [
              { id: 1, name: 'Product A' }
            ]
          }
        }
      }
    });
  };

  it('devrait acceder aux getters des modules', () => {
    const store = createModularStore();

    const wrapper = mount(Dashboard, {
      global: {
        plugins: [store]
      }
    });

    expect(wrapper.text()).to.contain('Alice');
    expect(wrapper.text()).to.contain('admin');
  });
});

Mocking des Timers

Les timers (setTimeout, setInterval) peuvent rendre les tests lents et non deterministes. Sinon offre des “fake timers” pour controler le temps.

Utilisation des Fake Timers

import sinon from 'sinon';
import { mount } from '@vue/test-utils';
import Countdown from '@/components/Countdown.vue';

describe('Countdown - Fake Timers', () => {
  let clock: sinon.SinonFakeTimers;

  beforeEach(() => {
    // Remplacer les timers reels par des faux
    clock = sinon.useFakeTimers();
  });

  afterEach(() => {
    // Restaurer les timers reels
    clock.restore();
  });

  it('devrait decrementer le compteur chaque seconde', async () => {
    const wrapper = mount(Countdown, {
      props: { startValue: 10 }
    });

    expect(wrapper.text()).to.contain('10');

    // Avancer le temps de 1 seconde
    clock.tick(1000);
    await wrapper.vm.$nextTick();
    expect(wrapper.text()).to.contain('9');

    // Avancer de 5 secondes supplementaires
    clock.tick(5000);
    await wrapper.vm.$nextTick();
    expect(wrapper.text()).to.contain('4');
  });

  it('devrait emettre un evenement quand le compte a rebours atteint zero', async () => {
    const wrapper = mount(Countdown, {
      props: { startValue: 3 }
    });

    // Avancer jusqu'a la fin du compte a rebours
    clock.tick(3000);
    await wrapper.vm.$nextTick();

    expect(wrapper.emitted('complete')).to.have.lengthOf(1);
  });

  it('devrait nettoyer l\'intervalle lors de la destruction', () => {
    const wrapper = mount(Countdown, {
      props: { startValue: 10 }
    });

    wrapper.unmount();

    // Verifier qu'avancer le temps ne cause pas d'erreur
    clock.tick(10000);
    // Pas d'erreur = intervalle bien nettoye
  });
});

Debounce et Throttle

import sinon from 'sinon';
import { mount } from '@vue/test-utils';
import SearchInput from '@/components/SearchInput.vue';

describe('SearchInput - Debounce', () => {
  let clock: sinon.SinonFakeTimers;
  let searchStub: sinon.SinonStub;

  beforeEach(() => {
    clock = sinon.useFakeTimers();
    searchStub = sinon.stub();
  });

  afterEach(() => {
    clock.restore();
    sinon.restore();
  });

  it('devrait debouncer les appels de recherche', async () => {
    const wrapper = mount(SearchInput, {
      props: {
        onSearch: searchStub,
        debounceMs: 300
      }
    });

    const input = wrapper.find('input');

    // Taper rapidement plusieurs caracteres
    await input.setValue('t');
    await input.setValue('te');
    await input.setValue('tes');
    await input.setValue('test');

    // Avant le delai de debounce, aucun appel
    expect(searchStub.called).to.be.false;

    // Avancer le temps de 300ms
    clock.tick(300);

    // Un seul appel avec la valeur finale
    expect(searchStub.calledOnce).to.be.true;
    expect(searchStub.calledWith('test')).to.be.true;
  });

  it('devrait annuler la recherche precedente si nouvelle frappe', async () => {
    const wrapper = mount(SearchInput, {
      props: {
        onSearch: searchStub,
        debounceMs: 300
      }
    });

    const input = wrapper.find('input');

    await input.setValue('hello');
    clock.tick(200); // Pas encore 300ms

    await input.setValue('world');
    clock.tick(300);

    // Seule la derniere valeur declenche la recherche
    expect(searchStub.calledOnce).to.be.true;
    expect(searchStub.calledWith('world')).to.be.true;
  });
});

Assertions Sinon-Chai

Le plugin sinon-chai ajoute des assertions expressives pour verifier le comportement des mocks.

Assertions de base

import { expect } from 'chai';
import sinon from 'sinon';

describe('Assertions sinon-chai', () => {
  it('devrait verifier les appels de base', () => {
    const spy = sinon.spy();

    spy('arg1', 'arg2');
    spy('arg3');

    // Verifier qu'une fonction a ete appelee
    expect(spy).to.have.been.called;
    expect(spy).to.have.been.calledTwice;

    // Verifier les arguments
    expect(spy).to.have.been.calledWith('arg1', 'arg2');
    expect(spy).to.have.been.calledWithExactly('arg3');
  });

  it('devrait verifier l\'ordre des appels', () => {
    const first = sinon.spy();
    const second = sinon.spy();
    const third = sinon.spy();

    first();
    second();
    third();

    expect(first).to.have.been.calledBefore(second);
    expect(second).to.have.been.calledBefore(third);
    expect(third).to.have.been.calledAfter(first);
  });

  it('devrait verifier le contexte this', () => {
    const obj = {
      name: 'Test',
      greet: function() {
        return `Hello, ${this.name}`;
      }
    };

    const spy = sinon.spy(obj, 'greet');
    obj.greet();

    expect(spy).to.have.been.calledOn(obj);
  });

  it('devrait verifier avec des matchers', () => {
    const callback = sinon.spy();

    callback({ type: 'click', target: 'button' });

    expect(callback).to.have.been.calledWith(
      sinon.match({ type: 'click' })
    );

    expect(callback).to.have.been.calledWith(
      sinon.match.has('target', 'button')
    );
  });

  it('devrait verifier les valeurs retournees', () => {
    const stub = sinon.stub().returns(42);

    const result = stub();

    expect(stub).to.have.returned(42);
    expect(stub).to.have.always.returned(42);
  });
});

Matchers avances

describe('Matchers Sinon avances', () => {
  it('devrait utiliser des matchers de type', () => {
    const api = sinon.stub();

    api(123, 'test', { key: 'value' }, [1, 2, 3]);

    expect(api).to.have.been.calledWith(
      sinon.match.number,
      sinon.match.string,
      sinon.match.object,
      sinon.match.array
    );
  });

  it('devrait utiliser des matchers personnalises', () => {
    const api = sinon.stub();

    api({ price: 99.99, quantity: 5 });

    const isValidOrder = sinon.match((value) => {
      return value.price > 0 && value.quantity > 0;
    }, 'valid order');

    expect(api).to.have.been.calledWith(isValidOrder);
  });

  it('devrait combiner les matchers', () => {
    const api = sinon.stub();

    api({ name: 'Product', price: 29.99, inStock: true });

    expect(api).to.have.been.calledWith(
      sinon.match.has('name', sinon.match.string)
        .and(sinon.match.has('price', sinon.match.number))
        .and(sinon.match.has('inStock', true))
    );
  });
});

Cleanup et Restore des Mocks

Une gestion correcte du nettoyage est cruciale pour eviter les effets de bord entre les tests.

Strategies de nettoyage

import sinon from 'sinon';

describe('Strategies de cleanup', () => {
  // Strategie 1 : Sandbox (recommandee)
  describe('Avec sandbox', () => {
    let sandbox: sinon.SinonSandbox;

    beforeEach(() => {
      sandbox = sinon.createSandbox();
    });

    afterEach(() => {
      // Restaure automatiquement tous les stubs/spies crees via sandbox
      sandbox.restore();
    });

    it('devrait utiliser le sandbox pour creer des stubs', () => {
      const stub = sandbox.stub(console, 'log');
      console.log('test');
      expect(stub).to.have.been.calledOnce;
    });
  });

  // Strategie 2 : Restore global (simple)
  describe('Avec restore global', () => {
    afterEach(() => {
      // Restaure TOUS les stubs/spies crees avec sinon
      sinon.restore();
    });

    it('test avec stubs', () => {
      const stub = sinon.stub(Math, 'random').returns(0.5);
      expect(Math.random()).to.equal(0.5);
    });
  });

  // Strategie 3 : Restore manuel (controle fin)
  describe('Avec restore manuel', () => {
    let fetchStub: sinon.SinonStub;

    beforeEach(() => {
      fetchStub = sinon.stub(global, 'fetch');
    });

    afterEach(() => {
      fetchStub.restore();
    });

    it('test avec fetch', async () => {
      fetchStub.resolves({ json: () => Promise.resolve({}) });
      await fetch('/api/test');
      expect(fetchStub).to.have.been.calledOnce;
    });
  });
});

Pattern de factory pour les mocks reutilisables

// tests/factories/apiMocks.ts
import sinon from 'sinon';

export function createApiMocks() {
  const sandbox = sinon.createSandbox();

  const mocks = {
    fetch: sandbox.stub(global, 'fetch'),
    localStorage: {
      getItem: sandbox.stub(Storage.prototype, 'getItem'),
      setItem: sandbox.stub(Storage.prototype, 'setItem'),
      removeItem: sandbox.stub(Storage.prototype, 'removeItem')
    },
    console: {
      log: sandbox.stub(console, 'log'),
      error: sandbox.stub(console, 'error'),
      warn: sandbox.stub(console, 'warn')
    }
  };

  return {
    mocks,
    restore: () => sandbox.restore(),
    reset: () => sandbox.reset()
  };
}

// Utilisation dans les tests
describe('Composant avec API', () => {
  let apiMocks: ReturnType<typeof createApiMocks>;

  beforeEach(() => {
    apiMocks = createApiMocks();
  });

  afterEach(() => {
    apiMocks.restore();
  });

  it('devrait charger les donnees', async () => {
    apiMocks.mocks.fetch.resolves({
      ok: true,
      json: () => Promise.resolve({ data: 'test' })
    });

    // ...test
  });
});

Exemple Complet : Composant de Formulaire

Voici un exemple complet integrant toutes les techniques vues :

// tests/unit/ContactForm.spec.ts
import { mount, flushPromises } from '@vue/test-utils';
import { createStore } from 'vuex';
import sinon from 'sinon';
import { expect } from 'chai';
import axios from 'axios';
import ContactForm from '@/components/ContactForm.vue';

describe('ContactForm.vue - Test complet', () => {
  let sandbox: sinon.SinonSandbox;
  let store: any;
  let axiosPostStub: sinon.SinonStub;
  let clock: sinon.SinonFakeTimers;

  const createTestStore = (authenticated = true) => {
    return createStore({
      state: () => ({ user: authenticated ? { name: 'Test User' } : null }),
      getters: {
        isAuthenticated: (state) => !!state.user,
        userName: (state) => state.user?.name || 'Anonyme'
      },
      actions: {
        showNotification: sinon.stub()
      }
    });
  };

  const mountComponent = (options = {}) => {
    return mount(ContactForm, {
      global: {
        plugins: [store],
        ...options.global
      },
      ...options
    });
  };

  beforeEach(() => {
    sandbox = sinon.createSandbox();
    clock = sandbox.useFakeTimers();
    axiosPostStub = sandbox.stub(axios, 'post');
    store = createTestStore();
  });

  afterEach(() => {
    sandbox.restore();
  });

  describe('Rendu initial', () => {
    it('devrait afficher le formulaire vide', () => {
      const wrapper = mountComponent();

      expect(wrapper.find('form').exists()).to.be.true;
      expect(wrapper.find('input[name="email"]').element.value).to.equal('');
      expect(wrapper.find('textarea[name="message"]').element.value).to.equal('');
    });

    it('devrait pre-remplir le nom pour utilisateur connecte', () => {
      const wrapper = mountComponent();

      expect(wrapper.find('input[name="name"]').element.value).to.equal('Test User');
    });

    it('devrait avoir le champ nom vide pour utilisateur non connecte', () => {
      store = createTestStore(false);
      const wrapper = mountComponent();

      expect(wrapper.find('input[name="name"]').element.value).to.equal('');
    });
  });

  describe('Validation du formulaire', () => {
    it('devrait afficher une erreur si email invalide', async () => {
      const wrapper = mountComponent();

      await wrapper.find('input[name="email"]').setValue('invalid-email');
      await wrapper.find('form').trigger('submit');

      expect(wrapper.find('.error-email').exists()).to.be.true;
      expect(wrapper.text()).to.contain('Email invalide');
      expect(axiosPostStub.called).to.be.false;
    });

    it('devrait afficher une erreur si message trop court', async () => {
      const wrapper = mountComponent();

      await wrapper.find('input[name="email"]').setValue('test@example.com');
      await wrapper.find('textarea[name="message"]').setValue('Hi');
      await wrapper.find('form').trigger('submit');

      expect(wrapper.find('.error-message').exists()).to.be.true;
      expect(axiosPostStub.called).to.be.false;
    });
  });

  describe('Soumission du formulaire', () => {
    const fillValidForm = async (wrapper: any) => {
      await wrapper.find('input[name="name"]').setValue('John Doe');
      await wrapper.find('input[name="email"]').setValue('john@example.com');
      await wrapper.find('textarea[name="message"]').setValue(
        'Ceci est un message de test suffisamment long pour etre valide.'
      );
    };

    it('devrait envoyer les donnees correctes', async () => {
      axiosPostStub.resolves({ data: { success: true } });
      const wrapper = mountComponent();

      await fillValidForm(wrapper);
      await wrapper.find('form').trigger('submit');
      await flushPromises();

      expect(axiosPostStub).to.have.been.calledOnce;
      expect(axiosPostStub).to.have.been.calledWith(
        '/api/contact',
        sinon.match({
          name: 'John Doe',
          email: 'john@example.com',
          message: sinon.match.string
        })
      );
    });

    it('devrait afficher un loader pendant l\'envoi', async () => {
      axiosPostStub.returns(new Promise(() => {})); // Never resolves
      const wrapper = mountComponent();

      await fillValidForm(wrapper);
      await wrapper.find('form').trigger('submit');

      expect(wrapper.find('.loading-spinner').exists()).to.be.true;
      expect(wrapper.find('button[type="submit"]').attributes('disabled')).to.exist;
    });

    it('devrait afficher un message de succes', async () => {
      axiosPostStub.resolves({ data: { success: true } });
      const wrapper = mountComponent();

      await fillValidForm(wrapper);
      await wrapper.find('form').trigger('submit');
      await flushPromises();

      expect(wrapper.find('.success-message').exists()).to.be.true;
      expect(wrapper.text()).to.contain('Message envoye');
    });

    it('devrait reinitialiser le formulaire apres succes', async () => {
      axiosPostStub.resolves({ data: { success: true } });
      const wrapper = mountComponent();

      await fillValidForm(wrapper);
      await wrapper.find('form').trigger('submit');
      await flushPromises();

      // Attendre le delai de reinitialisation
      clock.tick(2000);
      await wrapper.vm.$nextTick();

      expect(wrapper.find('textarea[name="message"]').element.value).to.equal('');
    });

    it('devrait gerer les erreurs serveur', async () => {
      axiosPostStub.rejects(new Error('Server error'));
      const wrapper = mountComponent();

      await fillValidForm(wrapper);
      await wrapper.find('form').trigger('submit');
      await flushPromises();

      expect(wrapper.find('.error-server').exists()).to.be.true;
      expect(wrapper.text()).to.contain('Erreur lors de l\'envoi');
    });

    it('devrait permettre de reessayer apres une erreur', async () => {
      axiosPostStub
        .onFirstCall().rejects(new Error('Server error'))
        .onSecondCall().resolves({ data: { success: true } });

      const wrapper = mountComponent();

      await fillValidForm(wrapper);
      await wrapper.find('form').trigger('submit');
      await flushPromises();

      expect(wrapper.find('.error-server').exists()).to.be.true;

      // Reessayer
      await wrapper.find('button.retry-button').trigger('click');
      await flushPromises();

      expect(wrapper.find('.success-message').exists()).to.be.true;
      expect(axiosPostStub).to.have.been.calledTwice;
    });
  });
});

Tableau Comparatif : Sinon vs Jest Mocks

FonctionnaliteSinonJest
Spiessinon.spy()jest.fn()
Stubssinon.stub()jest.fn().mockReturnValue()
Mocks avec attentessinon.mock()Non natif (librairies tierces)
Fake timerssinon.useFakeTimers()jest.useFakeTimers()
Fake serversinon.fakeServerNon natif (MSW recommande)
Restore automatiquesinon.restore()jest.restoreAllMocks()
Sandboxsinon.createSandbox()Non natif
Matcherssinon.match()expect.any(), expect.objectContaining()
Integration Chaisinon-chaiNon necessaire
ApprentissageCourbe modereeCourbe douce
DocumentationExcellenteExcellente
CommunauteGrandeTres grande

Quand choisir Sinon ?

  • Vous utilisez Mocha, Vitest ou un autre runner que Jest
  • Vous avez besoin de mocks avec attentes predefinies
  • Vous preferez une separation claire entre runner et mocking
  • Vous travaillez sur un projet legacy utilisant deja Sinon

Quand choisir Jest mocks ?

  • Vous utilisez Jest comme runner de tests
  • Vous voulez une solution tout-en-un
  • Vous preferez une syntaxe plus simple
  • Vous demarrez un nouveau projet

Bonnes Pratiques

1. Toujours restaurer les mocks

// BON
afterEach(() => {
  sinon.restore();
});

// MAUVAIS - Oublier de restaurer peut causer des effets de bord
afterEach(() => {
  // Rien...
});

2. Utiliser des sandboxes pour l’isolation

// BON - Chaque describe a son propre sandbox
describe('Feature A', () => {
  const sandbox = sinon.createSandbox();

  afterEach(() => sandbox.restore());

  // Tests...
});

describe('Feature B', () => {
  const sandbox = sinon.createSandbox();

  afterEach(() => sandbox.restore());

  // Tests isoles de Feature A
});

3. Eviter les mocks trop specifiques

// BON - Flexible
expect(api).to.have.been.calledWith(
  sinon.match.has('userId', sinon.match.number)
);

// MAUVAIS - Trop rigide, casse facilement
expect(api).to.have.been.calledWith({
  userId: 42,
  timestamp: 1234567890,
  requestId: 'abc-123'
});

4. Tester le comportement, pas l’implementation

// BON - Teste le resultat
it('devrait afficher les produits', async () => {
  apiStub.resolves({ products: [{ name: 'Test' }] });
  const wrapper = mount(ProductList);
  await flushPromises();

  expect(wrapper.text()).to.contain('Test');
});

// MAUVAIS - Teste l'implementation
it('devrait appeler setState avec products', async () => {
  // Trop couple a l'implementation interne
});

5. Nommer clairement les stubs

// BON - Noms descriptifs
const fetchUserStub = sinon.stub(api, 'fetchUser');
const saveOrderStub = sinon.stub(api, 'saveOrder');

// MAUVAIS - Noms generiques
const stub1 = sinon.stub(api, 'fetchUser');
const stub2 = sinon.stub(api, 'saveOrder');

6. Centraliser les configurations de mock reutilisables

// tests/mocks/apiResponses.ts
export const mockSuccessfulLogin = {
  token: 'fake-jwt-token',
  user: { id: 1, name: 'Test User' }
};

export const mockProducts = [
  { id: 1, name: 'Product A', price: 29.99 },
  { id: 2, name: 'Product B', price: 49.99 }
];

// Utilisation
import { mockProducts } from '@/tests/mocks/apiResponses';

axiosStub.resolves({ data: mockProducts });

Pieges Courants

1. Oublier d’attendre les promesses

// MAUVAIS - Le test passe meme si le comportement est incorrect
it('devrait charger les donnees', () => {
  apiStub.resolves({ data: [] });
  mount(MyComponent);
  // Test termine avant que la promesse soit resolue
});

// BON
it('devrait charger les donnees', async () => {
  apiStub.resolves({ data: [] });
  const wrapper = mount(MyComponent);
  await flushPromises(); // Attendre toutes les promesses
  expect(wrapper.text()).to.contain('Aucune donnee');
});

2. Stub sur le mauvais objet

// MAUVAIS - Stub sur l'import, pas sur l'instance
import { fetchData } from '@/api';
sinon.stub(fetchData); // Ne fonctionne pas !

// BON - Stub sur le module
import * as api from '@/api';
sinon.stub(api, 'fetchData');

3. Ne pas tester les cas d’erreur

// INCOMPLET - Teste seulement le succes
describe('API calls', () => {
  it('devrait charger les donnees', async () => {
    apiStub.resolves({ data: [] });
    // ...
  });
});

// COMPLET - Teste succes ET erreurs
describe('API calls', () => {
  it('devrait charger les donnees', async () => { /* ... */ });
  it('devrait gerer les erreurs reseau', async () => { /* ... */ });
  it('devrait gerer les erreurs 404', async () => { /* ... */ });
  it('devrait gerer les erreurs 500', async () => { /* ... */ });
});

4. Mocks trop permissifs

// MAUVAIS - Le stub accepte tout
const apiStub = sinon.stub().resolves({ success: true });

// BON - Le stub valide les arguments attendus
const apiStub = sinon.stub();
apiStub.withArgs('/api/users', sinon.match({ name: sinon.match.string }))
  .resolves({ success: true });
apiStub.throws(new Error('Arguments inattendus'));

5. Fake timers mal geres avec async/await

// MAUVAIS - Les timers ne fonctionnent pas bien avec await
it('test avec timer', async () => {
  clock = sinon.useFakeTimers();

  const promise = delayedAction(); // Retourne une promesse avec setTimeout
  clock.tick(1000);
  await promise; // Peut causer un deadlock !
});

// BON - Utiliser runAllAsync ou gerer manuellement
it('test avec timer', async () => {
  clock = sinon.useFakeTimers();

  const promise = delayedAction();
  await clock.tickAsync(1000); // Version async de tick
  const result = await promise;
});

Conclusion

Sinon et Chai forment un duo puissant pour tester vos applications Vue.js. Leur flexibilite et leur expressivite permettent d’ecrire des tests robustes, maintenables et comprehensibles.

Les points cles a retenir :

  1. Choisissez le bon outil : Spy pour observer, Stub pour remplacer, Mock pour les contrats stricts
  2. Isolez vos tests : Utilisez des sandboxes et restaurez systematiquement les mocks
  3. Testez les scenarios d’erreur : Les cas d’erreur sont souvent plus importants que les cas de succes
  4. Evitez les mocks trop specifiques : Utilisez des matchers pour plus de flexibilite
  5. Gerez correctement les timers : Les fake timers sont puissants mais demandent de la rigueur

En appliquant ces principes et en evitant les pieges courants, vous construirez une suite de tests fiable qui vous permettra de refactorer et faire evoluer votre application en toute confiance.

Ressources supplementaires

Prochaines etapes

  • Explorer les techniques de mocking avec MSW (Mock Service Worker) pour des tests encore plus realistes
  • Decouvrir les tests d’integration avec Cypress et le mocking de reseau
  • Implementer des tests de snapshot avec Vitest pour les composants Vue.js
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