Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 739 minIntermédiaire

Mock, stub, spy : les doublures de test

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • distinguer dummy, stub, spy, mock et fake ;
  • remplacer une dépendance avec vi.fn, vi.spyOn ou vi.mock ;
  • simuler une panne pour tester la gestion des erreurs ;
  • créer des doublures en C# avec NSubstitute et en PHP avec PHPUnit ;
  • éviter les tests qui décrivent l'implémentation au lieu du comportement.

4 exercices corrigés1 schéma

Une doublure de test (test double) est un objet qui remplace, pendant un test, une dépendance réelle du code testé : une base de données, un serveur de courriel, une API externe, l'horloge. Le stub fournit des réponses préparées, le mock vérifie que le code l'a appelé comme prévu, l'espion (spy) enregistre les appels faits à un vrai objet, et le fake est une implémentation simplifiée qui marche vraiment. Ce chapitre montre quand et comment les utiliser avec Vitest, xUnit et PHPUnit, et comment éviter d'en abuser.

Pourquoi remplacer une dépendance

Le rappel de Prêt'Matos part la veille de la date de retour. Le code cherche dans la base les prêts qui se terminent demain, puis envoie un courriel à chaque emprunteur. Le tester avec les vraies dépendances poserait quatre problèmes :

ProblèmeAvec les vraies dépendancesAvec des doublures
Effets de bordChaque lancement des tests envoie de vrais courriels aux salariésRien ne sort du test
LenteurUne connexion MySQL et un serveur SMTP : des centaines de millisecondes par testQuelques millisecondes
ReproductibilitéLe résultat dépend du contenu de la base ce jour-làLes données sont fixées par le test
Cas difficilesComment provoquer une panne du serveur de courriel à la demande ?Une ligne : la doublure lève une erreur

Le dernier point est souvent décisif : une doublure permet de tester ce qui se passe quand une dépendance échoue, un cas presque impossible à reproduire avec la vraie.

Les cinq familles de doublures

Le vocabulaire vient du livre de Gerard Meszaros, xUnit Test Patterns (2007). Dans la pratique, beaucoup de développeurs disent « mock » pour toutes les doublures, et les bibliothèques mélangent les rôles ; connaître les distinctions aide surtout à savoir ce que tu vérifies.

DoublureCe qu'elle faitCe que le test vérifieExemple dans Prêt'Matos
Dummy (factice)Rien : elle remplit un paramètre obligatoire dont le test n'a pas besoin–Un journal: {} vide passé à envoyerRappels quand aucune erreur n'est possible
Stub (bouchon)Renvoie des réponses préparées à l'avanceLe résultat du code testéLe dépôt renvoie un prêt, celui de Julie
Spy (espion)Enregistre les appels reçus ; peut laisser passer vers le vrai codeAprès coup : quels appels, avec quels argumentsUn espion sur depot.ajouterReservation pour vérifier qu'elle n'a pas été appelée
Mock (simulacre)Est programmé avec les appels attendus et échoue si la réalité diffèreLes interactions avec la dépendanceLa messagerie doit recevoir un envoi, pour l'adresse de Julie, avec le bon sujet
Fake (substitut)Une implémentation simplifiée mais fonctionnelleLe comportement du code avec une dépendance réalisteLe dépôt en mémoire, qui range les réservations dans un tableau
Le testprépare les doublures, appelle envoyerRappels, puis vérifie le résultat et les appels reçusenvoyerRappelsle code testéDépôt des prêtsstub : réponse préparéepretsARendreLe('2027-03-14')renvoie [le prêt de Julie]Messageriemock : vérifie l'appelenvoyer({ a: julie…, … })l'appel est l'effet attenduJournalespion : observe les appelserreur(…)seulement si un envoi échoue
Les doublures autour du code testé : le stub fournit les données, le mock et l'espion enregistrent les appels

Le code à tester

js
// src/rappels.js : le rappel envoyé la veille de la date de retour
import { formaterDate } from './regles.js';

export function lendemain(dateIso) {
  const date = new Date(`${dateIso}T00:00:00Z`);
  date.setUTCDate(date.getUTCDate() + 1);
  return date.toISOString().slice(0, 10);
}

