Aller au contenu principal
Version : Suivant

Simulations de classe ES6

Jest peut être utilisé pour simuler les classes ES6 qui sont importées dans les fichiers que vous voulez tester.

Les classes ES6 sont des fonctions constructrices avec du sucre syntaxique. Par conséquent, toute simulation d'une classe ES6 doit être une fonction ou une classe ES6 réelle (qui est, elle aussi, une autre fonction). Vous pouvez donc les simuler en utilisant des fonctions simulées.

Un exemple de classe ES6

Nous utiliserons un exemple factice d'une classe qui lit des fichiers sonores, SoundPlayer, et une classe de consommatrice qui utilise cette classe, SoundPlayerConsumer. Nous allons simuler SoundPlayer dans nos tests pour SoundPlayerConsumer.

sound-player.js
export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}

playSoundFile(fileName) {
console.log('Lecture du fichier audio ' + fileName);
}
}
sound-player-consumer.js
import SoundPlayer from './sound-player';

export default class SoundPlayerConsumer {
constructor() {
this.soundPlayer = new SoundPlayer();
}

playSomethingCool() {
const coolSoundFileName = 'song.mp3';
this.soundPlayer.playSoundFile(coolSoundFileName);
}
}

Les 4 façons de créer une simulation de classe ES6

Simulation automatique

L'appel à jest.mock('./sound-player') renvoie une « simulation automatique » utile que vous pouvez utiliser pour espionner les appels au constructeur de la classe et à toutes ses méthodes. Elle remplace la classe ES6 par un constructeur simulé, et remplace toutes ses méthodes par des fonctions simulées qui renvoient toujours undefined. Les appels de méthode sont enregistrés dans theAutomaticMock.mock.instances[index].methodName.mock.calls.

remarque

If you use arrow functions in your classes, they will not be part of the mock. La raison, c'est que les fonctions fléchées ne sont pas présentes sur le prototype de l'objet, ce sont simplement des propriétés contenant une référence à une fonction.

Si vous n'avez pas besoin de remplacer l'implémentation de la classe, c'est l'option la plus simple à mettre en place. Par exemple :

import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor

beforeEach(() => {
// Clear all instances and calls to constructor and all methods:
SoundPlayer.mockClear();
});

it('We can check if the consumer called the class constructor', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});

it('We can check if the consumer called a method on the class instance', () => {
// Show that mockClear() is working:
expect(SoundPlayer).not.toHaveBeenCalled();

const soundPlayerConsumer = new SoundPlayerConsumer();
// Constructor should have been called again:
expect(SoundPlayer).toHaveBeenCalledTimes(1);

const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();

// mock.instances is available with automatic mocks:
const mockSoundPlayerInstance = SoundPlayer.mock.instances[0];
const mockPlaySoundFile = mockSoundPlayerInstance.playSoundFile;
expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName);
// Equivalent to above check:
expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
expect(mockPlaySoundFile).toHaveBeenCalledTimes(1);
});

Simulation manuelle

Créez une simulation manuelle en sauvegardant une implémentation de simulation dans le dossier __mocks__. Cela vous permet de spécifier l'implémentation, et cela peut être utilisé dans plusieurs fichiers de test.

__mocks__/sound-player.js
// Importe cet export nommé dans votre fichier de test :
export const mockPlaySoundFile = jest.fn();
const mock = jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});

export default mock;

Importez la simulation et la méthode simulée partagée par toutes les instances :

sound-player-consumer.test.js
import SoundPlayer, {mockPlaySoundFile} from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer est maintenant un constructeur simulé

beforeEach(() => {
// Efface toutes les instances et les appels au constructeur et à toutes les méthodes :
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});

it('Nous pouvons vérifier si le consommateur a appelé le constructeur de la classe', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});

it('Nous pouvons vérifier si le consommateur a appelé une méthode sur l'instance de la classe', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();
expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
});

Appel de jest.mock() avec le paramètre factory du module

jest.mock(path, moduleFactory) prend un argument factory de module. Un factory de module est une fonction qui renvoie la simulation.

Pour pouvoir simuler une fonction de constructeur, la factory de module doit retourner une fonction de constructeur. En d'autres termes, le factory de module doit être une fonction qui renvoie une fonction - une fonction d'ordre supérieur (« higher-order function » HOF).

import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});
attention

Comme les appels à jest.mock() sont remontés au sommet du fichier, Jest empêche l'accès aux variables hors de portée. Par défaut, vous ne pouvez pas d'abord définir une variable et ensuite l'utiliser dans la factory. Jest désactivera cette vérification pour les variables qui commencent par le mot mock. Cependant, c'est à vous de garantir qu'elles seront initialisées à temps . Attention à la zone morte temporaire.

