
Mocking et stubbing : isoler les dépendances lors des tests
Apprenez à utiliser les techniques de mocking et de stubbing pour isoler vos composants des dépendances externes lors des tests unitaires et d'intégration en Node.js.
Le besoin d'isolement : pourquoi mocker ou stubber ?
Lors de l'écriture de tests, qu'ils soient unitaires ou d'intégration, l'un des objectifs principaux est d'isoler le code sous test. Les dépendances externes, telles que les bases de données, les API tierces, le système de fichiers ou même l'heure système, peuvent rendre les tests lents, non déterministes (leur résultat peut varier sans modification du code testé) et difficiles à configurer. Imaginez tester une fonction qui envoie un email à chaque exécution ; vous ne voulez pas envoyer de vrais emails pendant vos tests !
C'est là qu'interviennent le mocking et le stubbing. Ce sont des techniques utilisées pour créer des objets substituts, appelés "doubles de test" (Test Doubles), qui remplacent les dépendances réelles pendant l'exécution des tests. Ces doubles imitent le comportement des dépendances réelles de manière contrôlée, permettant ainsi de tester le code en isolation, de manière rapide et fiable.
L'isolation permet de se concentrer sur la logique spécifique du composant testé sans être affecté par le comportement ou l'état de ses dépendances. Si un test échoue, vous savez que le problème se situe dans l'unité testée elle-même, et non dans une dépendance externe imprévisible.
Stubbing : contrôler les entrées indirectes
Le stubbing consiste à remplacer une dépendance par une version simplifiée qui retourne des réponses prédéfinies et spécifiques, quelles que soient les entrées (ou pour des entrées spécifiques). L'objectif principal d'un stub est de fournir un état ou une valeur contrôlée au système sous test, afin de pouvoir vérifier comment ce dernier réagit.
On utilise typiquement un stub lorsqu'on veut s'assurer qu'une fonction dépendante retourne une certaine valeur pour que le code testé puisse suivre un chemin d'exécution particulier. Par exemple, si vous testez une fonction qui traite des données utilisateur récupérées depuis une base de données, vous pouvez "stubber" la méthode d'accès à la base de données pour qu'elle retourne un objet utilisateur spécifique, ou `null`, sans réellement interroger la base.
Avec Jest, on peut créer des stubs facilement. Supposons une fonction `getUserData(userId)` qui appelle `database.findUserById(userId)`. Pour tester `getUserData` sans appeler la vraie base de données, on peut stubber `database.findUserById` :
// Supposons un module database.js
const database = {
findUserById: (id) => { /* ... vraie logique DB ... */ throw new Error('Ne devrait pas être appelé en test'); }
};
// Le code à tester
function getUserData(userId) {
const user = database.findUserById(userId);
if (!user) {
return 'Utilisateur non trouvé';
}
return `Nom: ${user.name}`;
}
// Le test avec un stub (en utilisant jest.spyOn)
test('getUserData devrait retourner les infos formatées si l\'utilisateur existe', () => {
// Arrange: Créer un stub pour findUserById
const mockUser = { id: 1, name: 'Alice' };
// jest.spyOn permet de remplacer temporairement une méthode
const findUserStub = jest.spyOn(database, 'findUserById')
.mockReturnValue(mockUser); // Le stub retourne toujours mockUser
// Act
const result = getUserData(1);
// Assert: Vérifier le résultat basé sur la valeur du stub
expect(result).toBe('Nom: Alice');
// Optionnel: vérifier que le stub a été appelé
expect(findUserStub).toHaveBeenCalledWith(1);
// Nettoyage: Restaurer l'implémentation originale (important!)
findUserStub.mockRestore();
});
test('getUserData devrait retourner un message si l\'utilisateur n\'existe pas', () => {
// Arrange: Stub retournant null
const findUserStub = jest.spyOn(database, 'findUserById').mockReturnValue(null);
// Act
const result = getUserData(2);
// Assert
expect(result).toBe('Utilisateur non trouvé');
expect(findUserStub).toHaveBeenCalledWith(2);
// Nettoyage
findUserStub.mockRestore();
});
Ici, `jest.spyOn(database, 'findUserById').mockReturnValue(...)` crée un stub qui remplace la vraie fonction `findUserById` et contrôle ce qu'elle retourne, permettant de tester les différents chemins logiques de `getUserData`.
Mocking : vérifier les sorties indirectes (interactions)
Le mocking va un peu plus loin que le stubbing. Un mock est également un objet substitut, mais son objectif principal est de vérifier les interactions entre le système sous test et ses dépendances. Un mock enregistre les appels qu'il reçoit (quelle méthode a été appelée, combien de fois, avec quels arguments) et permet ensuite au test d'effectuer des assertions sur ces appels.
On utilise un mock lorsqu'on veut s'assurer que notre code appelle correctement une autre partie du système, particulièrement quand cet appel a un effet de bord (envoyer un email, enregistrer un log, mettre à jour une base de données). Le mock remplace la dépendance et vérifie si elle a été utilisée comme prévu.
Imaginons une fonction `registerUser(email)` qui doit appeler `emailService.sendWelcomeEmail(email)` après avoir créé l'utilisateur. Pour tester que l'email est bien envoyé, sans l'envoyer réellement, on peut "mocker" le service d'email :
// Supposons un module emailService.js
const emailService = {
sendWelcomeEmail: (email) => { /* ... vraie logique d'envoi ... */ console.log(`Envoi réel à ${email}`); }
};
// Le code à tester
function registerUser(email) {
// ... logique de création utilisateur ...
console.log(`Utilisateur ${email} enregistré.`);
// Appel de la dépendance
emailService.sendWelcomeEmail(email);
}
// Le test avec un mock (en utilisant jest.fn)
test('registerUser devrait envoyer un email de bienvenue', () => {
// Arrange: Créer une fonction mock pour remplacer sendWelcomeEmail
const sendEmailMock = jest.fn(); // Crée une fonction mock vide
// Remplacer la vraie fonction par le mock
emailService.sendWelcomeEmail = sendEmailMock;
const userEmail = 'test@example.com';
// Act: Exécuter la fonction qui doit appeler le mock
registerUser(userEmail);
// Assert: Vérifier que le mock a été appelé correctement
expect(sendEmailMock).toHaveBeenCalledTimes(1); // A-t-il été appelé une fois ?
expect(sendEmailMock).toHaveBeenCalledWith(userEmail); // A-t-il été appelé avec le bon email ?
// Note: Dans un vrai scénario, on utiliserait plutôt jest.spyOn ou jest.mock pour une restauration plus propre.
});
Ici, `jest.fn()` crée une fonction mock qui ne fait rien par défaut mais enregistre les appels. Les assertions `toHaveBeenCalledTimes` et `toHaveBeenCalledWith` permettent de vérifier que l'interaction attendue avec `emailService` a bien eu lieu. C'est une vérification de comportement (behavior verification).
Distinction clé et utilisation de jest.mock()
Bien que les termes "mock" et "stub" soient souvent utilisés de manière interchangeable, la distinction conceptuelle réside dans l'intention du test :
- Stub : On l'utilise pour fournir un état contrôlé au système sous test. La vérification porte sur l'état final du système testé (State Verification).
- Mock : On l'utilise pour vérifier que le système sous test interagit correctement avec ses dépendances. La vérification porte sur les appels effectués vers le mock (Behavior Verification).
Dans la pratique, de nombreux outils de test (comme Jest) fournissent des fonctionnalités qui combinent les deux aspects. Un "mock" Jest (`jest.fn()` ou le résultat de `jest.spyOn`) peut à la fois être configuré pour retourner des valeurs spécifiques (stubbing) et être utilisé pour vérifier les appels (mocking).
Une fonctionnalité très puissante de Jest est `jest.mock(moduleName)`. Elle permet de remplacer automatiquement toutes les exportations d'un module par des fonctions mock. C'est particulièrement utile pour mocker des modules entiers importés via `require` ou `import`.
// userService.js
const logger = require('./logger'); // Dépendance à mocker
function processUserData(data) {
if (!data) {
logger.error('Données invalides reçues');
return false;
}
logger.info('Traitement des données utilisateur...');
// ... logique de traitement ...
return true;
}
module.exports = { processUserData };
// logger.js (le module à mocker)
module.exports = {
info: (message) => console.log(`INFO: ${message}`),
error: (message) => console.error(`ERROR: ${message}`)
};Le test utilisant `jest.mock()` :
// userService.test.js
const { processUserData } = require('./userService');
const logger = require('./logger');
// Dire à Jest de remplacer automatiquement le module 'logger' par un mock
jest.mock('./logger');
describe('processUserData', () => {
beforeEach(() => {
// Réinitialiser les mocks avant chaque test pour l'indépendance
jest.clearAllMocks();
// ou logger.info.mockClear(); logger.error.mockClear();
});
test('devrait logger une erreur si les données sont invalides', () => {
// Arrange
const invalidData = null;
// Act
const result = processUserData(invalidData);
// Assert
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith('Données invalides reçues');
expect(logger.info).not.toHaveBeenCalled(); // Vérifier que info n'a pas été appelé
});
test('devrait logger une info si les données sont valides', () => {
// Arrange
const validData = { name: 'Test User' };
// Act
const result = processUserData(validData);
// Assert
expect(result).toBe(true);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith('Traitement des données utilisateur...');
expect(logger.error).not.toHaveBeenCalled();
});
});Avec `jest.mock('./logger')`, toutes les fonctions exportées par `logger.js` (`info` et `error`) sont automatiquement remplacées par des fonctions mock (`jest.fn()`), nous permettant de vérifier facilement si elles ont été appelées.
Conseils et bonnes pratiques
Pour utiliser efficacement le mocking et le stubbing, voici quelques recommandations :
- N'abusez pas du mocking : Mockez uniquement ce qui est nécessaire pour isoler l'unité sous test. Trop mocker peut rendre les tests fragiles et étroitement couplés à l'implémentation interne, plutôt qu'au comportement observable.
- Gardez les mocks simples : Un mock ne devrait pas réimplémenter la logique complexe de la dépendance réelle. Concentrez-vous sur l'interface et les interactions minimales nécessaires au test.
- Testez le contrat, pas l'implémentation : Assurez-vous que vos mocks respectent le contrat (interface, types de retour, erreurs potentielles) de la dépendance réelle. Si l'interface de la dépendance change, vos mocks devront peut-être être mis à jour.
- Nettoyez après vous : Si vous modifiez des objets ou des modules globaux (comme avec `jest.spyOn`), assurez-vous de restaurer leur état d'origine après chaque test (par exemple avec `mockRestore()` ou en utilisant les fonctions de setup/teardown de Jest comme `beforeEach` ou `afterEach`). `jest.clearAllMocks()` est utile pour réinitialiser les compteurs d'appels entre les tests.
- Préférez les vraies instances quand c'est possible et pertinent : Pour les tests d'intégration, il est parfois préférable d'utiliser une vraie base de données de test ou une version en mémoire plutôt que de tout mocker, afin de tester l'intégration réelle.
Le mocking et le stubbing sont des outils puissants pour écrire des tests robustes et isolés en Node.js. En comprenant leur rôle et en les appliquant judicieusement, vous pouvez améliorer significativement la qualité et la maintenabilité de votre code, tout en accélérant votre cycle de développement grâce à des tests rapides et fiables.