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.
- comment rédiger des tests en React
- comment adapter ces tests Ă dâautres front-end (vous ĂȘtes ici)
âčïž 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 :
-
GIVEN a dropdown that was just rendered
render( <Dropdown openLabel="Open" closeLabel="Close"> <p>Hidden part of the dropdown</p> </Dropdown> );
-
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('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 depuisimport { 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âŻ?
- transformer un fichier Twig en vrai HTML
- 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 fonctionrender
que nous avons créée est asynchrone. Câest pour cette raison que nous devons ajouter leawait
devant la méthode : il nous faut attendre que la librairietwing
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.

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 lereturn
de votre méthoderender
 :+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Â :
- Récupérer le CSS
- 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 deexport 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 :
- faire un rendu twig qui va venir sâajouter dans la page HTML
- exĂ©cuter du JavaScript pour ĂȘtre en mesure de tester des interactions
- 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 :)