Ajouter des tests de non régression visuelle (et plus encore !) sur Storybook avec Chromatic

Ajouter des tests de non régression visuelle (et plus encore !) sur Storybook avec Chromatic


Tests d'interactivité avec Play

Pour l’instant nous avons vu qu’avec Chromatic on pouvait lancer des tests de non régression visuelle. On peut également lancer des tests d’interaction de composant ! On pourra tester si les comportements du composant lorsque l’utilisateur interagit avec correspond bien à ce que l’on souhaite.

Ces tests sont réalisés dans l’onglet Interactions des stories. Pour ajouter une interaction à une story il faut créer une fonction play. À l’intérieur on va pouvoir utiliser les utilitaires fournis par @storybook/tests qui regroupent des utilitaires de Jest et de Testing-library. On pourra utiliser step pour diviser les tests en sous-parties nommées.

Chromatic n’est pas nécessaire pour lancer les tests d’interactivité. Il y a d’autres façons de lancer ces tests de façon automatique, notamment avec @storybook/test-runner. Je parle de ces tests ici car ils sont automatiquement lancés dans Chromatic avant qu'il prenne le snapshot, donc c’est d’une pierre deux coups.

Nous allons voir dans quels cas concrets on peut utiliser des tests d'interactivité.

Création de notre composant de test

Nous allons réaliser une modale très basique, un composant avec un dialog qui s'ouvre et se ferme.

Tout d'abord nous allons nous mettre sur une nouvelle branche, feat/enable-interactions.

git checkout main git checkout -b feat/enable-interactions

Dans src/stories, créez un fichier Modal.tsx. Copiez et collez à l'intérieur de ce fichier le code suivant :

import { useRef } from 'react'; import { Button } from './Button'; export const Modal = ({onOpenModal}: {onOpenModal: () => void}) => { const dialogElement = useRef<HTMLDialogElement>(null); const openModal = () => { dialogElement?.current?.showModal(); onOpenModal(); } const closeModal = () => dialogElement?.current?.close(); return( <> <dialog ref={dialogElement}> <button type="button" aria-label="Fermer la modale" onClick={closeModal}>X</button> <p>Je suis une modale !</p> </dialog> <Button label="Ouvrir la modale" primary onClick={openModal} /> </> ) }