Par exemple, l'exemple suivant déclenchera une erreur hors du champ d'application en raison de l'utilisation de fake au lieu de mock dans la déclaration de la variable .

// Remarque : ceci échouera
import SoundPlayer from './sound-player';
const fakePlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: fakePlaySoundFile};
});
});

Ce qui suit lancera une ReferenceError malgré l'utilisation de mock dans la déclaration de la variable, car le mockSoundPlayer n'est pas enveloppé dans une fonction fléchée et elle est donc accédée avant l'initialisation après le hissage.

import SoundPlayer from './sound-player';
const mockSoundPlayer = jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
// donne lieu à une ReferenceError
jest.mock('./sound-player', () => {
return mockSoundPlayer;
});

Remplacer la simulation en utilisant mockImplementation() ou mockImplementationOnce()

Vous pouvez remplacer tous les simulations ci-dessus afin de modifier l'implémentation, pour un seul test ou tous les tests, en appelant mockImplementation() sur la simulation existante.

Les appels à jest.mock sont remontés au sommet du code. Vous pouvez spécifier une simulation ultérieurement, par exemple dans beforeAll(), en appelant mockImplementation() (ou mockImplementationOnce()) sur la simulation existante au lieu d'utiliser le paramètre factory. Cela vous permet également de modifier la simulation entre les tests, si nécessaire :

import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

jest.mock('./sound-player');

describe('Quand SoundPlayer lève une erreur', () => {
beforeAll(() => {
SoundPlayer.mockImplementation(() => {
return {
playSoundFile: () => {
throw new Error('Erreur de test');
},
};
});
});

it('Il devrait y avoir une erreur lors de l\'appel de playSomethingCool', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(() => soundPlayerConsumer.playSomethingCool()).toThrow();
});
});

En détail : comprendre les fonctions de constructeur simulées

La construction de votre fonction constructeur simulée en utilisant jest.fn().mockImplementation() fait croire que les simulations sont plus compliquées qu'elles ne le sont réellement. Cette section montre comment vous pouvez créer vos propres simulations pour illustrer le fonctionnement de la simulation.

Simulation manuelle qui est une autre classe ES6

Si vous définissez une classe ES6 utilisant le même nom de fichier que la classe simulée dans le dossier __mocks__, elle servira de simulation. Cette classe sera utilisée à la place de la classe réelle. Cela vous permet d'injecter une implémentation de test pour la classe, mais ne fournit pas un moyen d'espionner les appels.

Pour l'exemple inventé, la simulation pourrait ressembler à ceci :

__mocks__/sound-player.js
export default class SoundPlayer {
constructor() {
console.log('Mock SoundPlayer : le constructeur a été appelé');
}

playSoundFile() {
console.log('Mock SoundPlayer : playSoundFile a été appelé');
}
}

Simuler en utilisant le paramètre factory du module

La fonction factory de modules passée à jest.mock(path, moduleFactory) peut être un HOF qui renvoie une fonction*. Cela permettra d'appeler new sur la simulation. Encore une fois, cela vous permet d'injecter un comportement différent pour les tests, mais ne fournit pas un moyen d'espionner les appels.

* La fonction factory du module doit retourner une fonction

Pour pouvoir simuler une fonction de constructeur, la factory de module doit retourner une fonction de constructeur. En d'autres termes, le factory de module doit être une fonction qui renvoie une fonction - une fonction d'ordre supérieur (« higher-order function » HOF).

jest.mock('./sound-player', () => {
return function () {
return {playSoundFile: () => {}};
};
});
remarque

The mock can't be an arrow function because calling new on an arrow function is not allowed in JavaScript. Donc ça ne marchera pas :

jest.mock('./sound-player', () => {
return () => {
// Ne fonctionne pas; les fonctions fléchées ne peuvent pas être appelées avec new
return {playSoundFile: () => {}};
};
});

Cela lèvera TypeError: _soundPlayer2.default is not a constructor, sauf si le code est transposé en ES5, par exemple par @babel/preset-env. (ES5 n'a pas de fonctions fléchées ni de classes, donc les deux seront transpiilées en fonctions simples.)

Simulation d'une méthode spécifique d'une classe

Lets say that you want to mock or spy on the method playSoundFile within the class SoundPlayer. Un exemple simple :

// votre fichier jest ci-dessous
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

const playSoundFileMock = jest
.spyOn(SoundPlayer.prototype, 'playSoundFile')
.mockImplementation(() => {
console.log('fonction simulée');
}); // commentez cette ligne si vous voulez juste « espionner ».

it('le lecteur joue de la musique', () => {
const player = new SoundPlayerConsumer();
player.playSomethingCool();
expect(playSoundFileMock).toHaveBeenCalled();
});

Méthodes statiques, getter et setter

Imaginons que notre classe SoundPlayer a une méthode getter foo et une méthode statique brand

export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}