export async function envoyerRappels({ depot, messagerie, journal, aujourdhui }) {
  const prets = await depot.pretsARendreLe(lendemain(aujourdhui));
  let envoyes = 0;
  for (const pret of prets) {
    try {
      await messagerie.envoyer({
        a: pret.email,
        sujet: `Rappel : ${pret.materiel} à rendre demain`,
        texte: `Bonjour ${pret.prenom}, merci de rapporter ${pret.materiel} à la logistique le ${formaterDate(pret.fin)}.`,
      });
      envoyes += 1;
    } catch (erreur) {
      journal.erreur(`Rappel non envoyé pour le prêt ${pret.id} : ${erreur.message}`);
    }
  }
  return envoyes;
}

La fonction ne crée pas elle-même sa connexion à la base ni son client de messagerie : elle les reçoit en paramètres. C'est ce qui permet de lui donner des doublures. Au démarrage de l'application, on lui passe le vrai dépôt MySQL et le vrai client SMTP ; dans les tests, des objets fabriqués avec vi.fn().

vi.fn() : une fonction simulée

vi.fn() crée une fonction qui ne fait rien par défaut, mais qui enregistre chacun de ses appels. On peut lui dire quoi renvoyer :

ÉcritureLa fonction simulée…
vi.fn()renvoie undefined
vi.fn().mockReturnValue(42)renvoie toujours 42
vi.fn().mockResolvedValue([pret])renvoie une promesse résolue avec [pret] (pour du code async)
vi.fn().mockRejectedValue(new Error('…'))renvoie une promesse rejetée
vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2)renvoie 1 au premier appel, 2 au second
vi.fn((x) => x * 2)exécute cette implémentation

Et on peut ensuite vérifier ses appels :

AssertionVérifie que la fonction simulée…
toHaveBeenCalled()a été appelée au moins une fois
toHaveBeenCalledTimes(n)a été appelée exactement n fois
toHaveBeenCalledWith(a, b)a été appelée au moins une fois avec ces arguments
toHaveBeenLastCalledWith(a, b)a été appelée en dernier avec ces arguments
not.toHaveBeenCalled()n'a jamais été appelée

Un stub pour fournir les données

js
// src/rappels.test.js
import { describe, test, expect, vi } from 'vitest';
import { envoyerRappels } from './rappels.js';

const pretDeJulie = {
  id: 41, materiel: 'PC-07', prenom: 'Julie', email: 'julie.lefevre@sorbier.example', fin: '2027-03-14',
};

describe('envoyerRappels', () => {
  test('cherche les prêts à rendre le lendemain', async () => {
    const depot = { pretsARendreLe: vi.fn().mockResolvedValue([]) };          // stub
    const messagerie = { envoyer: vi.fn() };

    await envoyerRappels({ depot, messagerie, journal: {}, aujourdhui: '2027-03-13' });

    expect(depot.pretsARendreLe).toHaveBeenCalledWith('2027-03-14');
  });
});

Le dépôt est réduit à l'unique méthode dont la fonction a besoin. mockResolvedValue([]) lui fait renvoyer une liste vide, comme une base sans prêt à rappeler. Le test vérifie que la fonction demande bien les prêts du lendemain : le 14 mars quand on est le 13. Le journal: {} est un dummy : il n'est jamais utilisé dans ce scénario.

Un mock pour vérifier les envois

js
  test('envoie un courriel à chaque emprunteur concerné', async () => {
    const depot = { pretsARendreLe: vi.fn().mockResolvedValue([pretDeJulie]) }; // stub
    const messagerie = { envoyer: vi.fn().mockResolvedValue(undefined) };       // mock

    const envoyes = await envoyerRappels({ depot, messagerie, journal: {}, aujourdhui: '2027-03-13' });

    expect(envoyes).toBe(1);
    expect(messagerie.envoyer).toHaveBeenCalledTimes(1);
    expect(messagerie.envoyer).toHaveBeenCalledWith({
      a: 'julie.lefevre@sorbier.example',
      sujet: 'Rappel : PC-07 à rendre demain',
      texte: 'Bonjour Julie, merci de rapporter PC-07 à la logistique le 14/03/2027.',
    });
  });

  test("n'envoie rien quand aucun prêt ne se termine demain", async () => {
    const depot = { pretsARendreLe: vi.fn().mockResolvedValue([]) };
    const messagerie = { envoyer: vi.fn() };

    const envoyes = await envoyerRappels({ depot, messagerie, journal: {}, aujourdhui: '2027-03-13' });

    expect(envoyes).toBe(0);
    expect(messagerie.envoyer).not.toHaveBeenCalled();
  });

