Simulez vos appels réseau dans Storybook avec MSW
Par Maxime Bréhin • Publié le 13 septembre 2021
• 6 min
Storybook, c’est génialissime pour développer des composants d’interface utilisateur, faire du BDD ou simplement illustrer une bibliothèque de composants UX.
Pour la plupart des composants ça se passe en général très bien. Sauf qu’à un moment on se retrouve confronté·e à un problème de taille : créer des scénarios d’affichage pour un composant qui consomme des données depuis une API.
En réalité on peut laisser ces appels API se faire, ça fonctionnera très bien tant qu’on a un serveur démarré joignable pour servir cette API. Sauf qu’en cas de panne réseau ou de serveur manquant, ou si l’API dudit serveur n’est pas encore prête, ou tout un tas d’autres raisons qu’il serait trop long d’énumérer, bah on est marron 😨 !
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 préférera pouvoir travailler avec Storybook et nos composants sans contrainte. Si on prend un peu de recul, on comprend qu’on a besoin que Storybook nous permette de charger des réponses de démo pour répondre aux appels de nos composants.
Et chevauchant fièrement dans les plaines JS arriva MSW (Mock Service Worker), triomphant !
Comme je l’explique dans notre article sur Jest et MSW, il est préférable d’éviter de mocker les modules de l’application et de court-circuiter une partie de notre logique pour renvoyer nos jeux de données de test. On a plus intérêt à capturer les appels API une fois « sortis » de l’application, ça nous évite la surprise d’un changement de comportement d’un module utilisé en interne. C’est à ça que MSW va nous servir.
En bonus très appréciable, MSW nous permet de mutualiser nos mocks entre nos tests et nos développements.
Fonctionnement
MSW nous fournit un service worker côté client (processus d’arrière plan dans le navigateur) qui capture toutes les requêtes réseau que notre code tente de faire. Dès lors qu’une requête correspond à l’une de celles qu’il écoute, il retournera la réponse simulée en conséquence.
Ce qui est plutôt cool là-dedans, c’est que tout est fait pour qu’on se concentre uniquement sur les jeux de données de test.
Vous pouvez voir ça comme un proxy réseau, mais local à la page.
Pour nos exemples…
Histoire d’aller au plus direct nous construirons nos composants en HTML/JS natifs. La procédure décrite ici n’est pas spécifique à une technologie et peut donc s’appliquer pour tout le spectre des bibliothèques/frameworks avec lesquels Storybook fonctionne.
Nous allons mettre en place des composants qui consomment des données via des appels REST à l’API JSON Placeholder et affichent…
- la liste des billets de blog (composant
src/components/Posts.js
) ; - le détail d’un billet de blog (composant
src/components/Post.js
).
Les appels API sont centralisés dans un module src/api.js
dont voici le contenu :
export const POST_BASE_URL = 'https://jsonplaceholder.typicode.com/posts'
export function getPosts() {
return fetch(POST_BASE_URL).then((response) => response.json())
}
export function getPost(id) {
return fetch(`${POST_BASE_URL}/${id}`).then((response) => response.json())
}
Et voici en exemple le code du composants POSTS
qui affichera sous forme d’un tableau des éléments récupérés :
import { getPosts } from '../api'
export default function Posts() {
// Du fait que l’appel à l’API soit asynchrone et parce que Storybook ne sait
// pas gérer l’asynchrone, notre composant retourne de manière synchrone le
// `div` enrobant dans lequel `loadPosts` viendra injecter ses éléments
// une fois la requête réalisée.
const container = document.createElement('div')
loadPosts()
return container
async function loadPosts() {
const posts = await getPosts()
if (posts.length === 0) {
return document.createTextNode('Aucun billet trouvé')
}
const table = document.createElement('table')
table.className =
'mdl-data-table mdl-js-data-table mdl-data-table--selectable mdl-shadow--2dp'
const postsRows = posts.map(
({ id, title }) =>
`<tr><td>${id}</td><td class="mdl-data-table__cell--non-numeric">${title}</td></tr>`
)
table.insertAdjacentHTML(
'beforeend',
`<tbody>${postsRows.join('')}</tbody>`
)
container.appendChild(table)
}
}
Mettons en place Storybook
Si ça n’est pas déjà fait, vous pouvez installer et pré-configurer Storybook en lançant la commande npx sb init
qui vous demandera dans quel contexte vous souhaitez l’utiliser (dans le cadre de cet article j’ai répondu html
).
Note : à l’heure où j’écris ces lignes Webpack 5 est la version majeure en place mais Storybook fonctionne encore avec la version 4 par défaut. Vous pouvez néanmoins forcer leur version expérimentale (entamée en mars 2021) en faisant un npx sb init --builder webpack5
.
Testez que tout roule en lançant npm run storybook
.
On reviendra à la configuration de Storybook par la suite pour le coupler à MSW, mais d’abord…
Mettons en place MSW
La mise en place de MSW côté navigateur demande, outre son installation, la création du service worker, son démarrage, la création et l’exposition de nos jeux de données simulés.
installation
Pour l’installation du module, c’est vite fait :
npm install --save-dev msw
# ou si vous utilisez Yarn
yarn add --dev msw
Création du service worker
La création du service worker, c’est tout aussi rapide :
npx msw init <PUBLIC_DIR> --save
Ça vient préciser l’emplacement du fichier généré dans le package.json
:
{
…
"msw": {
"workerDirectory": "dist"
},
…
}
Note : le répertoire publique utilisé dépend de votre architecture projet. Dans notre cas, on utilise ./dist
.
Préparation des données simulées
Nous préparons les mocks dans un fichier src/mocks/handlers.js
. L’objet retourné est un tableau qui décrit pour chaque entrée une réponse de test à apporter à une URL donnée.
import { POST_BASE_URL } from '../api'
import { rest } from 'msw'
const POSTS = [
{ id: 1, title: 'First post', body: 'First post body', userId: 1 },
{ id: 2, title: 'Second post', body: 'Second post body', userId: 1 },
]
export const handlers = [
// Renvoie toute la liste des billets de blog
rest.get(POST_BASE_URL, (req, res, ctx) => res(ctx.json(POSTS))),
// Retourne un billet de blog avec l’ID recherché.
// Notez la syntaxe usant du préfixe `:` qui permet d’indiquer
// qu’un paramètre est dynamique. Ceci permet sa récupération depuis
// l’objet `req.params`.
rest.get(`${POST_BASE_URL}/:id`, (req, res, ctx) =>
res(ctx.json({ ...POSTS[0], id: Number(req.params.id) }))
),
]
Chaque entrée du tableau fait appel à la fonction rest
de MSW et emploie la structure rest.httpMethodName(url, responseHandler)
.
La méthode peut prendre une valeur parmi les méthodes de requête HTTP standard, les plus courantes étant : get/post/put/patch/delete
.
Le gestionnaire de réponse est une fonction qui prend trois arguments :
- les informations de la requête interceptée (
req
) ; - une fonction utilitaire pour créer la réponse mockée (
res
) ; - un objet fournissant un ensemble de fonctions permettant de renseigner le statut, les en-têtes et le corps de la réponse (
ctx
).
Si on prend par exemple la récupération de nos billets de blog, la fonction retourne res(ctx.json(POSTS))
, à savoir une réponse renseignant notre « fausse » liste de billets au format JSON.
Mettre les mocks à disposition du service worker
On doit ensuite créer le module qui permettra de charger et transmettre nos mocks au service worker. On crée un fichier src/mocks/browser.js
dans lequel on charge nos gestionnaires :
import { handlers } from './handlers'
import { setupWorker } from 'msw'
// Permet au service worker de savoir quelle
// réponse transmettre à telle ou telle URL.
export const worker = setupWorker(...handlers)
Couplons MSW à Storybook
Maintenant qu’on a tout ça, il faut démarrer notre service worker avec Storybook. On touche donc à la configuration de Storybook et son fichier .storybook/preview.js
dans lequel on va faire :
// Storybook exécute ce module une fois au chargement (Node.js), puis à
// l'éxecution (navigateur). Le chargement du service worker n'a de sens que
// dans le second cas.
if (typeof process === 'undefined') {
const { worker } = require('../src/mocks/browser')
// Ça vient démarrer le *mocking* quand chaque story est chargée. MSW se
// débrouille pour éviter de charger X fois le service s’il reçoit plusieurs
// appels à `start`.
worker.start()
}
Dernier point de configuration : pour que MSW puisse enregistrer le service worker, nous devons indiquer à Storybook l’emplacement des fichiers statiques à servir. Ça se fait avec l’option -s
qu’on ajoute au script storybook
dans notre fichier package.json
:
{
…
"scripts": {
…
"storybook": "start-storybook -p 6006 -s dist"
}
…
}
Reste à vérifier que tout marche bien. On lance donc Storybook : npm run storybook
. Et voilà !
Bonus : utiliser MSW en développement
Il est possible que vous souhaitiez également bénéficier du mocking lors de vos développements, hors Storybook. C’est plus que probable si vous n’ayez pas accès à une connexion internet (contraintes de l’entreprise ou parce que vous bossez dans les transports).
Ça ne demande alors qu’une chose : charger dans votre application le MSW et son service worker quand vous êtes en développement.
Dans notre application d’exemple, le point d’entrée applicatif est le fichier src/index.js
. On peut lui ajouter un appel conditionnel comme nous l’avons fait avec Storybook :
import Posts from './components/Posts'
// On fait bien attention à ne charger le service
// worker qu’en développement.
if (process.env.NODE_ENV === 'development') {
const { worker } = require('./mocks/browser')
worker.start()
}
buildHTML()
function buildHTML() {
const app = document.getElementById('app')
const updatedApp = document.createElement('div')
updatedApp.id = 'app'
updatedApp.appendChild(Posts())
const container = app.parentNode
container.replaceChild(updatedApp, app)
}
Et voilà, votre application utilise désormais vos mocks en développement 🙌 !
On peut aller encore plus loin et capitaliser sur MSW également dans nos tests comme je l’explique dans cet article sur Jest et MSW.
Alors, séduit·e ?
Je trouve MSW très bien pensé, sa documentation bien écrite et sa mise en œuvre plutôt rapide et peu complexe. Le côté le plus appréciable est la capacité qu’il nous offre à ré-utiliser nos mocks. Pour des applications construites en grande partie sur des appels API ça peut représenter un gain de temps énorme.
J’espère que vous y trouverez autant votre compte que moi et que ces quelques explications vous auront aidé·e à y voir plus clair.
L’ensemble du code de démonstration est disponible sur GitHub.