Aller au contenu principal
Version : Suivant

Fonctions simulées

Les fonctions simulées permettent de tester les liens entre le code en effaçant l'implémentation réelle d'une fonction, en capturant les appels à la fonction (et les paramètres passés dans ces appels), en capturant les instances des fonctions constructrices lorsqu'elles sont instanciées avec new, et en permettant la configuration des valeurs de retour au moment du test.

Il existe deux façons de simuler des fonctions : soit en créant une fonction simulée à utiliser dans le code de test, soit en écrivant une simulation manuelle pour remplacer une dépendance du module.

Utilisation d'une fonction simulée

Imaginons que nous testons l'implémentation d'une fonction forEach, qui appelle un callback pour chaque élément d'un tableau fourni.

forEach.js
export function forEach(items, callback) {
for (const item of items) {
callback(item);
}
}

Pour tester cette fonction, nous pouvons utiliser une fonction simulée, et inspecter l'état de la fonction simulée pour nous assurer que le callback est appelé comme prévu.

forEach.test.js
const forEach = require('./forEach');

const mockCallback = jest.fn(x => 42 + x);

test('forEach mock function', () => {
forEach([0, 1], mockCallback);

// The mock function was called twice
expect(mockCallback.mock.calls).toHaveLength(2);

// The first argument of the first call to the function was 0
expect(mockCallback.mock.calls[0][0]).toBe(0);

// The first argument of the second call to the function was 1
expect(mockCallback.mock.calls[1][0]).toBe(1);

// The return value of the first call to the function was 42
expect(mockCallback.mock.results[0].value).toBe(42);
});

Propriété .mock

Toutes les fonctions simulées ont cette propriété spéciale .mock, qui est l'endroit où sont conservées les données sur la façon dont la fonction a été appelée et ce que la fonction a renvoyé. La propriété .mock trace également la valeur de this pour chaque appel, il est donc possible de l'inspecter également :

const myMock1 = jest.fn();
const a = new myMock1();
console.log(myMock1.mock.instances);
// > [ <a> ]

const myMock2 = jest.fn();
const b = {};
const bound = myMock2.bind(b);
bound();
console.log(myMock2.mock.contexts);
// > [ <b> ]

Ces membres simulés sont très utiles dans les tests pour vérifier comment ces fonctions sont appelées, instanciées, ou ce qu'elles retournent :

// The function was called exactly once
expect(someMockFunction.mock.calls).toHaveLength(1);

// The first arg of the first call to the function was 'first arg'
expect(someMockFunction.mock.calls[0][0]).toBe('first arg');

// The second arg of the first call to the function was 'second arg'
expect(someMockFunction.mock.calls[0][1]).toBe('second arg');

// The return value of the first call to the function was 'return value'
expect(someMockFunction.mock.results[0].value).toBe('return value');

// The function was called with a certain `this` context: the `element` object.
expect(someMockFunction.mock.contexts[0]).toBe(element);

// This function was instantiated exactly twice
expect(someMockFunction.mock.instances.length).toBe(2);

// The object returned by the first instantiation of this function
// had a `name` property whose value was set to 'test'
expect(someMockFunction.mock.instances[0].name).toBe('test');

// The first argument of the last call to the function was 'test'
expect(someMockFunction.mock.lastCall[0]).toBe('test');

Valeurs de retour simulées

Les fonctions simulées peuvent également être utilisées pour injecter des valeurs de test dans votre code pendant un test :

const myMock = jest.fn();
console.log(myMock());
// > undefined

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true

Les fonctions simulées sont également très efficaces dans le code qui utilise un « style de passage de continuation » (NdT continuation-passing style) fonctionnel. Le code écrit dans ce style permet d'éviter le recours à des blocs compliqués qui recréent le comportement du composant réel qu'ils remplacent, en faveur de l'injection de valeurs directement dans le test juste avant leur utilisation.

const filterTestFn = jest.fn();

// Faire en sorte que la simulation renvoie `true` pour le premier appel,
// et `false` pour le second appel
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false);

const result = [11, 12].filter(num => filterTestFn(num));

console.log(result);
// > [11]
console.log(filterTestFn.mock.calls[0][0]); // 11
console.log(filterTestFn.mock.calls[1][0]); // 12

La plupart des exemples réels impliquent en fait de saisir une fonction fictive sur un composant dépendant et de la configurer, mais la technique est la même. Dans ces cas, essayez d'éviter la tentation d'implémenter la logique à l'intérieur de toute fonction qui n'est pas directement testée.

Modules de simulation

Supposons que nous ayons une classe qui récupère les utilisateurs de notre API. La classe utilise axios pour appeler l'API puis retourne l'attribut data qui contient tous les utilisateurs :

users.js
import axios from 'axios';

class Users {
static all() {
return axios.get('/users.json').then(resp => resp.data);
}
}

export default Users;

Maintenant, afin de tester cette méthode sans toucher à l'API (et donc créer des tests lents et fragiles), nous pouvons utiliser la fonction jest.mock(...) pour simuler automatiquement le module axios.

Une fois le module simulé, nous pouvons fournir un mockResolvedValue pour .get qui renvoie les données que nous voulons que notre test vérifie. En effet, nous voulons que axios.get('/users.json') renvoie une fausse réponse.

