Simulez vos appels réseau pour vos tests Jest avec MSW
Par Maxime Bréhin • Publié le 13 septembre 2021 • 7 min

Le code de nos applications est de plus en plus riche et les appels API de plus en plus fréquents. Pour gagner en sérénité, en pérennité et en qualité on essaie de mettre en place des tests pour couvrir a minima les fonctionnalités critiques. Les bibliothèques de tests se sont largement démocratisées ces dernières années et on constate (enfin) une vraie culture des tests en entreprise (même de petite taille).

La simplification des outils n’y est pas pour rien. Côté JavaScript, Jest a su séduire un très large public, notamment par ses aspects « tout en un » et « zéro config ». Il fait office, à l’heure où j’écris ces lignes, de référence aussi bien pour les tests front-end que back-end.

Cependant la mise en place de tests d’appels API est souvent perçue comme une épine dans le pied. Certaines questions reviennent fréquemment : que doit-on tester exactement ? Comment tester ? Comment faire en sorte que ces tests soient maintenables et leur mise en place reproductible ?

Vous préférez une vidéo ?

Si vous êtes du genre à préférer regarder que lire pour apprendre, on a pensé à vous :

Idéalement on souhaite décorréler nos tests des simulations d’appels API (mocks). En d’autre termes, on veut écrire nos tests comme si l’appel réseau était réalisé normalement. Sauf qu’en pratique on ne va pas appeler ni monter à la main un serveur localement juste pour simuler ces appels API, ça serait lourd et fastidieux (et on va encore moins appeler un serveur distant d’API, hein 😅 !).

Heureusement on bénéficie d’un truc génial pour intercepter nos requêtes au niveau réseau depuis le navigateur et nous renvoyer la réponse qu’on souhaite (jouant en quelque sorte le rôle d’un proxy) : les service workers. C’est hyper bien quand on est dans le navigateur, sauf qu’avec Jest on peut aussi être dans du Node.js, ou il resterait lourd de simuler toute la couche réseau d’un navigateur pour exécuter nos tests d’appels API front.

Que ce soit pour gérer dans le navigateur en dev (avec Storybook par exemple) ou côté Node.js, il existe une solution fabuleuse qui nous permet de mettre en place nos mocks quasiment sans effort : MSW (Mocked Service Worker).

Cet article vous montre comment mettre en place MSW (0.35) avec Jest (27) et quelles bonnes pratiques adopter. Le contexte est celui d’une page web réalisant des appels API REST via la fonction fetch (MSW mocke aussi facilement GraphQL, notez bien). Les tests étant exécutés par Jest (donc via Node.js), MSW ne sera pas utilisé en mode navigateur (avec un service worker) mais en mode serveur.

Un article complémentaire décrit la mise en œuvre de MSW avec Storybook pour réutiliser les mêmes mocks dans vos guides de style et revues visuelles.

L’application de démonstration utilisée pour ces deux articles est disponible sur GitHub.

Avant propos : pourquoi ne pas simplement mocker fetch ?

Kent C. Dodds, formateur JavaScript et auteur de l’excellent écosystème Testing Library, explique tout cela très bien dans son article “Stop mocking fetch”.

En résumé, il est préférable d’éviter de simuler le comportement du client car l’usage que nous en faisons pourrait devenir obsolète si l’API venait à changer. En d’autres termes, on a plus intérêt à laisser l’intégralité de notre code s’exécuter comme tel et intercepter les appels « autour ». C’est exactement ce que fait MSW pour nous !

Principes de fonctionnement

On peut utiliser MSW pour simuler nos appels API aussi bien dans le navigateur que côté Node.js (via Jest dans notre cas).

Dans un navigateur moderne, MSW nous fournira un service worker qui fera office de proxy interceptant les requêtes de l’application et retournant les réponses que nous aurons défini.

MSW dans un navigateur moderne

Côté Node.js ou vieux navigateurs (ex. IE11) MSW agira différement car il n’a pas les services workers. Il va donc augmenter le comportement des bibliothèques natives, tels le module noyau https ou le constructeur XMLHttpRequest. Autrement dit, il s’incruste dans les appels à ces API et s’assure de retourner les bonnes valeurs simulées pour les URLs demandées.

MSW dans Node.js ou IE11

Installer et configurer MSW

En bon module JavaScript, son installation se fait via :

npm install --save-dev msw
# ou si vous utilisez Yarn
yarn add --dev msw

