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ème | Avec les vraies dépendances | Avec des doublures |
|---|---|---|
| Effets de bord | Chaque lancement des tests envoie de vrais courriels aux salariés | Rien ne sort du test |
| Lenteur | Une connexion MySQL et un serveur SMTP : des centaines de millisecondes par test | Quelques 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 difficiles | Comment 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.
| Doublure | Ce qu'elle fait | Ce que le test vérifie | Exemple 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'avance | Le 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 code | Après coup : quels appels, avec quels arguments | Un 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ère | Les interactions avec la dépendance | La messagerie doit recevoir un envoi, pour l'adresse de Julie, avec le bon sujet |
| Fake (substitut) | Une implémentation simplifiée mais fonctionnelle | Le comportement du code avec une dépendance réaliste | Le dépôt en mémoire, qui range les réservations dans un tableau |
Le code à tester
// 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 :
| Écriture | La 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 :
| Assertion | Vé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
// 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
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
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.
// 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.
// 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.
// 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.`,
});
}
// 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 :
// 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 };
}
// 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.
// 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 :
// 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) :
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
// 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
// src/Messagerie.php
declare(strict_types=1);
namespace PretMatos;
interface Messagerie
{
public function envoyer(string $a, string $sujet, string $texte): void;
}
<?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
// 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ège | Symptôme | Remède |
|---|---|---|
| Simuler ce que l'on teste | Le test remplace la fonction testée elle-même : il ne vérifie plus rien | Ne remplace que les dépendances du code testé |
| Un stub irréaliste | Le stub renvoie des dates Date alors que le vrai dépôt renvoie des chaînes : les tests passent, la production plante | Construis 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 pas | Les tests reproduisent ta compréhension, peut-être fausse, de l'API d'un tiers | Enveloppe la bibliothèque dans ton propre module (messagerie.js) et simule ce module |
| Doublures non remises à zéro | Un test voit les appels d'un autre | Vitest 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).
- Un objet
horlogedont la méthodemaintenant()renvoie toujours le 8 mars 2027 à 9 h. - Une classe
DepotSalariesEnMemoirequi stocke les salariés dans un dictionnaire et permet d'en ajouter et d'en chercher. - Un
vi.fn()passé commejournal.erreurdans un test où aucune erreur ne peut se produire, et jamais vérifié. vi.spyOn(console, 'warn'), puisexpect(console.warn).toHaveBeenCalledTimes(1).- Un mock PHPUnit de
Messagerieavecexpects($this->once())->method('envoyer').
Voir la réponseMasquer la réponse de l'exercice 1
- Stub : il fournit une donnée d'entrée fixe.
- Fake : une implémentation simplifiée mais fonctionnelle.
- Dummy : il ne sert qu'à remplir un paramètre.
- Spy : il observe un vrai objet et le test vérifie les appels après coup.
- 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.
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
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.
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 :
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
[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é ?