Ici, l'appel à la messagerie est le comportement attendu : envoyer un rappel, c'est le but de la fonction. Vérifier l'interaction a donc du sens. C'est la règle pour choisir entre stub et mock : si la dépendance fournit une donnée, on la remplace par un stub et on vérifie le résultat ; si l'appel à la dépendance est l'effet attendu (envoyer, enregistrer, publier), on vérifie l'appel.

Simuler une panne

js
  test('continue après un échec et le journalise', async () => {
    const pretDeKarim = { ...pretDeJulie, id: 42, prenom: 'Karim', email: 'karim.morel@sorbier.example' };
    const depot = { pretsARendreLe: vi.fn().mockResolvedValue([pretDeJulie, pretDeKarim]) };
    const messagerie = {
      envoyer: vi.fn()
        .mockRejectedValueOnce(new Error('serveur SMTP injoignable'))
        .mockResolvedValueOnce(undefined),
    };
    const journal = { erreur: vi.fn() };

    const envoyes = await envoyerRappels({ depot, messagerie, journal, aujourdhui: '2027-03-13' });

    expect(envoyes).toBe(1);
    expect(messagerie.envoyer).toHaveBeenCalledTimes(2);
    expect(journal.erreur).toHaveBeenCalledWith('Rappel non envoyé pour le prêt 41 : serveur SMTP injoignable');
  });

Le premier envoi échoue, le second réussit. Le test prouve trois choses : une panne sur un courriel n'empêche pas les suivants, le compteur ne compte que les envois réussis, et l'erreur est journalisée avec le numéro du prêt. Reproduire ce scénario avec un vrai serveur de courriel demanderait de le couper au bon moment : avec une doublure, c'est une ligne.

Un espion sur un vrai objet : vi.spyOn

vi.spyOn(objet, 'methode') remplace une méthode d'un objet existant par un espion qui, par défaut, appelle toujours la vraie méthode. On garde le comportement réel, et on peut vérifier les appels. Exemple : une réservation refusée ne doit rien enregistrer.

js
// src/espion.test.js : un espion sur le vrai dépôt en mémoire
import { test, expect, vi } from 'vitest';
import request from 'supertest';
import { creerApp } from './app.js';
import { creerDepotMemoire } from './depot-memoire.js';
import { AUJOURDHUI, UTILISATEURS, jeuDeTest } from './jeu-de-test.js';

test("n'enregistre rien quand la réservation est refusée", async () => {
  const depot = creerDepotMemoire(jeuDeTest());
  const espion = vi.spyOn(depot, 'ajouterReservation');
  const app = creerApp({ depot, authentifier: async (j) => UTILISATEURS[j] ?? null, aujourdhui: () => AUJOURDHUI });

  await request(app)
    .post('/api/reservations')
    .set('Authorization', 'Bearer jeton-julie')
    .send({ materiel: 'PC-07', debut: '2027-03-12', fin: '2027-03-18' });

  expect(espion).not.toHaveBeenCalled();
});

Cet exemple utilise l'application Express et Supertest, présentés au chapitre 8. On peut aussi forcer la réponse d'un espion (vi.spyOn(depot, 'reservationsDu').mockResolvedValue([])) : il devient alors un stub posé sur un vrai objet. Un espion modifie l'objet d'origine ; s'il est partagé entre plusieurs tests, appelle vi.restoreAllMocks() dans un afterEach pour remettre les vraies méthodes, ou active l'option restoreMocks: true dans la configuration.

Un fake : le dépôt en mémoire

Un fake est une vraie implémentation, simplifiée. Le dépôt en mémoire de Prêt'Matos range les réservations dans un tableau au lieu d'une table MySQL, mais il fonctionne : ce qu'on y ajoute, on le retrouve.