users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('doit récupérer les utilisateurs', () => {
const users = [{name: 'Bob'}];
const resp = {data: users};
axios.get.mockResolvedValue(resp);

// ou vous pouvez utiliser ce qui suit en fonction de votre cas d'utilisation :
// axios.get.mockImplementation(() => Promise.resolve(resp))

return Users.all().then(data => expect(data).toEqual(users));
});

Simulation partielle

Les sous-ensembles d'un module peuvent être simulés et le reste du module peut conserver son implémentation réelle :

foo-bar-baz.js
export const foo = 'foo';
export const bar = () => 'bar';
export default () => 'baz';
//test.js
import defaultExport, {bar, foo} from '../foo-bar-baz';

jest.mock('../foo-bar-baz', () => {
const originalModule = jest.requireActual('../foo-bar-baz');

//Simule l'exportation par défaut et l'exportation nommée 'foo'
return {
__esModule: true,
...originalModule,
default: jest.fn(() => 'mocked baz'),
foo: 'mocked foo',
};
});

test('devrait faire une simulation partielle', () => {
const defaultExportResult = defaultExport();
expect(defaultExportResult).toBe('mocked baz');
expect(defaultExport).toHaveBeenCalled();

expect(foo).toBe('mocked foo');
expect(bar()).toBe('bar');
});

Implémentations simulées

Pourtant, il existe des cas où il est utile d'aller plus loin que la possibilité de spécifier des valeurs de retour et de remplacer complètement l'implémentation d'une fonction simulée. Cela peut être fait avec jest.fn ou la méthode mockImplementationOnce sur les fonctions simulées.

const myMockFn = jest.fn(cb => cb(null, true));

myMockFn((err, val) => console.log(val));
// > true

La méthode mockImplementation est utile lorsque vous devez définir l'implémentation par défaut d'une fonction simulée qui est créée à partir d'un autre module :

foo.js
module.exports = function () {
// une implémentation;
};
test.js
jest.mock('../foo'); // cela se produit automatiquement avec l'autosimulation
const foo = require('../foo');

// foo est une fonction simulée
foo.mockImplementation(() => 42);
foo();
// > 42

Lorsque vous devez recréer un comportement complexe d'une fonction simulée, de sorte que plusieurs appels de fonction produisent des résultats différents, utilisez la méthode mockImplementationOnce :

const myMockFn = jest
.fn()
.mockImplementationOnce(cb => cb(null, true))
.mockImplementationOnce(cb => cb(null, false));

myMockFn((err, val) => console.log(val));
// > true

myMockFn((err, val) => console.log(val));
// > false

Lorsque la fonction simulée manque d'implémentations définies avec mockImplementationOnce, elle exécutera l'implémentation par défaut définie avec jest.fn (si elle est définie) :

const myMockFn = jest
.fn(() => 'par défaut')
.mockImplementationOnce(() => 'premier appel')
.mockImplementationOnce(() => 'second appel');

console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn());
// > 'premier appel', 'second appel', 'par défaut', 'par défaut'

Pour les cas où nous avons des méthodes qui sont typiquement enchaînées (et donc doivent toujours retourner this), nous avons une API sucrée pour simplifier cela sous la forme d'une fonction .mockReturnThis() qui se trouve également sur tous les simulations :

const myObj = {
myMethod: jest.fn().mockReturnThis(),
};

// est identique à

const otherObj = {
myMethod: jest.fn(function () {
return this;
}),
};

Noms simulés

You can optionally provide a name for your mock functions, which will be displayed instead of 'jest.fn()' in the test error output. Use .mockName() if you want to be able to quickly identify the mock function reporting an error in your test output.

const myMockFn = jest
.fn()
.mockReturnValue('default')
.mockImplementation(scalar => 42 + scalar)
.mockName('add42');

Comparateurs personnalisés

Enfin, pour qu'il soit moins difficile de déterminer comment les fonctions simulées ont été appelées, nous avons ajouté quelques fonctions comparatrices personnalisées :

// La fonction simulée a été appelée au moins une fois
expect(mockFunc).toHaveBeenCalled();

// La fonction simulée a été appelée au moins une fois avec les arguments spécifiés
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// Le dernier appel à la fonction simulée a été appelé avec les arguments spécifiés
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// Tous les appels et le nom de la simulation sont écrits en tant que snapshot
expect(mockFunc).toMatchSnapshot();

Ces comparateurs sont du sucre pour les formes courantes d'inspection de la propriété .mock. Vous pouvez toujours le faire manuellement si cela vous convient mieux ou si vous avez besoin de quelque chose de plus spécifique :

// La fonction simulée a été appelée au moins une fois
expect(mockFunc.mock.calls.length).toBeGreaterThan(0);

// La fonction simulée a été appelée au moins une fois avec les arguments spécifiés
expect(mockFunc.mock.calls).toContainEqual([arg1, arg2]);

// Le dernier appel à la fonction fantaisie a été appelé avec les arguments spécifiés
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1]).toEqual([
arg1,
arg2,
]);

// Le premier argument du dernier appel à la fonction simulée était `42`
// (notez qu'il n'existe pas d'aide sucrée pour cette assertion spécifique)
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1][0]).toBe(42);

// Un snapshot vérifiera qu'une simulation a été appelée le même nombre de fois,
// dans le même ordre, avec les mêmes arguments. Il vérifiera également le nom.
expect(mockFunc.mock.calls).toEqual([[arg1, arg2]]);
expect(mockFunc.getMockName()).toBe('un nom de simulation');

Pour une liste complète des comparateurs, consultez les docs de référence.