Nous devons ensuite définir nos mocks. La doc MSW nous recommande de regrouper leurs définitions au même endroit, par exemple dans un répertoire src/mocks/. On viendra par la suite exposer ces jeux de données de tests via un module unique, classiquement src/mocks/handlers.js.

La suite de la procédure et des exemples décrits dans cet article traitent du mocking d’API REST. Si toutefois votre cas d’usage est lié à des appels GraphQL, sachez que MSW convient également mais que la mise en place est légèrement différente. Et si par un malheureux hasard vous faites du SOAP 🤢… vous avec l’habitude de souffrir et continuerez, MSW ne peut rien pour vous 😫.

Définir les requêtes interceptées et leurs mocks

Notre module handlers doit exporter un tableau contenant un mock spécifique par requête interceptée.

// On traite du REST donc on exploite le module idoine
import { rest } from 'msw'

export const handlers = [
// Récupère tous les billets de blog
rest.get('https://jsonplaceholder.typicode.com/posts', (req, res, ctx) =>),
// Récupère un billet spécifique
rest.get(`https://jsonplaceholder.typicode.com/posts/:id`, (req, res, ctx) =>),

]

Pour que ces mocks puissent être utilisés, nous indiquons à MSW quand les charger, dans quel contexte. Notre contexte étant Jest, on va donc demander à MSW d’intercepter côté Node.js les requêtes de notre application pour leur servir en réponse nos mocks, le cas échéant.

Configurer le « serveur » de mocks

On crée typiquement un fichier src/mocks/server.js, dont le but est de simuler une instance de serveur basée sur nos mocks. Je vous rappelle qu’en pratique aucun serveur n’est créé : MSW augmente le comportement des capacités natives comme https et XMLHttpRequest, ce qui a pour avantage de ne pas nuire à la performance des tests.

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Reste à exploiter tout ça dans nos tests. On utilise les opérations de préparation et de nettoyage des suites de tests Jest qu’on définit dans un fichier jest.setup.js à la racine du projet. Notez que vous pouvez procéder autrement (la doc officielle prend l’exemple de Create React App).

import { server } from './mocks/server.js'

// Demande au serveur de mocks d’écouter et d’intercepter
// les requêtes décrites dans les handlers.
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))

// Réinitialise les handlers entre chaque test pour éviter
// d’affecter les autres tests.
afterEach(() => server.resetHandlers())

// Arrête le serveur quand les tests sont terminés.
afterAll(() => server.close())

Enfin, on indique à Jest de charger ça en renseignant la clé setupFilesAfterEnv dans son fichier de configuration jest.config.js également à la racine du projet :

module.exports = {
setupFilesAfterEnv: ['./jest.setup.js'],
}

Il ne nous reste plus qu’à passer à nos tests et mettre en face les mocks appropriés.

Nos tests avec MSW

Tests unitaires ou tests d’intégration ? Peu importe, MSW servira dans les deux cas (sauf besoin de test complet « end to end » nécessitant la validation de l’appel à votre propre API).

On se concentre ici sur le cas classique de test unitaire : notre appel API a-t-il le comportement attendu ?

Partons d’un module src/api.js qui réalise des appels à un service tiers (ici JSON Placeholder). Notre module propose la consultation des billets de blogs.

// Node.js (et donc Jest) ne connaît pas fetch.
// cross-fetch sert ici de polyfill.
import fetch from 'cross-fetch'

export const POST_BASE_URL = 'https://jsonplaceholder.typicode.com/posts'

export function getPosts() {
return fetch(POST_BASE_URL).then((response) => response.json())
}

À terme on souhaite tester unitairement chaque méthode, pour chaque opération.

La procédure avec MSW étant la même pour chaque cas de figure, nous nous concentrerons ici sur la vérification du chargement de tous les billets. Pour centraliser les jeux de données de tests et faciliter la lisibilité du module handlers.js j’ai pris pour habitude de créer un fichier src/mocks/fixtures.js :

// Ces données serviront de mocks à MSW et
// de valeur de vérification dans mes tests.
export const POSTS = [
{ id: 1, title: 'First post', body: 'First post body', userId: 1 },
{ id: 2, title: 'Second post', body: 'Second post body', userId: 1 },
]

Pour que MSW serve ces données nous allons retourner dans notre module src/mocks/handlers.js et répondre à l’appel de l’URL https://jsonplaceholder.typicode.com/posts (que nous avons exportée au préalable depuis notre module api.js sous le nom POST_BASE_URL) :

import { POSTS } from './fixtures'
import { POST_BASE_URL } from '../api'
import { rest } from 'msw'