js
// src/depot-memoire.js : un dépôt en mémoire, pour les tests et la démonstration
export function creerDepotMemoire({ materiels = [], reservations = [] } = {}) {
  const lignes = reservations.map((r) => ({ ...r }));
  let prochainId = Math.max(0, ...lignes.map((r) => r.id)) + 1;

  return {
    async listerMateriels() {
      return materiels;
    },
    async materielExiste(code) {
      return materiels.some((m) => m.code === code);
    },
    async reservationsDu(code) {
      return lignes.filter((r) => r.materiel === code);
    },
    async reservationsDe(salarie) {
      return lignes.filter((r) => r.salarie === salarie);
    },
    async ajouterReservation(reservation) {
      const ligne = { id: prochainId++, ...reservation, sortie: false };
      lignes.push(ligne);
      return ligne;
    },
    async enregistrerSortie(id) {
      const ligne = lignes.find((r) => r.id === id);
      if (ligne) ligne.sortie = true;
      return ligne ?? null;
    },
  };
}

Il a les mêmes méthodes que le dépôt MySQL : l'application ne voit pas la différence. Un fake est plus réaliste qu'une série de stubs (on peut ajouter puis relire) et il sert aussi pour une démonstration sans base. Son risque : s'écarter du vrai dépôt. Le dépôt MySQL doit donc avoir ses propres tests, contre une vraie base (chapitre 8).

Remplacer un module entier : vi.mock

Parfois, le code n'a pas reçu sa dépendance en paramètre : il l'importe directement. Pour la remplacer, vi.mock substitue le module entier pendant le test.

js
// src/annulation.js : prévient la logistique quand un salarié annule
import { envoyerCourriel } from './messagerie.js';

export async function prevenirAnnulation(reservation) {
  await envoyerCourriel({
    a: 'logistique@sorbier.example',
    sujet: `Annulation : ${reservation.materiel}`,
    texte: `La réservation n° ${reservation.id} est annulée.`,
  });
}
js
// src/annulation.test.js
import { test, expect, vi } from 'vitest';
import { prevenirAnnulation } from './annulation.js';
import { envoyerCourriel } from './messagerie.js';

// Remplace tout le module : prevenirAnnulation recevra la fausse fonction.
vi.mock('./messagerie.js', () => ({ envoyerCourriel: vi.fn() }));

test('prévient la logistique avec le numéro de la réservation', async () => {
  await prevenirAnnulation({ id: 12, materiel: 'VP-02' });

  expect(envoyerCourriel).toHaveBeenCalledWith({
    a: 'logistique@sorbier.example',
    sujet: 'Annulation : VP-02',
    texte: 'La réservation n° 12 est annulée.',
  });
});

Vitest remonte (hoist) les appels à vi.mock en haut du fichier, avant les import : le module simulé est en place quand annulation.js est chargé. Depuis Vitest 5, vi.mock doit être écrit au niveau principal du fichier ; placé dans un test ou un describe, il lève une erreur. L'import de envoyerCourriel dans le test récupère la fonction simulée, ce qui permet de vérifier ses appels.

Simuler l'API côté front : vi.stubGlobal

Le front de Prêt'Matos appelle l'API avec fetch. Pour tester sa fonction d'appel sans serveur, on remplace la fonction globale fetch :

