Comment et pourquoi mocker une API navigateur dans des tests unitaires ?
vendredi 15 mai 2023
Dans un tutoriel prĂ©cĂ©dent nous avons vu comment faire des tests unitaires en JavaScript. Pour cela nous sommes passĂ©s par jsdom : une implĂ©mentation dâune grande partie des APIs navigateurs en Node.js.
LâintĂ©rĂȘt de cette librairie est que nous pouvons exĂ©cuter de la manipulation de DOM alors que nous ne sommes pas dans un vrai navigateur. Nous avons donc lâopportunitĂ© dâexĂ©cuter plus rapidement un bon nombre de tests pour aller chercher les cas limites et sĂ©curiser nos composants.
Le problĂšme câest que tout nâest pas disponible : nous ne sommes pas dans un navigateur donc tout ce qui est visuel ne sera pas testable. A titre dâexemple, des APIs telles que getBoundingClientRect
, scrollTo
, IntersectionObserver
, matchMedia
, etc. ne sont pas implĂ©mentĂ©es dans jsdom puisquâelles reposent sur la taille de votre navigateur, un Ă©lĂ©ment purement visuel.
Vous vous retrouvez avec des erreurs du style :
TypeError: window.matchMedia is not a function
Que doit-on faire dans ce cas lĂ âŻ? Doit-on nous aussi faire une fausse implĂ©mentation (= un mock)âŻ? OĂč se trouve la limite de ce quâon peut remplacerâŻ?
Quel genre dâAPI peut-on mockerâŻ?
Prenons lâexemple dâun composant qui doit afficher des choses en fonction du device de lâutilisateur (ajouter une classe, changer du contenu, etc. sur mobile vs desktop).
Pour cela il y a deux implémentations possibles :
- ajouter un event listener sur le
resize
window.addEventListener('resize', (event) => { console.log(window.innerWidth); });
- utiliser
window.matchMedia
qui permet de réutiliser la syntaxe des media queries en CSS pour savoir si on match ou non un certain type de device.const match = window.matchMedia('(min-width: 992px)'); match.addListener(() => { console.log(match.matches); // true or false });
Dans le premier cas, il va ĂȘtre trĂšs difficile de mocker de maniĂšre convaincante le resize
 : quand une personne resize votre page, iel ne va pas déclencher un unique événement de resize
. Il y aura certainement un changement dâorientation, voire dĂ©placer sa souris qui va dĂ©clencher plein dâĂ©vĂ©nements : autant dâĂ©vĂ©nements quâil faudra aussi simuler et mocker. Il faudra aussi ĂȘtre vigilant Ă bien mettre Ă jour la taille de la fenĂȘtre, mettre Ă jour les informations de lâĂ©lĂ©ment en mockant le getBoundingClientRect, etc.
Ce ne sera donc pas le genre dâAPI quâil sera pertinent de mocker parce que votre test sera 90% de mock pour 10% de test. De plus, cela risque de nâĂȘtre quâune reprĂ©sentation trĂšs partielle du comportement rĂ©el du navigateur. Sur ce genre de cas, privilĂ©giez donc des tests e2e avec Cypress ou Playwright.
Pour le deuxiĂšme cas, celui de window.matchMedia
câest diffĂ©rent : on ne va plus devoir simuler la totalitĂ© des resize
, on peut se contenter de déclencher un événement unique dans lequel match.matches === true
.
Si on généralise :
- â mockez si vous pouvez vous contenter dâune quantitĂ© limitĂ©e de mock ou dâĂ©vĂ©nements
- â ne pas mocker si vous vous retrouvez avec une multitude de mocks Ă crĂ©er ou que vous commencez Ă trop dĂ©pendre de positions ou de tailles en pixels.
Dâailleurs de maniĂšre gĂ©nĂ©rale, quelque chose qui est facile Ă mocker sera certainement un code qui sera meilleur. Typiquement il est prĂ©conisĂ© de passer par matchMedia
plutĂŽt que resize
parce que câest plus performant, plus fiable et plus expressif. Que du bonheur :)
Que faire quand jâutilise une librairie qui abstrait le navigateurâŻ?
Maintenant admettons que vous utilisez par exemple use-match-media, un hook React qui gĂšre pour vous window.matchMedia
 : que faire�
Est-ce que vous devez mocker la librairie en elle mĂȘme ou window.matchMedia
qui est en dessous�
Ca dĂ©pend. đ
Personnellement je suis dâavis que plus vous mockez quelque chose de bas niveau, mieux câest. En effet, cela vous permet dâĂȘtre plus rĂ©silient au changement et de tester une plus grande partie de la couche. Par exemple, si vous changez de version majeure de use-match-media
, vous serez content·e de ne pas avoir besoin dâadapter votre mock ET dâavoir un test qui vous assure que la fonctionnalitĂ© complĂšte fonctionne. Si vous aviez mockĂ© la librairie vous allez devoir vĂ©rifier manuellement que la librairie vous renvoie toujours les bonnes informations et vous devrez faire Ă©voluer votre mock le cas Ă©chĂ©ant. Il est donc plus pertinent de mocker window.matchMedia
plutĂŽt que la librairie qui lâutilise.
Cependant parfois cela peut rendre vos mocks trop complexes. Ca peut ĂȘtre le cas par exemple pour tout ce qui va toucher au rĂ©seau. Il peut par exemple ĂȘtre plus facile de mocker le hook useQuery
de @tanstack/react-query plutĂŽt que dâaller mocker le window.fetch
qui est en dessous. Ou peut ĂȘtre en passant par msw comme le propose Kent C. Dodds dans Stop mocking fetch.
Cas pratique de matchMedia
Maintenant que nous savons sâil est pertinent de se lancer dans un mock, voyons comment nous pourrions implĂ©menter ce mock.
- Remplacer lâAPI navigateur dans le test
- Déclencher un listener manuellement
Remplacer lâAPI navigateur
Quand on utilise un window.matchMedia
, le code ressemble à ceci :
// addClassOnMobile.js
function addClassOnMobile() {
const match = window.matchMedia('(min-width: 992px)');
// initialize mobile class when the function is first called
document.body.classList.toggle('mobile', match.matches);
// update the mobile class whenever the device format changes
match.addListener((match) => {
document.body.classList.toggle('mobile', match.matches);
});
}
Nous avons donc une fonction qui prend en paramĂštre une mediaQuery
. Cette fonction retourne un objet qui a les clés :
matches
qui indique si Ă lâinitialisation on vĂ©rifie cette mediaQuery ou nonaddListener
qui est appelĂ© lorsquâil y a un changement dâaffichage
En rĂ©alitĂ© lâobjet est de type MediaQueryList
et donc a plus de clĂ©s disponibles. Cependant, si dans votre code vous nâutilisez que matches
et addListener
nâallez pas vous embĂȘter Ă mocker les autres.
Ainsi, si on retranscrit ces mots avec du code, ça nous donnerait :
// matchMediaMock.js
export const matchMediaMock = (mediaQuery) => {
let callbacks = [];
return {
matches: false,
addListener: (callback) => {
callbacks.push(callback);
}
};
};
Ce nâest quâune premiĂšre implĂ©mentation partielle, mais ça nous suffit pour commencer Ă remplacer lâAPI dans notre test :
// addClassOnMobile.test.js
beforeEach(() => {
window.matchMedia = matchMediaMock;
});
afterEach(() => {
// Par acquis de conscience on nettoie toujours les variables
// globales qu'on a modifié au cours de notre test
// afin d'éviter fuites de mémoire et pollution des autres tests
window.matchMedia = undefined;
});
Maintenant si on execute notre test, ce ne sera plus lâAPI du navigateur ou de jsdom mais bien le nĂŽtre qui sera utilisĂ©.
đĄ Parfois, lâAPI que vous voulez mocker existe dĂ©jĂ . Dans ce cas, vous avez deux options :
soit vous pouvez utiliser jest.spyOn afin dâobserver lâinteraction avec cette API
soit vous pouvez conserver la mĂȘme mĂ©thode que prĂ©cĂ©demment mais en passant Ă rĂ©initialiser dans
afterEach
non pas avecundefined
mais avec la valeur initialzelet initialMatchMedia; beforeEach(() => { initialMatchMedia = window.matchMedia; window.matchMedia = matchMediaMock; }); afterEach(() => { window.matchMedia = initialMatchMedia; });
Déclencher un listener manuellement
Cependant, on ne dĂ©clenche pour lâinstant jamais de mise Ă jour. Le callback passĂ© au addListener
nâest jamais appelĂ©, il est uniquement stockĂ© dans le tableau callbacks
.
Si on se rĂ©fĂšre Ă la mĂ©thode Given When Then que jâavais prĂ©sentĂ© dans Testing Library : Comment rĂ©diger des tests front-endâŻ?, il nous manque le When : quand le navigateur change de format et dĂ©clenche le listener.
// addClassOnMobile.test.js
it('should add a mobile class to the body when the match changes', () => {
// GIVEN
addClassOnMobile();
// WHEN
triggerMediaChange(true);
// THEN
expect(document.body.classList.contains('mobile')).toBe(true);
});
La méthode triggerMediaChange
nâexiste nul part pour le moment. Nous allons devoir la crĂ©er pour pour nous assurer que les listeners soient bien dĂ©clenchĂ©s quand elle est appelĂ©e.
Le principe dâun mock est quâil doit fonctionner, certes, mais dans le cas trĂšs restreint de votre cas. Ainsi, mĂȘme si dans notre premiĂšre implĂ©mentation du matchMediaMock
nous avions stockĂ© les callback Ă lâintĂ©rieur du mock, il ne faut pas hĂ©siter Ă les dĂ©placer pour rĂ©pondre Ă notre besoin, mĂȘme quand ça paraĂźt moche.
// matchMediaMock.js
+let callbacks = [];
export const matchMediaMock = (mediaQuery) => {
- let callbacks = [];
return {
matches: false,
addListener: (callback) => {
callbacks.push(callback);
}
};
};
+export const triggerMediaChange(matches) {
+ callbacks.forEach((callback) => {
+ callback({
+ matches: matches
+ })
+ })
+}
Ici, nous avons fait en sorte que tous les listeners de tous les matchMedia en cours dâutilisation soient appelĂ©s avec {matches: true}
dans le cas oĂč on appelle triggerMediaChange(true)
.
Câest donc une implĂ©mentation trĂšs limitĂ©e, mais dans notre cas nous nâavons quâun seul matchMedia
Ă la fois. Nous nâavons pas besoin dâaller plus loin.
Si au contraire nous avions deux exécutions différentes (un matchMedia
pour la vue mobile et un matchMedia
pour la vue tablette), nous aurions certainement besoin dâamĂ©liorer notre mock. Mais pour lâinstant cette version suffira.
Si vous ĂȘtes arrivĂ©s jusquâici, vous avez sĂ»rement votre test qui passe au vert. đ
Cependant, avant de passer la suite je veux retenir votre attention sur un point : quand vous Ă©crivez des mocks, il y a des grandes chances de sâemmĂȘler les pinceaux. En effet, on implĂ©mente une version simplifiĂ©e de la rĂ©alitĂ© qui, par essence, est fausse. Il y a donc de fortes chances pour que le mock ne fasse pas rĂ©ellement ce que vous imaginez ou que votre test empĂȘche des rĂ©gressions sur votre mock au lieu dâempĂȘcher des rĂ©gressions sur votre code.
Pensez donc Ă casser votre code (ce quâil y a dans addClassOnMobile.js
), pour vous assurer que votre test dĂ©tectera toute rĂ©gression. Tant que vous nâaurez jamais vu votre test Ă©chouer (rouge), vous pouvez considĂ©rer que votre test ne marche pas. Je ne compte plus le nombre de fois oĂč je pensais avoir fini dâĂ©crire mes tests mais oĂč je me suis rendu compte que je ne testais rien. Câest dâailleurs un des avantages du TDD : vu que vous nâavez pas encore codĂ© au moment dâĂ©crire vos tests, vous le verrez forcĂ©ment rouge.
Penser Ă nettoyer son mock aprĂšs les tests
Câest toujours un bon rĂ©flexe Ă prendre dâessayer de se demander : que se passe-t-il si jâeffectue un autre testâŻ?
Dans notre cas, on fait appel pendant le test Ă addClassOnMobile
. Cette fonction appelle matchMedia
(la version mockée) qui ajoute un nouveau callback dans le tableau callbacks
. Cette derniĂšre existe Ă lâĂ©chelle du module (= une fonction qui nâest pas cachĂ©e dans une fonction). Donc si un autre test fait un appel Ă matchMedia
, on ne se retrouvera avec deux listeners dans notre tableau callbacks
.
Cela peut vite commencer Ă provoquer des problĂšmes notamment quand plusieurs fichiers diffĂ©rents utilisent le mĂȘme mock. Donc Ă la fin de chaque test on va faire attention Ă bien nettoyer notre mock.
// addClassOnMobile.test.js
import { cleanupMatchMedia } from './matchMediaMock';
afterEach(() => {
cleanupMatchMedia();
});
// matchMediaMock.js
export const cleanupMatchMedia = () => {
callbacks = [];
};
Exercice : ajouter un test avec une valeur initiale différente
Si on récapitule à quoi ressemble notre mock, notre ficher ressemble à ceci :
// matchMediaMock.js
/** @var {((match: {matches: boolean}) => void)[]} */
let callbacks = [];
/**
* @param {string} mediaQuery
*/
export const matchMediaMock = (mediaQuery) => {
return {
matches: false,
addListener: (callback) => {
callbacks.push(callback);
}
};
};
/**
* @param {boolean} matches
*/
export const triggerMediaChange(matches) {
callbacks.forEach((callback) => {
callback({
matches: matches
})
})
}
export const cleanupMatchMedia = () => {
callbacks = [];
};
Cependant, admettons quâon veut ajouter un nouveau test :
- it should add a mobile class on body when the initial media matches
Actuellement, dans matchMediaMock
, on retourne matches: false
. Ce nâest pas compatible avec le test quâon veut ajouter : il faudrait que matches: true
. Comment pourrions-nous faire�
đĄ Je vous encourage Ă chercher la solution par vous mĂȘme avant de passer Ă la suite. Une piste serait dâimaginer ce quâon pourrait faire dans lâĂ©tape Given du test pour influencer le comportement du mock.

âčïž Solution âčïž
Lâastuce est de dĂ©clencher le changement dâĂ©tat avant lâappel Ă addClassOnMobile
. Dâun point de vue navigateur câest ce qui se passe : au tout premier affichage de la page, il se demande sur quel type de device on est.
// addClassOnMobile.test.js
it('should add a mobile class on body when the initial media matches', () => {
+ triggerMediaChange(true);
addClassOnMobile();
- triggerMediaChange(true);
expect(document.body.classList.contains('mobile')).toBe(true);
});
Par contre, pour que ça fonctionne il faut nécessairement faire évoluer le mock. Ainsi, au lieu de renvoyer un matches: false
, on va le variabiliser. Quand on appelle triggerMediaChange
, on change cette variable. Ainsi, au moment oĂč notre code va appeler matchMediaMock
, il aura la valeur du dernier appel Ă triggerMediaChange
.
// matchMediaMock.js
let callbacks = [];
+let currentMatches = false;
export const matchMediaMock = (mediaQuery) => {
return {
- matches: false,
+ matches: currentMatches,
addListener: (callback) => {
callbacks.push(callback);
}
};
};
export const triggerMediaChange = (matches) => {
+ currentMatches = matches;
callbacks.forEach((callback) => {
callback({
matches: matches
});
});
};
export const cleanupMatchMedia = () => {
+ currentMatches = false;
callbacks = [];
};
Et enfin, comme pour les callbacks, on pense bien Ă rĂ©initialiser la variable initiale histoire dâĂȘtre sĂ»r dâĂȘtre exactement dans le mĂȘme Ă©tat Ă chaque dĂ©marrage de test.
Récapitulatif
Nous voilĂ arrivĂ© au boutâŻ! đ
Vous sentez-vous capable dâattaquer les mocks dont vous avez besoin dans vos tests maintenantâŻ? NâhĂ©sitez pas Ă me partager vos rĂ©ussites ou difficultĂ©s sur Mastodon ou Twitter.
En attendant, sâil nây a que quelques Ă©lĂ©ments que je veux que vous reteniez de cet article, ce serait :
- ce nâest pas parce que jsdom nâimplĂ©mente pas une API quâil nâest pas possible de la tester unitairement
- évitez cependant de mocker tout ce qui repose sur une orchestration complexe ou sur des pixels, les tests e2e ou les tests visuels seront plus adaptés
- les mocks reposent souvent sur des variables globales, pensez Ă les nettoyer
- veillez Ă toujours casser votre code quand vous avez fini dâĂ©crire votre test : tant que le test nâaura pas Ă©tĂ© rouge vous nâĂȘtes pas sĂ»r que votre test est bon.
Si ce sont des pratiques que vous aimeriez propager au sein de votre entreprise, sans forcĂ©ment trop savoir par oĂč commencer ou sans avoir le temps de vous y pencher, nâhĂ©sitez pas Ă me contacter par mail. Nous pourrons en discuter avec plaisir.
Adishatz đ
Si vous voulez suivre mes publications, il paraĂźt que j'ai un feed RSS, Mastodon et un Twitter.
Si vous pensez à d'autres méthodes que vous voudriez que je mette en place (pigeon voyageur, avion en papier, etc.), n'hésitez pas à me les proposer :)