playSoundFile(fileName) {
console.log('Lecture du fichier audio ' + fileName);
}

get foo() {
return 'bar';
}
static brand() {
return 'player-brand';
}
}

You can mock/spy on them easily, here is an example:

// your jest test file below
import SoundPlayer from './sound-player';

const staticMethodMock = jest
.spyOn(SoundPlayer, 'brand')
.mockImplementation(() => 'some-mocked-brand');

const getterMethodMock = jest
.spyOn(SoundPlayer.prototype, 'foo', 'get')
.mockImplementation(() => 'some-mocked-result');

it('custom methods are called', () => {
const player = new SoundPlayer();
const foo = player.foo;
const brand = SoundPlayer.brand();

expect(staticMethodMock).toHaveBeenCalled();
expect(getterMethodMock).toHaveBeenCalled();
});

Suivre l'utilisation (espionner la simulation)

L'injection d'une implémentation de test est utile, mais vous voudrez probablement aussi tester si le constructeur et les méthodes de la classe sont appelés avec les bons paramètres.

Espionnage du constructeur

Afin de suivre les appels au constructeur, remplacez la fonction retournée par le HOF par une fonction simulée Jest. Créez-la avec jest.fn(), puis spécifiez son implémentation avec mockImplementation().

import SoundPlayer from './sound-player';
jest.mock('./sound-player', () => {
// Fonctionne et vous permet de vérifier les appels du constructeur :
return jest.fn().mockImplementation(() => {
return {playSoundFile: () => {}};
});
});

This will let us inspect usage of our mocked class, using SoundPlayer.mock.calls: expect(SoundPlayer).toHaveBeenCalled(); or near-equivalent: expect(SoundPlayer.mock.calls.length).toBeGreaterThan(0);

Simulation de classe non exportée par défaut

Si la classe n'est pas l'exportation par défaut du module alors vous devez retourner un objet avec la clé qui est la même que le nom d'exportation de la classe.

import {SoundPlayer} from './sound-player';
jest.mock('./sound-player', () => {
// Fonctionne et vous permet de vérifier les appels du constructeur :
return {
SoundPlayer: jest.fn().mockImplementation(() => {
return {playSoundFile: () => {}};
}),
};
});

Espionnage sur les méthodes de notre classe

Notre classe simulée devra fournir toutes les fonctions membres (playSoundFile dans l'exemple) qui seront appelées pendant nos tests, sinon nous obtiendrons une erreur pour avoir appelé une fonction qui n'existe pas. Mais nous voudrons probablement aussi espionner les appels à ces méthodes, pour nous assurer qu'elles ont été appelées avec les paramètres attendus.

Un nouvel objet sera créé chaque fois que la fonction constructeur simulée sera appelée pendant les tests. Pour espionner les appels de méthode dans tous ces objets, nous remplissons playSoundFile avec une autre fonction simulée, et nous stockons une référence à cette même fonction simulée dans notre fichier de test, afin qu'elle soit disponible pendant les tests.

import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
// Nous pouvons maintenant suivre les appels vers playSoundFile
});
});

L'équivalent de la simulation manuelle serait le suivant :

__mocks__/sound-player.js
// Importe cet export nommé dans votre fichier de test
export const mockPlaySoundFile = jest.fn();
const mock = jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});

export default mock;

L'utilisation est similaire à la fonction factory du module, sauf que vous pouvez omettre le second argument de jest.mock(), et vous devez importer la méthode simulée dans votre fichier de test, puisqu'elle n'y est plus définie. Utilisez le chemin du module original pour cela ; n'incluez pas __mocks__.

Nettoyage entre les tests

Pour effacer l'enregistrement des appels à la fonction constructeur simulée et à ses méthodes, nous appelons mockClear() dans la fonction beforeEach() :

beforeEach(() => {
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});

Exemple complet

Voici un fichier de test complet qui utilise le paramètre factory du module pour jest.mock :

sound-player-consumer.test.js
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});

beforeEach(() => {
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});

it('The consumer should be able to call new() on SoundPlayer', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
// Ensure constructor created the object:
expect(soundPlayerConsumer).toBeTruthy();
});

it('We can check if the consumer called the class constructor', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});

it('We can check if the consumer called a method on the class instance', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();
expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName);
});