Nous avons donc un Button qui nous permettra d'ouvrir la modale pour tester son comportement. Au clik sur le bouton on appelle une méthode showModal qui appartient au dialog et qui permet comme son nom l'indique d'ouvrir la modale. Sur l'élément dialog nous avons récupéré la référence de l'élément avec ref pour utiliser ses méthodes. Cet élément contient un button avec un aria-label explicite pour améliorer sa compréhension pour les personnes utilisant un lecteur d'écran (puisque "X" n'est pas vraiment un super contenu de bouton, mais c'est pour l'exemple). Enfin on a un p qui affiche le contenu de la modale.

On va ensuite créer un fichier au même niveau, appelé Modal.stories.ts dans lequel on va ajouter ce code :

import type { Meta, StoryObj } from '@storybook/react'; import { Modal } from './Modal'; const meta = { title: 'Example/Modal', component: Modal, parameters: { layout: 'centered', }, tags: ['autodocs'], } satisfies Meta<typeof Modal>; export default meta; type Story = StoryObj<typeof meta>; export const Default: Story = {}

Relancez storybook s'il n'est pas déjà en train de tourner avec npm run storybook, vous devriez voir apparaître une nouvelle story, "Modal".

Prendre le snapshot d’un composant avec un état "ouvert"

Grâce aux interactions nous pouvons résoudre un des problèmes qu’on peut avoir avec Chromatic sur certains composants. Comment faire pour prendre le snapshot d’un composant qui ne s’ouvre qu’au clic, comme un dropdown ou une modale par exemple ? On peut utiliser une interaction pour activer le composant, car Chromatic prend le snapshot après que les interactions ont réussi. Si les interactions sont en erreur, alors le build est lui aussi en erreur.

Dans Default tout en bas de notre story nous allons ajouter une fonction play. Celle-ci va nous permettre de lancer des interactions, visibles dans l'onglet du même nom dans la story Default.

Note

Si vous ne voyez pas l'onglet en question, appuyez sur le raccourci "q" pour afficher le panneau des addons dans la story. Il faut aussi bien être sur la story en elle-même et pas sur la documentation du composant.
import { userEvent, within } from '@storybook/test'; // On importe ce dont on a besoin depuis @storybook/test // ... export const Default: Story = { play: async ({ canvasElement }) => { // On ajoute play const { getByRole } = within(canvasElement); const openButton = getByRole('button', { name: "Ouvrir la modale" }); await userEvent.click(openButton); } }

canvasElement contient le DOM de notre composant. openButton contient l'élément qui a un rôle de bouton avec un nom accessible "Ouvrir la modale". On utilise userEvent pour cliquer sur le bouton. getByRole et userEvent viennent de testing-library qui est importé avec @storybook/test.

Dans l'onglet Interactions on a bien des lignes qui sont apparues pour décrire le code que nous venons d'ajouter. Aussi on remarque que la modale s'ouvre toute seule ! Chromatic va donc pouvoir prendre en snapshot son contenu.

Plus d'informations sur Testing Library ici

Tester l’accessibilité

Comment tester des composants en prenant en compte des notions d’accessibilité ?

Il y a tout d’abord un addon d'accessibilité, storybook-addon-a11y, qui permet d’afficher les erreurs et warnings possibles sur un composant. On peut aussi installer axe-playwright pour vérifier qu’il n’y a pas de régression d’accessibilité.

On peut tester quelques points d'accessibilité en utilisant les interactions appropriées :

  • on peut naviguer au clavier dans notre composant grâce aux différentes méthodes de userEvent, pour vérifier que le composant répond bien aux différentes commandes au clavier, si le focus est bien visible et bien placé aux endroits appropriés au moment du snapshot,

  • on peut utiliser la query byRole de testing-library. Elle permet de tester si le rôle de l’élément est le bon, ce qui est utile pour les technologies d'assistance. On peut également vérifier que le nom accessible de l’élément est correct. On peut utiliser logRoles pour loguer dans la console la liste des rôles présents au moment du log sur le composant, ce qui est très pratique pour débugger.

  • Avec byRole on peut utiliser des options comme name, description et bien d’autres options pour vérifier l’accessibilité du composant.

  • Enfin on peut aussi utiliser les matchers de jest-dom pour compléter testing-library (comme toHaveAccessibleName, toHaveFocus...)

Le comportement souhaité d'une modale, c'est que quand on l'ouvre, le focus passe directement sur l'élément permettant sa fermeture. C'est donc ce que nous allons tester !

On va d'abord tester d'ajouter logRoles pour savoir quels éléments sont présents à l'ouverture de la modale.

import { logRoles, userEvent, within } from '@storybook/test'; export const Default: Story = { play: async ({ canvasElement }) => { // ... logRoles(canvasElement); } }

Dans la console du navigateur on peut voir que les éléments ont été loggés, et notamment celui-ci :

button: Name "Fermer la modale": <button aria-label="Fermer la modale" type="button" />

C'est bien notre bouton de fermeture ! Et son nom est comme indiqué dans le aria-label "Fermer la modale" et non "X", son contenu. On peut donc retirer le logRoles et utiliser getByRole pour attraper ce bouton et vérifier qu'il est bien en état de focus lorsqu'on ouvre la modale.

import { expect, userEvent, within } from '@storybook/test'; export const Default: Story = { play: async ({ canvasElement }) => { // ... const closeButton = getByRole('button', { name: "Fermer la modale" }); expect(closeButton).toHaveFocus(); } }

Les interactions sont vertes, le test passe bien ! On aura même une double vérification avec le snapshot de Chromatic qui va montrer l'état de focus par défaut du navigateur sur le bouton de fermeture.

Voici le lien de documentation de byRole, avec toutes les options possibles et la documentation de jest-dom

Tester les événements

On peut tester les events émis par les composants avec les actions de Storybook. Les événements émis s’affichent dans l’onglet Actions mais sont bien utilisables dans la fonction play. On peut vérifier si un événement a bien été émis lors d’une interaction, et avec quelle valeur, s'il en a.

On voudrait vérifier que notre composant Modale émet bien un évènement à l'ouverture de la modale. On a déjà un onOpenModal dans notre composant, c'est lui que nous allons écouter. Dans les argTypes de meta, dans la story, nous allons ajouter cette configuration :

const meta = { title: 'Example/Modal', component: Modal, argTypes: { onOpenModal: { action: 'onOpenModal' } }, // <= nouvelle ligne ici parameters: { layout: 'centered', }, tags: ['autodocs'], } satisfies Meta<typeof Modal>;

Maintenant lorsque l'interaction se lance on a dans l'onglet Actions une entrée onOpenModal ! Il ne nous reste plus qu'à tester que l'action est bien effectuée dans notre fonction play :

export const Default: Story = { play: async ({ canvasElement, args }) => { // On ajoute `args` qui contient onOpenModal const { getByRole } = within(canvasElement); expect(args.onOpenModal).not.toHaveBeenCalled(); // On vérifie que onOpenModal n'a pas encore été appelée const openButton = getByRole('button', { name: "Ouvrir la modale" }); await userEvent.click(openButton); const closeButton = getByRole('button', { name: "Fermer la modale" }); expect(closeButton).toHaveFocus(); expect(args.onOpenModal).toHaveBeenCalled(); // On vérifie que onOpenModal a été appelée }, };

Si tout s'est bien passé les tests sont toujours verts !

Conditionner le lancement des interactions

Lorsqu’on ajoute une interaction, elle est systématiquement jouée à l’ouverture de la story. Ce n’est pas toujours le comportement souhaité. Il est possible de ne lancer les interactions que pour Chromatic, et également de masquer certains éléments à Chromatic pour le snapshot avec la fonction isChromatic. La fonction renverra true si on est sur Chromatic, sinon false. On peut l’utiliser directement dans les stories ou dans la fonction play.

On veut que nos utilisateurs de Storybook puissent tester eux-mêmes l'ouverture de la modale avec le bouton. Nous allons donc conditionner l'ouverture et les tests qui suivent pour ne les lancer que sur Chromatic.

import isChromatic from "chromatic/isChromatic"; // On importe isChromatic // ... export const Default: Story = { play: async ({ canvasElement, args }) => { if (isChromatic()) { // On ajoute la condition autour de nos tests const { getByRole } = within(canvasElement); expect(args.onOpenModal).not.toHaveBeenCalled(); const openButton = getByRole('button', { name: "Ouvrir la modale" }); await userEvent.click(openButton); const closeButton = getByRole('button', { name: "Fermer la modale" }); expect(closeButton).toHaveFocus(); expect(args.onOpenModal).toHaveBeenCalled(); } }, };

Et voilà ! Notre story est redevenue immobile. Il ne reste plus qu'à créer un commit et pousser nos modifications sur la CI :

git add . git commit -m "feat: enable interactions on component Modal" git push -u origin feat/enable-interactions

Sur le build de Chromatic on a bien la modale ouverte avec notre bouton de fermeture entouré de noir, c'est l'outline de focus par défaut de Chrome. C'est exactement ce qu'on voulait !

Auteur(s)

Alice Fauquet

Frontend developer

Voir le profil

Vous souhaitez en savoir plus sur le sujet ?
Organisons un échange !

Notre équipe d'experts répond à toutes vos questions.

Nous contacter

Découvrez nos autres contenus dans le même thème

À la découverte de l'Anchor positioning API

La nouvelle Anchor positioning API en CSS

L'Anchor positioning API est arrivée en CSS depuis quelques mois. Expérimentale et uniquement disponible à ce jour pour les navigateurs basés sur Chromium, elle est tout de même très intéressante pour lier des éléments entre eux et répondre en CSS à des problématiques qui ne pouvaient se résoudre qu'en JavaScript.