js
// front/api.js : les appels du front à l'API de Prêt'Matos
export async function reserver(demande, jeton) {
  const reponse = await fetch('/api/reservations', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jeton}` },
    body: JSON.stringify(demande),
  });
  if (reponse.ok) return { ok: true };
  const { erreur } = await reponse.json();
  return { ok: false, erreur };
}
js
// front/api.test.js
import { test, expect, vi, afterEach } from 'vitest';
import { reserver } from './api.js';

afterEach(() => {
  vi.unstubAllGlobals();
});

test("renvoie le motif du refus quand l'API répond 409", async () => {
  const faux = vi.fn().mockResolvedValue(
    new Response(JSON.stringify({ erreur: 'PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.' }), { status: 409 }),
  );
  vi.stubGlobal('fetch', faux);

  const resultat = await reserver({ materiel: 'PC-07', debut: '2027-03-12', fin: '2027-03-18' }, 'jeton-julie');

  expect(resultat).toEqual({ ok: false, erreur: 'PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.' });
  expect(faux).toHaveBeenCalledWith('/api/reservations', expect.objectContaining({ method: 'POST' }));
});

new Response(…) fabrique une vraie réponse HTTP, avec son statut et son corps : reponse.ok vaut false pour un statut 409, exactement comme avec le serveur. expect.objectContaining vérifie seulement une partie des options. Pour une application plus grosse, la bibliothèque MSW (Mock Service Worker) intercepte les requêtes au niveau du réseau et sert des réponses préparées pour toutes les routes, dans les tests comme dans le navigateur.

Les doublures en C# : à la main ou avec NSubstitute

En C#, le code dépend d'interfaces, et une doublure est une classe qui implémente l'interface. Voici le service de rappels de Prêt'Matos, le même qu'en JavaScript : il reçoit le dépôt, la messagerie et l'horloge dans son constructeur.

csharp
// PretMatos.Domaine/ServiceRappels.cs
using System.Globalization;

namespace PretMatos.Domaine;

public record Pret(int Id, string Materiel, string Prenom, string Email, DateOnly Fin);

public interface IDepotPrets
{
    IReadOnlyList<Pret> PretsARendreLe(DateOnly jour);
}

public interface IMessagerie
{
    void Envoyer(string destinataire, string sujet, string texte);
}

public class ServiceRappels
{
    private readonly IDepotPrets _depot;
    private readonly IMessagerie _messagerie;
    private readonly TimeProvider _horloge;

    public ServiceRappels(IDepotPrets depot, IMessagerie messagerie, TimeProvider horloge)
    {
        _depot = depot;
        _messagerie = messagerie;
        _horloge = horloge;
    }

    // Envoie un rappel pour chaque prêt à rendre demain ; renvoie le nombre de courriels envoyés.
    public int EnvoyerRappels()
    {
        DateOnly demain = DateOnly.FromDateTime(_horloge.GetLocalNow().DateTime).AddDays(1);
        IReadOnlyList<Pret> prets = _depot.PretsARendreLe(demain);
        foreach (Pret pret in prets)
        {
            _messagerie.Envoyer(
                pret.Email,
                $"Rappel : {pret.Materiel} à rendre demain",
                string.Create(CultureInfo.InvariantCulture,
                    $"Bonjour {pret.Prenom}, merci de rapporter {pret.Materiel} à la logistique le {pret.Fin:dd/MM/yyyy}."));
        }
        return prets.Count;
    }
}

TimeProvider est l'horloge injectable de .NET : en production, on passe TimeProvider.System ; dans les tests, un FakeTimeProvider réglé sur une date fixe (paquet Microsoft.Extensions.TimeProvider.Testing, comme au chapitre 5). Une doublure de la messagerie peut s'écrire à la main :

csharp
// Une doublure écrite à la main : elle retient les courriels au lieu de les envoyer.
public class MessagerieEspion : IMessagerie
{
    public List<(string Destinataire, string Sujet)> Envoyes { get; } = [];

    public void Envoyer(string destinataire, string sujet, string texte) => Envoyes.Add((destinataire, sujet));
}

Ou utiliser une bibliothèque qui les fabrique à la volée. NSubstitute a une syntaxe très lisible ; Moq est l'autre bibliothèque répandue. Avec NSubstitute (dotnet add PretMatos.Tests package NSubstitute) :

csharp
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using PretMatos.Domaine;

public class ServiceRappelsTests
{
    private static readonly FakeTimeProvider Horloge = new(new DateTimeOffset(2027, 3, 13, 8, 0, 0, TimeSpan.Zero));
    private static readonly Pret PretDeJulie =
        new(41, "PC-07", "Julie", "julie.lefevre@sorbier.example", new DateOnly(2027, 3, 14));

    [Fact]
    public void EnvoyerRappels_AvecNSubstitute()
    {
        var depot = Substitute.For<IDepotPrets>();                       // stub
        depot.PretsARendreLe(new DateOnly(2027, 3, 14)).Returns([PretDeJulie]);
        var messagerie = Substitute.For<IMessagerie>();                  // mock
        var service = new ServiceRappels(depot, messagerie, Horloge);

        service.EnvoyerRappels();

        messagerie.Received(1).Envoyer(
            "julie.lefevre@sorbier.example",
            "Rappel : PC-07 à rendre demain",
            "Bonjour Julie, merci de rapporter PC-07 à la logistique le 14/03/2027.");
    }

    [Fact]
    public void EnvoyerRappels_AucunPret_NEnvoieRien()
    {
        var depot = Substitute.For<IDepotPrets>();
        depot.PretsARendreLe(Arg.Any<DateOnly>()).Returns([]);
        var messagerie = Substitute.For<IMessagerie>();
        var service = new ServiceRappels(depot, messagerie, Horloge);

        service.EnvoyerRappels();

        messagerie.DidNotReceive().Envoyer(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
    }
}

Substitute.For<IDepotPrets>() crée un objet qui implémente l'interface ; .Returns(…) en fait un stub pour un argument donné ; Arg.Any<DateOnly>() accepte n'importe quelle valeur ; Received(1) et DidNotReceive() vérifient les appels après coup. Le ServiceRappels C# reçoit ses dépendances dans son constructeur : c'est l'injection de dépendances que les applications ASP.NET Core utilisent partout.

Les doublures avec PHPUnit

En PHP aussi, le service dépend de deux interfaces : le vrai dépôt (avec PDO) et la vraie messagerie les implémentent en production, les doublures dans les tests.

php
<?php
// src/DepotPrets.php
declare(strict_types=1);

namespace PretMatos;

interface DepotPrets
{
    /** @return list<array{id: int, materiel: string, prenom: string, email: string, fin: string}> */
    public function pretsARendreLe(string $jour): array;
}
php
<?php
// src/Messagerie.php
declare(strict_types=1);

namespace PretMatos;

interface Messagerie
{
    public function envoyer(string $a, string $sujet, string $texte): void;
}
php
<?php
// src/ServiceRappels.php
declare(strict_types=1);

namespace PretMatos;

final class ServiceRappels
{
    public function __construct(private DepotPrets $depot, private Messagerie $messagerie)
    {
    }

    /** Envoie un rappel pour chaque prêt à rendre demain ; renvoie le nombre de courriels envoyés. */
    public function envoyerRappels(\DateTimeImmutable $aujourdhui): int
    {
        $demain = $aujourdhui->modify('+1 day')->format('Y-m-d');
        $envoyes = 0;
        foreach ($this->depot->pretsARendreLe($demain) as $pret) {
            $fin = (new \DateTimeImmutable($pret['fin']))->format('d/m/Y');
            $this->messagerie->envoyer(
                $pret['email'],
                sprintf('Rappel : %s à rendre demain', $pret['materiel']),
                sprintf('Bonjour %s, merci de rapporter %s à la logistique le %s.', $pret['prenom'], $pret['materiel'], $fin),
            );
            $envoyes++;
        }
        return $envoyes;
    }
}

PHPUnit fabrique les doublures lui-même, à partir d'une interface. createStub() crée un stub, createMock() un mock dont on annonce les appels attendus avant d'exécuter le code :

php
<?php
// tests/ServiceRappelsTest.php
declare(strict_types=1);

namespace PretMatos\Tests;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use PretMatos\DepotPrets;
use PretMatos\Messagerie;
use PretMatos\ServiceRappels;

final class ServiceRappelsTest extends TestCase
{
    #[Test]
    public function envoieUnRappelPourChaquePretQuiFinitDemain(): void
    {
        // Stub : le dépôt renvoie une réponse préparée.
        $depot = $this->createStub(DepotPrets::class);
        $depot->method('pretsARendreLe')
            ->willReturn([[
                'id' => 41, 'materiel' => 'PC-07', 'prenom' => 'Julie',
                'email' => 'julie.lefevre@sorbier.example', 'fin' => '2027-03-14',
            ]]);

        // Mock : on annonce l'appel attendu AVANT d'exécuter le code.
        $messagerie = $this->createMock(Messagerie::class);
        $messagerie->expects($this->once())
            ->method('envoyer')
            ->with(
                'julie.lefevre@sorbier.example',
                'Rappel : PC-07 à rendre demain',
                'Bonjour Julie, merci de rapporter PC-07 à la logistique le 14/03/2027.',
            );

        $service = new ServiceRappels($depot, $messagerie);

        $this->assertSame(1, $service->envoyerRappels(new \DateTimeImmutable('2027-03-13')));
    }

    #[Test]
    public function nEnvoieRienQuandAucunPretNeFinitDemain(): void
    {
        $depot = $this->createStub(DepotPrets::class);
        $depot->method('pretsARendreLe')->willReturn([]);
        $messagerie = $this->createMock(Messagerie::class);
        $messagerie->expects($this->never())->method('envoyer');

        $service = new ServiceRappels($depot, $messagerie);

        $this->assertSame(0, $service->envoyerRappels(new \DateTimeImmutable('2027-03-13')));
    }
}

La documentation de PHPUnit est nette sur le choix : createStub() pour isoler le code d'une dépendance, createMock() seulement pour vérifier une communication entre objets. Depuis PHPUnit 12, on ne peut plus poser d'attente (expects) sur un stub, et PHPUnit 13 a rendu obsolète l'attente $this->any() : si tu ne vérifies pas le nombre d'appels, c'est un stub qu'il te faut.

Les pièges des doublures

PiègeSymptômeRemède
Simuler ce que l'on testeLe test remplace la fonction testée elle-même : il ne vérifie plus rienNe remplace que les dépendances du code testé
Un stub irréalisteLe stub renvoie des dates Date alors que le vrai dépôt renvoie des chaînes : les tests passent, la production planteConstruis les données des stubs à partir de vraies réponses, et garde des tests d'intégration avec la vraie dépendance
Simuler une bibliothèque que tu ne maîtrises pasLes tests reproduisent ta compréhension, peut-être fausse, de l'API d'un tiersEnveloppe la bibliothèque dans ton propre module (messagerie.js) et simule ce module
Doublures non remises à zéroUn test voit les appels d'un autreVitest 5 efface l'historique des vi.fn() avant chaque test (clearMocks, actif par défaut) ; pour vi.spyOn, ajoute vi.restoreAllMocks()

Exercices

Exercice 1 sur 4 Quelle doublure ? Réussi À revoir

Pour chaque doublure, donne sa famille (dummy, stub, spy, mock, fake).

  1. Un objet horloge dont la méthode maintenant() renvoie toujours le 8 mars 2027 à 9 h.
  2. Une classe DepotSalariesEnMemoire qui stocke les salariés dans un dictionnaire et permet d'en ajouter et d'en chercher.
  3. Un vi.fn() passé comme journal.erreur dans un test où aucune erreur ne peut se produire, et jamais vérifié.
  4. vi.spyOn(console, 'warn'), puis expect(console.warn).toHaveBeenCalledTimes(1).
  5. Un mock PHPUnit de Messagerie avec expects($this->once())->method('envoyer').
Voir la réponseMasquer la réponse de l'exercice 1
  1. Stub : il fournit une donnée d'entrée fixe.
  2. Fake : une implémentation simplifiée mais fonctionnelle.
  3. Dummy : il ne sert qu'à remplir un paramètre.
  4. Spy : il observe un vrai objet et le test vérifie les appels après coup.
  5. Mock : l'appel attendu est déclaré avant l'exécution, et le test échoue si la réalité diffère.

Tu avais trouvé ?

Exercice 2 sur 4 Écrire un test avec un stub et un mock Réussi À revoir

Écris avec Vitest le test de cette fonction, dans le cas où le salarié n'a aucun retard : le dépôt renvoie une liste vide, et aucune alerte ne doit partir.

js
export async function alerterRetards({ depot, alertes, aujourdhui }) {
  const enRetard = await depot.pretsNonRendusAvant(aujourdhui);
  for (const pret of enRetard) {
    await alertes.signaler(pret.salarie, pret.materiel);
  }
  return enRetard.length;
}

Écris ensuite le test du cas où deux prêts sont en retard.

Voir la réponseMasquer la réponse de l'exercice 2
js
import { test, expect, vi } from 'vitest';
import { alerterRetards } from './retards.js';

test("n'alerte personne quand aucun prêt n'est en retard", async () => {
  const depot = { pretsNonRendusAvant: vi.fn().mockResolvedValue([]) };
  const alertes = { signaler: vi.fn() };

  const nombre = await alerterRetards({ depot, alertes, aujourdhui: '2027-03-20' });

  expect(nombre).toBe(0);
  expect(alertes.signaler).not.toHaveBeenCalled();
});

test('signale chaque prêt en retard', async () => {
  const depot = {
    pretsNonRendusAvant: vi.fn().mockResolvedValue([
      { salarie: 3, materiel: 'PC-07' },
      { salarie: 5, materiel: 'VP-02' },
    ]),
  };
  const alertes = { signaler: vi.fn().mockResolvedValue(undefined) };

  const nombre = await alerterRetards({ depot, alertes, aujourdhui: '2027-03-20' });

  expect(nombre).toBe(2);
  expect(alertes.signaler).toHaveBeenCalledTimes(2);
  expect(alertes.signaler).toHaveBeenCalledWith(3, 'PC-07');
  expect(alertes.signaler).toHaveBeenCalledWith(5, 'VP-02');
});

On peut ajouter expect(depot.pretsNonRendusAvant).toHaveBeenCalledWith('2027-03-20') pour vérifier que la date du jour est bien transmise.

Tu avais trouvé ?

Exercice 3 sur 4 Le test qui teste l'implémentation Réussi À revoir

Qu'est-ce qui ne va pas dans ce test ? Réécris-le.

js
test('verifierReservation', () => {
  const espion = vi.spyOn(regles, 'periodesSeChevauchent');

  regles.verifierReservation(
    { materiel: 'PC-07', debut: '2027-03-12', fin: '2027-03-18' },
    [{ materiel: 'PC-07', debut: '2027-03-10', fin: '2027-03-14' }],
    '2027-03-08',
  );

  expect(espion).toHaveBeenCalledTimes(1);
});
Voir la réponseMasquer la réponse de l'exercice 3

Le test vérifie comment la fonction travaille (elle appelle periodesSeChevauchent une fois), pas ce qu'elle produit. Si quelqu'un réécrit le contrôle de chevauchement sans cette fonction, le test casse alors que le comportement est le même ; et si la fonction renvoie un mauvais motif, le test reste vert. Son nom ne dit rien non plus. (De plus, en modules ES, un espion posé sur l'export ne voit pas les appels internes au module : le test échouerait sans raison claire.)

On vérifie le résultat :

js
test('refuse un matériel déjà réservé sur une partie de la période', () => {
  const resultat = verifierReservation(
    { materiel: 'PC-07', debut: '2027-03-12', fin: '2027-03-18' },
    [{ materiel: 'PC-07', debut: '2027-03-10', fin: '2027-03-14' }],
    '2027-03-08',
  );

  expect(resultat).toEqual({ acceptee: false, motif: 'PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.' });
});

Tu avais trouvé ?

Exercice 4 sur 4 Une doublure en C# Réussi À revoir

Écris avec xUnit et NSubstitute le test qui vérifie que ServiceRappels.EnvoyerRappels() renvoie 2 quand le dépôt renvoie deux prêts pour le lendemain, et que la messagerie reçoit deux envois.

Voir la réponseMasquer la réponse de l'exercice 4
csharp
[Fact]
public void EnvoyerRappels_DeuxPrets_EnvoieDeuxCourriels()
{
    var horloge = new FakeTimeProvider(new DateTimeOffset(2027, 3, 13, 8, 0, 0, TimeSpan.Zero));
    var depot = Substitute.For<IDepotPrets>();
    depot.PretsARendreLe(new DateOnly(2027, 3, 14)).Returns(
    [
        new Pret(41, "PC-07", "Julie", "julie.lefevre@sorbier.example", new DateOnly(2027, 3, 14)),
        new Pret(42, "VP-02", "Karim", "karim.morel@sorbier.example", new DateOnly(2027, 3, 14)),
    ]);
    var messagerie = Substitute.For<IMessagerie>();
    var service = new ServiceRappels(depot, messagerie, horloge);

    int envoyes = service.EnvoyerRappels();

    Assert.Equal(2, envoyes);
    messagerie.Received(2).Envoyer(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
    messagerie.Received(1).Envoyer("karim.morel@sorbier.example", "Rappel : VP-02 à rendre demain", Arg.Any<string>());
}

Le stub ne répond que pour le 14 mars : si le service demandait une autre date, il recevrait une liste vide (la valeur par défaut de NSubstitute pour une collection), et le test échouerait. La date du lendemain est donc vérifiée sans assertion supplémentaire.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Tests d'intégration et tests d'API avec SupertestRevoir le sommaire du cours