Contenu principal

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.

  1. Remplacer l’API navigateur dans le test
  2. 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 non
  • addListener 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 avec undefined mais avec la valeur initialze

    let 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.

Le texte 'A little interlude' est affiché sur un fond étoilé tout mignon.
Source : KerBop Publishing

â„č 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 :)