Contenu principal

Testing Library : Comment l'utiliser avec son propre framework ?

lundi 19 juin 2023

Testing Library est une librairie qui nous permet de transformer notre façon d’écrire les tests en adoptant le point de vue de nos utilisateurs. Dans sa documentation, nous pouvons voir qu’il est compatible avec toute une tripotĂ©e de frameworks: React, Vue, Angular, etc. La semaine derniĂšre, nous avons par exemple vu comment cela fonctionnait pour React.

Dans le billet de cette semaine, nous allons essayer de comprendre comment ça marche plus en profondeur en l’adaptant à un autre framework : Twig.

â„č PrĂ©requis : pour pouvoir suivre ce tutoriel, je vous conseille d’avoir lu la premiĂšre partie notamment pour comprendre les subtilitĂ©s de jsdom et pour vous aider Ă  comprendre la construction du test.

Présentation de Twig

Twig est un moteur de templating trĂšs rĂ©pandu dans l’écosystĂšme PHP et plus particuliĂšrement Symfony. C’est l’outil qui va sortir du HTML en prenant en compte des variables, des fonctions, etc. Sa syntaxe est trĂšs proche d’autres outils tels que handlebars, mustache.js, ERB, etc.

Par exemple voici à quoi pourrait ressembler une dropdown :