export const handlers = [
// Renvoie tous les billets au format JSON
rest.get(POST_BASE_URL, (req, res, ctx) => res(ctx.json(POSTS))),
]

Décortiquons ça. Pour réaliser le mocking de l’API appelée, nous devons dire à MSW quelle méthode HTTP est utilisée et vers quelle URL, puis lui indiquer quoi retourner.

rest.httpMethodName(url, responseHandler)

Le gestionnaire de réponse est une fonction qui prend trois arguments :

  1. les informations de la requête interceptée (req) ;
  2. une fonction utilitaire pour créer la réponse mockée (res) ;
  3. un objet fournissant un ensemble de fonctions permettant de renseigner le statut, les en-têtes et le corps de la réponse (ctx).

Dans notre exemple nous avons demandé à MSW de mocker un appel get à l’URL POST_BASE_URL et de retourner la liste POSTS des billets au format JSON. C’est concis, explicite, efficace !

Il ne reste plus qu’à créer notre fichier de tests src/api.test.js et à confronter le résultat de l’appel d’API à nos données de test.

import { getPosts } from './api'

import { POSTS } from './mocks/fixtures'

describe('Posts', () => {
it('loads all posts', async () => {
const posts = await getPosts()

expect(posts).toEqual(POSTS)
})
})

Note : vous remarquerez le recours à async/await pour traiter de manière plus lisible des appels retournant des promesses.

Une fois nos tests lancés (en mode watch tant qu’à faire) on constate que tout passe tranquillement : npm test (ou si votre tâche test n’est pas automatiquement en watch hors de la CI, npm test -- --watch).

Les tests Jest s’exécutent sans accroc

Des mocks adaptables

Il est possible que vous souhaitiez adapter le comportement de vos mocks selon les paramètres d’URL. Prenons par exemple le chargement d’un billet de blog spécifique. L’URL intégrera alors un identifiant nous permettant de simuler :

  • soit le chargement d’un billet ;
  • soit l’absence de billet associé (et éventuellement un message d’erreur en conséquence).

Dans notre fichier src/api.js nous aurions une méthode prenant en paramètre ledit identifiant :

export function getPost(id) {
return fetch(`${POST_BASE_URL}/${id}`).then((response) => response.json())
}

Nos tests décrivent les scénarios listés tout à l’heure (fichier src/api.test.js) :

import { POSTS, UNKNOWN_POST_ID } from './fixtures'

// Succès, billet trouvé et récupéré
it('loads the targeted post', async () => {
const id = 1
const post = await getPost(id)

expect(post).toEqual({ ...POSTS[0], id })
})

// Échec, billet inconnu
it('fails to load an unknown post', async () => {
const post = await getPost(UNKNOWN_POST_ID)

expect(post).toEqual({ error: `Unable to load blog entry #${UNKNOWN_POST_ID}` })
})

On a centralisé la constante UNKNOWN_POST_ID dans src/mocks/fixtures.js pour éviter l’effet magic number™ :

export const UNKNOWN_POST_ID = 42

Il ne nous reste plus qu’à mocker correctement ces comportements dans notre fichier src/mocks/handlers.js :

import { POSTS, UNKNOWN_POST_ID } from './fixtures'

export const handlers = [

rest.get(`${POST_BASE_URL}/:id`, (req, res, ctx) => {
const id = Number(req.params.id)

// On considère que seul le billet d’ID 42 n’existe pas.
if (id === UNKNOWN_POST_ID) {
return res(
ctx.status(404),
ctx.json({
error: `Unable to load blog entry #${id}`,
})
)
}

// Pour tous les autres on retourne un billet
// (bon là on fait au plus efficace en renvoyant toujours
// le même contenu en feintant et forçant l’ID, à vous de voir
// si vous préférez répondre autrement).
return res(ctx.json({ ...POSTS[0], id }))
}),
]

Notez le préfixe « : » pour les paramètres dynamiques. C’est avec cette syntaxe que nous pouvons les récupérer via l’objet req.params. Attention toutefois, c’est toujours fourni sous forme de String, un transtypage peut donc être nécessaire, comme ici.

Et voilà, encore une fois, nos tests fonctionnent comme sur des roulettes 🤘 !

J’adore quand un flanc se démoule sans accroc

En conclusion

MSW c’est de la balle ! Rapidité de mise en place, simplicité d’utilisation (et de réutilisation), pérennité du code ou performance, tout est là !

Et encore, vous n’avez peut-être pas encore vu le double avantage qu’il nous procure en développement, notamment avec Storybook.

Pour rappel, vous trouverez l’ensemble du code de démonstration sur GitHub.