{# templates/dropdown.html.twig #}
<div
    class="dropdown {% if isOpenedInitially %}dropdown--opened{% endif %}"
    aria-expanded="{% if isOpenedInitially %}true{% else %}false{% endif %}"
    aria-controls="dropdown-content"
>
	<button class="dropdwn__toggle">Open/Close</button>
    <p
        id="dropdown-content"
        class="dropdown__content"
        {% if isOpenedInitially %}hidden{% endif %}
    >
        {{ content }}
    </p>
</div>

Le HTML peut paraĂźtre un peu compliquĂ© parce que j’ai ajoutĂ© des attributs nĂ©cessaires Ă  la gestion de l’accessibilitĂ©. Mais dans l’esprit, j’ai accĂšs Ă  des variables que je peux afficher ({{ content }}) et je peux faire des conditions avec {% if condition %}.

Et cela va ensuite ĂȘtre utilisĂ© en gĂ©nĂ©ral par un autre fichier Twig:

{% include 'dropdown.html.twig' with {
    'isOpenedInitially': true,
    'content': 'Mon super contenu',
} only %}

Si vous ĂȘtes plus habituĂ©s Ă  React, c’est finalement trĂšs Ă©quivalent à :

<Dropdown isOpenedInitially={true} content="Mon super contenu" />

Comment le transposer avec Testing Library ?

Maintenant, revenons Ă  nos tests : quand nous avions Ă©crit nos tests dans l’article prĂ©cĂ©dent, nous avions eu trois Ă©tapes diffĂ©rentes :

  1. GIVEN a dropdown that was just rendered

    render(
    	<Dropdown openLabel="Open" closeLabel="Close">
    		<p>Hidden part of the dropdown</p>
    	</Dropdown>
    );
    
  2. WHEN the user clicks

    const dropdownButton = await screen.findByText('Open');
    userEvent.click(dropdownButton);
    
  3. THEN the hidden part should be displayed.

    const content = await screen.findByText('Hidden part of the dropdown');
    expect(content).toBeVisible();
    

Il y a donc essentiellement le render qui est propre à React : on a besoin de définir quel composant et quelles propriétés on veut afficher, puis la fonction render ajoute le composant au faux DOM, à la page invisible créée par jsdom.

Le reste peut rester strictement identique.

â„č Peut-ĂȘtre avez-vous notĂ© que screen qui est utilisĂ© dans le WHEN et le THEN, est importĂ© depuis @testing-library/react. Peut-ĂȘtre est-ce donc liĂ© Ă  React ? La rĂ©ponse est non. Il s’agit en fait d’un alias et nous aurions tout Ă  fait pu l’importer directement depuis import { screen } from @testing-library/dom

Un render qui fait un rendu Twig

Commençons donc par Ă©crire la fonction render dĂ©diĂ©e Ă  Twig. L’objectif est de pouvoir passer un fichier (dropdown.html.twig) et des propriĂ©tĂ©s (isInitiallyOpened & content). Finalement, c’est trĂšs proche de ce qu’on fait quand on Ă©crit un include. On peut donc adapter cela Ă  une syntaxe plus JS.

render('dropdown.html.twig', {
	isInitiallyOpened: true,
	content: 'Mon super contenu'
});

Mais ce render qu’est-ce qu’il doit faire ?

  1. transformer un fichier Twig en vrai HTML
  2. ajouter ce HTML dans le DOM (celui de jsdom)

1. Transformer un fichier Twig en vrai HTML

Twig est initialement un projet réalisé en PHP. Or, quand on exécute des tests en jest/vitest, nous sommes dans un environnement JavaScript. Nous allons donc avoir besoin de trouver une librarie qui fait ça pour nous.

La premiĂšre qui remonte avec une recherche Google est twig.js. Cependant je vous dĂ©conseille de l’utiliser car le support des fonctionnalitĂ©s Twig n’est que trĂšs partiel. C’est possible de s’en sortir avec mais cela vous demandera de modifier certaines syntaxes dans vos templates.

Twing est plus confidentiel mais a l’avantage de supporter la quasi-totalitĂ© du langage. Il vous posera donc moins de problĂšmes.

// ./testing-library-twig.js
import path from 'path';
import { TwingEnvironment, TwingLoaderFilesystem } from 'twing';

// Le chemin vers le dossier qui contient vos fichiers Twig
// Cela peut aussi ĂȘtre un tableau de chemins si vous vos templates
// sont répartis dans plusieurs dossiers/bundles.
const templatesPath = path.join(process.cwd(), 'templates');
// Le loader est l'outil responsable de trouver vos templates twigs
// dans votre systĂšme de fichier
const loader = new TwingLoaderFilesystem(templatesPath);

const twing = new TwingEnvironment(
	loader,
	// Des options de rendu qui correspondent aux options que vous avez définies
	// dans votre configuration Symfony
	// https://nightlycommit.github.io/twing/api.html#environment-options
	{
		strict_variables: true
	}
);

/**
 * La méthode render qui sera utilisée dans vos tests
 * @param {string} templatePath
 * @param {Object} properties
 */
export async function render(templatePath, properties) {
	const template = await twing.load(templatePath);
	const html = await template.render(properties);
	console.log(html);
}

// Nous exportons les méthodes de @testing-library/dom afin de
// conserver la mĂȘme façon d'importer `screen` ou n'importe quelle
// méthode qui permet d'interagir avec le DOM dans nos tests
export * from '@testing-library/dom';

Ok donc normalement si vous exécutez cette nouvelle fonction render, cela créera le HTML attendu dans la variable html. Cependant, il ne fait pas encore partie de votre page. Un peu comme vous auriez fait un appel ajax et vous vous contentez de faire un console.log du contenu de la réponse. Il faut faire quelque chose de cette réponse.

2. ajouter ce HTML dans le DOM

Nous allons donc faire ce que nous aurions fait avec de l’Ajax dans un vrai navigateur, c’est-à-dire ajouter le HTML dans le body de votre page :

export async function render(templatePath, properties) {
	const template = await twing.load(templatePath);
	const html = await template.render(properties);
-   console.log(html);
+
+   const element = document.createElement('div');
+   element.innerHTML = html;
+   document.body.appendChild(element);
}

À partir de ce moment-lĂ  vous pouvez commencer Ă  exĂ©cuter votre test :

- import { render, screen } from '@testing-library/react';
+ import { render, screen } from './texting-library-twig.js';

-   render(
-   	<Dropdown openLabel="Open" closeLabel="Close">
-   		<p>Hidden part of the dropdown</p>
-   	</Dropdown>
+   await render(
+      'dropdown.html.twig',
+      {
+          isInitiallyOpened: true,
+          content: 'Mon super contenu'
+      }
   );

â„č La mĂ©thode render de React est synchrone, tandis que la fonction render que nous avons créée est asynchrone. C’est pour cette raison que nous devons ajouter le await devant la mĂ©thode : il nous faut attendre que la librairie twing ait bien fini son travail.

Et voilà ! Dans votre test, vous pouvez maintenant interagir avec le rĂ©sultat de votre fichier Twig en passant par Testing Library.

Un lapin habillé tout en jaune qui écarte les bras et s'exclame : Tadaaaaa
Source : Simon Super Rabbit

Par exemple vous pouvez vérifier que le contenu est bien visible initialement vu que vous avez passé isInitiallyOpened: true.

expect(await screen.findByText('Mon super contenu')).toBeVisible();

💡 Si vous avez dĂ©jĂ  l’habitude de Testing Library, vous avez peut-ĂȘtre l’habitude d’écrire : const { findByText } = render(...). Vous pouvez mettre cela en place en configurant le return de votre mĂ©thode render :

+import { getQueriesForElement } from '@testing-library/dom';

export async function render(templatePath, properties) {
    [...]
+
+   return getQueriesForElement(element);
}

Nettoyer le DOM aprĂšs le test

Nous avons donc notre render qui transform du Twig en HTML pour ensuite l’ajouter dans notre page virtuelle. Ca nous permet d’exĂ©cuter un premier test. Mais que va-t-il se passer au prochain test ?

Notre render fait un document.body.appendChild. Donc si j’exĂ©cute plusieurs fois la mĂ©thode, le HTML va commencer Ă  se dupliquer dans le HTML causant ainsi des problĂšmes de concurrence entre les tests et accessoirement de memory leak.

La bonne pratique est donc de nettoyer le DOM aprĂšs chaque test.

Dans jest et Testing Library cela se fait gĂ©nĂ©ralement dans un afterEach avec une mĂ©thode appelĂ©e cleanup : aprĂšs chaque test on s’assure de remettre notre page en Ă©tat pour que le prochain ne soit pas impactĂ©.

// ./testing-library-twig.js

+let mountedElements = [];

export async function render(templatePath, properties) {
	const template = await twing.load(templatePath);
	const html = await template.render(properties);

    const element = document.createElement('div');
    element.outerHTML = html;
    document.body.appendChild(element);
+
+   mountedElements.push(element);
}

+export function cleanup() {
+   mountedElements.forEach((element) => {
+       document.body.removeChild(element);
+   });
+};
+
+afterEach(() => {
+   cleanup();
+});

Ainsi, aprÚs chaque exécution de test, le body sera à nouveau vide.

Ajouter la gestion du JavaScript

Nous sommes dĂ©sormais capable de tester le HTML gĂ©nĂ©rĂ© par Twig. C’est donc une trĂšs bonne premiĂšre Ă©tape, mais ce ne sont pas forcĂ©ment les tests qui apportent le plus de valeur : en effet ce qui a le plus de chance de casser au cours des Ă©volutions, ce sont les interactions de l’utilisateur. Que doit-il se passer quand l’utilisateur clique sur le bouton ?

Pour cette partie-là, nous avons de la chance, nous avons peu de choses à faire :

  • nous avons le HTML qui est dĂ©jĂ  prĂȘt
  • nous sommes dans un environnement jsdom qui mimique un navigateur et donc la plupart des APIs navigateur sont dĂ©jĂ  mockĂ©es

La seule chose qui peut Ă©voluer c’est la maniĂšre dont vous avez Ă©crit votre JS.

window.addEventListener('DOMContentLoaded', () {
	const button = document.querySelector('.dropdown__toggle');
	button.addEventListener('click', function () {
		const dropdown = button.closest('.dropdown');
		dropdown.classList.toggle('dropdown--opened');
		// + la gestion des attributs d'accessiblité
	});
});

Par exemple dans ce cas, on se repose sur DOMContentLoaded qui est un Ă©vĂ©nement qui n’a pas de sens dans le contexte de jsdom : le contenu est chargĂ© depuis bien longtemps, on ne manipule la page qu’avec du JavaScript.

De plus, en fonction de comment vous avez l’habitude de coder votre JS, vous avez peut-ĂȘtre beaucoup d’autres fonctionnalitĂ©s dans votre page et pas uniquement le code dĂ©diĂ© Ă  votre dropdown. Dans notre test on ne veut tester que la dropdown.

L’idĂ©al serait donc de transformer le code en sĂ©parant la partie qui concerne la dropdown dans sa propre fonction, voire dans son propre fichier, afin de pouvoir l’exĂ©cuter indĂ©pendamment. En faisant cela on sĂ©pare la responsabilitĂ© de la page (= quand est-ce qu’on doit commencer Ă  initialiser la dropdown), de la responsabilitĂ© de la dropdown (= il faut Ă©couter tel bouton).

import { initDropdown } from './initDropdown.js';

window.addEventListener('DOMContentLoaded', function () {
	initDropdown();
});
// initDropdown.js
export function initDropdown() {
	const button = document.querySelector('.dropdown__toggle');

	button.addEventListener('click', function onToggle() {
		const dropdown = button.closest('.dropdown');
		dropdown.classList.toggle('dropdown--opened');
	});
}

Une fois fait, dĂšs que le HTML est prĂȘt dans notre test, on peut commencer Ă  appeler cette fonction.

// dropdown.test.js
+import initDropdown from './initDropdown.js',

// ...

render('dropdown.html.twig', {
	isInitiallyOpened: true,
	content: 'Mon super contenu'
});
+initDropdown();

// ...

Ainsi, en faisant un click sur le composant, l’event listener du initDropdown sera bien appelĂ© et donc la dropdown se mettra bien Ă  jour. 🎉

Il est courant de ne pas trop savoir quand mettre le initDropdown ou de le mettre trop tard dans les tests. Il faut bien Ă  faire attention de le faire avant l’action de l’utilisateur. C’est donc une bonne astuce de toujours le faire dans l’étape GIVEN du test.

Ajouter la gestion du CSS

Maintenant la derniÚre partie de ce qui compose un composant : le CSS.

Je vous parlais dans le tutoriel prĂ©cĂ©dent de la diffĂ©rence entre .toBeInTheDocument() et .toBeVisible(). L’avantage du second est qu’il permet de prendre en compte le CSS.

Ainsi, mĂȘme si nous ne sommes pas dans le contexte d’une vraie page web il est quand mĂȘme pertinent d’essayer d’importer le CSS afin de le prendre en compte dans les tests.

Pour cela nous allons utiliser le mĂȘme principe que le HTML :

  1. Récupérer le CSS
  2. L’ajouter dans le DOM

Comme base d’exemple, nous utiliserons ce petit bout de Sass.

.dropdown {
	&__content {
		display: none;
	}

	&--opened &__content {
		display: block;
	}
}

Récupérer le CSS en utilisant un Transformer

La premiĂšre Ă©tape de rĂ©cupĂ©ration du CSS se fait en configurant des Transformers dans Jest. Un Transformer est une Ă©tape de modification de l’import qui permet d’indiquer Ă  node.js comment exĂ©cuter le code lorsque celui-ci n’est pas du JavaScript compatible.

Certaines librairies existent Ă  ce sujet, mais elles ne font pas forcĂ©ment ce dont on a besoin. Par exemple, jest-scss-transform transforme vos fichiers en retirant tout le CSS et en ne gĂ©rant que les :export. Il faut qu’on trouve une autre solution.

Nous allons donc voir comment crĂ©er notre propre Transformer pour ĂȘtre capable de s’adapter Ă  n’importe quel tooling CSS. Notamment, on veut faire en sorte que si on Ă©crit :

import dropdownStyles from './dropdown.scss';

console.log(dropdownStyles);

Alors ça devrait logger le CSS de la dropdown, celui qu’on ajouterait dans une balise <style> dans le navigateur. Ainsi, aprùs le passage du transformer, le code devrait ressembler à quelque chose de ce style :

export default `
.dropdown__content {
    display: none;
}

.dropdown--opened .dropdown__content {
    display: block;
}
`;

Ainsi, dans notre test, quand on importera le fichier .scss, il ne verra plus du style, mais un export de string.

â„č Je considĂšre ici que jest est configurĂ© pour fonctionner en ESM. Mais si vos fichiers sont Ă©crits en CommonJS, alors le fichier JS gĂ©nĂ©rĂ© devrait utiliser module.exports = Ă  la place de export default.

Si vous avez besoin de la syntaxe en :export n’hĂ©sitez pas Ă  adapter le code gĂ©nĂ©rĂ© pour ajouter des variables supplĂ©mentaires Ă  exporter par exemple.

Configurer un transformer

PremiÚrement pour indiquer à Jest quel Transformer appliquer sur quel fichier, nous devons modifier sa configuration :

// jest.config.js
export default {
	// ...
	transform: {
		'^.+\\.(scss|sass)$': './config/jest/SassTransformer.js'
	}
};

Le but est d’indiquer pour quels fichiers on veut appliquer notre transformer. Le fichier SassTransformer.js va ĂȘtre le code responsable de transformer le fichier initial en un fichier JavaScript. Il doit respecter la signature suivante :

export default {
	process: (sourceText, sourcePath) => {
		return {
			code: code
		};
	}
};

Nous avons ainsi accĂšs Ă  la source du fichier qu’on veut compiler. Puis dans la variable code, nous devons retourner une string qui doit contenir le code JavaScript gĂ©nĂ©rĂ© (exporter une string qui contiendra le CSS final).

Pour créer cette variable, nous allons récupérer le CSS depuis notre fichier Sass, grùce à la librairie sass.

const sass = require('sass');

export default {
	process: (sourceText, sourcePath) => {
		const css = sass.compile(sourcePath);
		return {
			code: `export default ${JSON.stringify(css)};`
		};
	}
};

Et pouf ! Jest est maintenant correctement configurĂ© pour que vous puissiez importer un fichier .scss et ainsi rĂ©cupĂ©rer le CSS final dans une variable. Si vous Ă©tiez en mode --watch pensez Ă  bien redĂ©marrer votre commande pour que jest repĂšre vos changements.

â„č À noter que les Transformers peuvent ĂȘtre configurĂ©s bien plus finement. Il est par exemple possible de configurer des sourceMap si vous avez besoin de faciliter le debug, ou de mettre des clĂ©s de cache si vos opĂ©rations de transformation sont lourdes. Cependant, ce sont souvent des opĂ©rations qui sont optionnelles et dont vous n’aurez pas besoin avant un bon moment. N’hĂ©sitez donc pas Ă  aller au plus simple dans un premier temps.

Importer le style dans notre page

Maintenant que nous sommes capables de rĂ©cupĂ©rer le CSS de notre dropdown, le but va ĂȘtre de l’ajouter dans notre DOM pour qu’il soit pris en compte par Testing Library.

Dans un vrai navigateur, nous aurions besoin de créer une balise <style> que nous remplissons avec le CSS. Nous allons donc reproduire cela en JavaScript:

import dropdownStyles from './dropdown.scss';

const style = document.createElement('style');
style.innerHTML = dropdownStyles;
document.body.appendChild(style);

Nettoyer le CSS aprĂšs le test

Il faut toutefois faire attention aux mĂȘmes choses que pour le render du HTML : bien nettoyer nos styles aprĂšs nos tests. L’idĂ©al est de faire ça en mĂȘme temps que le cleanup afin que cela soit fait de maniĂšre gĂ©nĂ©rique et que vous n’ayez pas besoin de le gĂ©rer manuellement dans chaque test.

Pour cela, nous allons ajouter une nouvelle mĂ©thode Ă  cĂŽtĂ© de notre render Twig qui aura la charge d’ajouter le CSS et de faire en sorte que la balise style soit retirĂ©e aprĂšs le cleanup.

// ./testing-library-twig.js

let mountedElements = [];

export async function render(templatePath, properties) {
    // ...
}

+export function renderStyles(css) {
+    const style = document.createElement('style');
+    style.innerHTML = css;
+    document.body.appendChild(style);
+
+    mountedElements.push(style);
+}

export function cleanup() {
   mountedElements.forEach((element) => {
       document.body.removeChild(element);
   });
};

Ainsi, dans notre fichier de test, nous n’avons plus qu’à ajouter ces quelques lignes dans l’étape GIVEN.

// dropdown.test.js
+import dropdownStyles from './dropdown.scss',

// ...

render('dropdown.html.twig', {
	isInitiallyOpened: true,
	content: 'Mon super contenu'
});
+renderStyles(dropdownStyles);

// ...

Récapitulatif

Nous avons donc maintenant possibilité de :

  1. faire un rendu twig qui va venir s’ajouter dans la page HTML
  2. exĂ©cuter du JavaScript pour ĂȘtre en mesure de tester des interactions
  3. importer le CSS pour ĂȘtre au plus proche du comportement du navigateur et rendre nos tests un peu plus fiables
// Dropdown.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import initDropdown from './initDropdown.js',
import dropdownStyles from './dropdown.scss';

describe('Dropdown', () => {
	it('should display hidden parts when the user clicks on the button', () => {
		// GIVEN a dropdown that was just rendered
        await render('dropdown.html.twig', {
            isInitiallyOpened: true,
            content: 'Mon super contenu'
        });
        renderStyles(dropdownStyles);
        initDropdown();

		// WHEN the user clicks
        const dropdownButton = await screen.findByText('Open');
        userEvent.click(dropdownButton);

		// THEN the hidden part should be displayed
        const content = await screen.findByText('Mon super contenu');
        expect(content).toBeVisible();
	});
});

Avec cela vous devriez donc avoir tout ce qu’il faut pour commencer Ă  Ă©crire des tests unitaires front-end mĂȘme si vous travaillez sur une stack qui n’est pas forcĂ©ment Ă  la pointe du dernier framework Ă  la mode.

L’avantage est que vos tests seront trĂšs peu couplĂ©s Ă  votre framework et seront en grande partie rĂ©utilisable le jour oĂč vous imaginez une migration.

C’est aussi un excellent outil Ă  avoir Ă  disposition car il vous permettra d’exĂ©cuter plus de tests plus rapidement qu’une suite e2e. Notamment sur une suite e2e on va gĂ©nĂ©ralement se concentrer sur des scenarios lĂ  oĂč avec ces tests unitaires vous pourrez aller tester les cas limites et toutes les subtilitĂ©s de votre composant.

Enfin, sachez que je n’ai prĂ©sentĂ© que le minimum vital pour que vous puissiez lancer vos premiers tests. Un sujet intĂ©ressant Ă  creuser si vous ĂȘtes sur Twig serait de mocker des Extensions Twigs. En effet, dans votre application Symfony vous avez sĂ»rement des fonctions ou des filtres globaux que vous utilisez dans vos templates. Un bon point d’entrĂ© pour dĂ©marrer serait cette documentation : Extending Twing.


Si en attendant ça vous a plu ou que vous avez la moindre remarque, n’hĂ©sitez pas Ă  me le faire savoir sur Mastodon ou Twitter. J’éclaircirai certains points avec plaisir.

L’infrastructure front-end est un domaine difficile Ă  aborder tellement les outils sont nombreux et Ă©voluent vite. Mais parfois un petit ajustement ou le bon outil au bon endroit peut radicalement amĂ©liorer votre expĂ©rience de dĂ©veloppement et votre efficacitĂ© au quotidien. Si vous avez besoin d’accompagnement ou que vous souhaitez initier ces pratiques dans votre entreprise, n’hĂ©sitez pas Ă  me contacter par mail. Nous pourrons en discuter avec plaisir.

Des bisous 😚


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 :)