Recettes de test
Voici quelques approches courantes pour tester des composants React.
Remarque
Cette page suppose que vous utilisez Jest comme harnais de test. Si vous utilisez un harnais différent, vous aurez peut-être besoin d’ajuster l’API, mais l’aspect général de la solution restera probablement inchangé. Pour en apprendre davantage sur la mise en place d’un environnement de test, consultez la page Environnements de test.
Dans cette page, nous utiliserons principalement des fonctions composants. Ceci dit, ces stratégies de test sont découplées de ce genre de détail d’implémentation, et fonctionneront tout aussi bien pour des composants définis à base de classes.
- Mise en place / nettoyage
act()
- Rendu
- Chargement de données
- Simuler des modules
- Événements
- Horloges
- Capture d’instantanés
- Moteurs de rendu multiples
- Pas trouvé votre bonheur ?
Mise en place / nettoyage
Pour chaque test, nous voulons habituellement réaliser le rendu d’un arbre React au sein d’un élément DOM attaché à document
. Ce dernier point est nécessaire pour que le composant puisse recevoir les événements du DOM. Et lorsque le test se termine, nous voulons « nettoyer » et démonter l’arbre présent dans document
.
Une façon courante de faire ça consiste à associer les blocks beforeEach
et afterEach
afin qu’il s’exécutent systématiquement autour de chaque test, ce qui permet d’en isoler les effets :
import { unmountComponentAtNode } from "react-dom";
let container = null;
beforeEach(() => {
// met en place un élément DOM comme cible de rendu
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
// nettoie en sortie de test
unmountComponentAtNode(container);
container.remove();
container = null;
});
Vous utilisez peut-être une autre approche, mais gardez à l’esprit que vous voulez exécuter le nettoyage même si un test échoue. Sinon, vos tests peuvent commencer à « fuire », et un test pourrait altérer par inadvertance le comportement d’un autre, ce qui complexifie beaucoup le débogage.
act()
Lorsqu’on écrit des tests UI, des tâches comme le rendu lui-même, les événements utilisateurs ou encore le chargement de données peuvent être considérées comme autant « d’unités » d’interaction avec l’interface utilisateur. react-dom/rest-utils
fournit une fonction utilitaire appelée act()
qui s’assure que toutes les mises à jour relatives à ces « unités » ont bien été traitées et appliquées au DOM avant que nous ne commencions à exprimer nos assertions :
act(() => {
// rendu des composants
});
// exécution des assertions
Ça nous aide à rapprocher nos tests du comportement que de véritables utilisateurs constateraient en utilisant notre application. La suite de ces exemples utilise act()
pour bénéficier de ces garanties.
Vous trouverez peut-être que le recours manuel à act()
est rapidement un tantinet verbeux. Pour vous épargner une bonne partie du code générique associé, vous pouvez opter pour une bibliothèque telle que React Testing Library, dont les utilitaires sont basés sur act()
.
Remarque
Le terme
act
vient de l’approche Arrange-Act-Assert.
Rendu
Vous voudrez fréquemment vérifier que le rendu d’un composant est correct pour un jeu de props donné. Prenons un composant simple qui affiche un message basé sur une prop :
// hello.js
import React from "react";
export default function Hello(props) {
if (props.name) {
return <h1>Bonjour, {props.name} !</h1>;
} else {
return <span>Salut, étranger</span>;
}
}
Nous pouvons écrire un test pour ce composant :
// hello.test.js
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import Hello from "./hello";
let container = null;
beforeEach(() => {
// met en place un élément DOM comme cible de rendu
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
// nettoie en sortie de test
unmountComponentAtNode(container);
container.remove();
container = null;
});
it("s’affiche avec ou sans nom", () => {
act(() => { render(<Hello />, container); }); expect(container.textContent).toBe("Salut, étranger");
act(() => {
render(<Hello name="Jenny" />, container);
});
expect(container.textContent).toBe("Bonjour, Jenny !");
act(() => {
render(<Hello name="Margaret" />, container);
});
expect(container.textContent).toBe("Bonjour, Margaret !");
});
Chargement de données
Au lieu d’appeler de véritables API dans tous vos tests, vous pouvez simuler les requêtes et renvoyer des données factices. Simuler le chargement de données avec de « fausses » données évite de fragiliser les tests lors d’un back-end indisponible, et les accélère en prime. Remarquez que vous voudrez peut-être qu’une petite partie de vos tests utilisent un framework « de bout en bout » pour vérifier que l’appli dans son ensemble fonctionne bien.
// user.js
import React, { useState, useEffect } from "react";
export default function User(props) {
const [user, setUser] = useState(null);
async function fetchUserData(id) {
const response = await fetch("/" + id);
setUser(await response.json());
}
useEffect(() => {
fetchUserData(props.id);
}, [props.id]);
if (!user) {
return "Chargement…";
}
return (
<details>
<summary>{user.name}</summary>
<strong>{user.age}</strong> ans
<br />
vit à {user.address}
</details>
);
}
Nous pouvons écrire les tests associés :
// user.test.js
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import User from "./user";
let container = null;
beforeEach(() => {
// met en place un élément DOM comme cible de rendu
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
// nettoie en sortie de test
unmountComponentAtNode(container);
container.remove();
container = null;
});
it("affiche les données utilisateur", async () => {
const fakeUser = { name: "Joni Baez", age: "32", address: "123, Charming Avenue" }; jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({ json: () => Promise.resolve(fakeUser) }) );
// Utilise la version asynchrone de `act` pour appliquer les promesses accomplies
await act(async () => {
render(<User id="123" />, container);
});
expect(container.querySelector("summary").textContent).toBe(fakeUser.name);
expect(container.querySelector("strong").textContent).toBe(fakeUser.age);
expect(container.textContent).toContain(fakeUser.address);
// retire la simulation pour assurer une bonne isolation des tests global.fetch.mockRestore();});
Simuler des modules
Certains modules ne fonctionneront peut-être pas bien dans un environnement de test, ou ne seront pas essentiels au test en lui-même. En simulant ces modules pour les remplacer par des versions factices, nous pouvons faciliter l’écriture des tests pour notre propre code.
Prenons un composant Contact
qui intègre un composant tiers GoogleMap
:
// map.js
import React from "react";
import { LoadScript, GoogleMap } from "react-google-maps";
export default function Map(props) {
return (
<LoadScript id="script-loader" googleMapsApiKey="VOTRE_CLÉ_API">
<GoogleMap id="example-map" center={props.center} />
</LoadScript>
);
}
// contact.js
import React from "react";
import Map from "./map";
export default function Contact(props) {
return (
<div>
<address>
Contacter {props.name} par{" "}
<a data-testid="email" href={"mailto:" + props.email}>
e-mail
</a>
ou sur son <a data-testid="site" href={props.site}>
site web
</a>.
</address>
<Map center={props.center} />
</div>
);
}
Si nous ne voulons pas charger ce composant tiers lors de nos tests, nous pouvons simuler la dépendance elle-même pour renvoyer un composant factice, et exécuter nos tests :
// contact.test.js
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import Contact from "./contact";
import MockedMap from "./map";
jest.mock("./map", () => { return function DummyMap(props) { return ( <div data-testid="map"> {props.center.lat}:{props.center.long} </div> ); };});
let container = null;
beforeEach(() => {
// met en place un élément DOM comme cible de rendu
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
// nettoie en sortie de test
unmountComponentAtNode(container);
container.remove();
container = null;
});
it("devrait afficher les infos de contact", () => {
const center = { lat: 0, long: 0 };
act(() => {
render(
<Contact
name="Joni Baez"
email="test@example.com"
site="http://test.com"
center={center}
/>,
container
);
});
expect(
container.querySelector("[data-testid='email']").getAttribute("href")
).toEqual("mailto:test@example.com");
expect(
container.querySelector('[data-testid="site"]').getAttribute("href")
).toEqual("http://test.com");
expect(container.querySelector('[data-testid="map"]').textContent).toEqual(
"0:0"
);
});
Événements
Nous vous conseillons de déclencher de véritables événements DOM sur des éléments DOM, et de vérifier le résultat. Prenez ce composant Toggle
:
// toggle.js
import React, { useState } from "react";
export default function Toggle(props) {
const [state, setState] = useState(false);
return (
<button
onClick={() => {
setState(previousState => !previousState);
props.onChange(!state);
}}
data-testid="toggle"
>
{state === true ? "Éteindre" : "Allumer"}
</button>
);
}
Nous pourrions le tester comme ceci :
// toggle.test.js
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import Toggle from "./toggle";
let container = null;
beforeEach(() => {
// met en place un élément DOM comme cible de rendu
container = document.createElement("div");
// `container` *doit* être attaché à `document` pour que les événements // fonctionnent correctement. document.body.appendChild(container);
});
afterEach(() => {
// nettoie en sortie de test
unmountComponentAtNode(container);
container.remove();
container = null;
});
it("change de valeur suite au clic", () => {
const onChange = jest.fn();
act(() => {
render(<Toggle onChange={onChange} />, container);
});
// récupère l’élément bouton et déclenche quelques clics dessus
const button = document.querySelector("[data-testid=toggle]");
expect(button.innerHTML).toBe("Allumer");
act(() => { button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(button.innerHTML).toBe("Éteindre");
act(() => {
for (let i = 0; i < 5; i++) { button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
});
expect(onChange).toHaveBeenCalledTimes(6);
expect(button.innerHTML).toBe("Allumer");
});
Les événements DOM disponibles et leurs propriétés sont décrits dans le MDN. Remarquez que vous devez passer { bubbles: true }
pour chaque événement créé afin qu’ils puissent atteindre l’écouteur de React, car React délègue automatiquement les événements au niveau racine du document.
Remarque
React Testing Library propose une façon plus concise de déclencher des événements.
Horloges
Votre code dépend peut-être de fonctions calées sur le temps telles que setTimeout
, afin de planifier davantage de travail à l’avenir. Dans l’exemple ci-après, un panneau de choix multiples attend une sélection puis avance, avec un timeout si la sélection ne survient pas dans les 5 secondes :
// card.js
import React, { useEffect } from "react";
export default function Card(props) {
useEffect(() => {
const timeoutID = setTimeout(() => {
props.onSelect(null);
}, 5000);
return () => {
clearTimeout(timeoutID);
};
}, [props.onSelect]);
return [1, 2, 3, 4].map(choice => (
<button
key={choice}
data-testid={choice}
onClick={() => props.onSelect(choice)}
>
{choice}
</button>
));
}
Nous pouvons écrire les tests de ce composant en tirant parti de la simulation d’horloges de Jest et en testant les différents états possibles.
// card.test.js
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import Card from "./card";
jest.useFakeTimers();
let container = null;
beforeEach(() => {
// met en place un élément DOM comme cible de rendu
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
// nettoie en sortie de test
unmountComponentAtNode(container);
container.remove();
container = null;
});
it("devrait sélectionner null à expiration", () => {
const onSelect = jest.fn();
act(() => {
render(<Card onSelect={onSelect} />, container);
});
// avance dans le temps de 100ms act(() => {
jest.advanceTimersByTime(100);
});
expect(onSelect).not.toHaveBeenCalled();
// puis avance de 5 secondes act(() => {
jest.advanceTimersByTime(5000);
});
expect(onSelect).toHaveBeenCalledWith(null);
});
it("devrait nettoyer derrière lui lorsqu’il est retiré", () => {
const onSelect = jest.fn();
act(() => {
render(<Card onSelect={onSelect} />, container);
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(onSelect).not.toHaveBeenCalled();
// démonte l’appli
act(() => {
render(null, container);
});
act(() => {
jest.advanceTimersByTime(5000);
});
expect(onSelect).not.toHaveBeenCalled();
});
it("devrait accepter des sélections", () => {
const onSelect = jest.fn();
act(() => {
render(<Card onSelect={onSelect} />, container);
});
act(() => {
container
.querySelector("[data-testid='2']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSelect).toHaveBeenCalledWith(2);
});
Vous pouvez ne recourir à de fausses horloges que pour certains tests. Ci-avant nous les avons activées en appelant jest.useFakeTimers()
. Le principal avantage réside dans le fait que votre test n’a pas besoin d’attendre effectivement cinq secondes pour s’exécuter, et vous n’avez pas eu besoin de complexifier le code de votre composant uniquement pour permettre ses tests.
Capture d’instantanés
Les frameworks tels que Jest vous permettent aussi de sauvegarder des « instantanés » de données grâce à toMatchSnapshot
/ toMatchInlineSnapshot
. Avec elles, vous pouvez « sauver » la sortie de rendu d’un composant et vous assurer que toute modification qui lui sera apportée devra être explicitement confirmée en tant qu’évolution de l’instantané.
Dans l’exemple qui suit, nous affichons un composant et formatons le HTML obtenu grâce au module pretty
, pour enfin le sauvegarder comme instantané en ligne :
// hello.test.js, à nouveau
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import pretty from "pretty";
import Hello from "./hello";
let container = null;
beforeEach(() => {
// met en place un élément DOM comme cible de rendu
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
// nettoie en sortie de test
unmountComponentAtNode(container);
container.remove();
container = null;
});
it("devrait afficher une salutation", () => {
act(() => {
render(<Hello />, container);
});
expect( pretty(container.innerHTML) ).toMatchInlineSnapshot(); /* …rempli automatiquement par Jest… */
act(() => {
render(<Hello name="Jenny" />, container);
});
expect(
pretty(container.innerHTML)
).toMatchInlineSnapshot(); /* …rempli automatiquement par Jest… */
act(() => {
render(<Hello name="Margaret" />, container);
});
expect(
pretty(container.innerHTML)
).toMatchInlineSnapshot(); /* …rempli automatiquement par Jest… */
});
Il est généralement préférable de recourir à des assertions spécifiques plutôt qu’à des instantanés. Ce type de tests inclut des détails d’implémentation qui les rendent particulièrement fragiles, entraînant une sorte d’anesthésie des équipes vis-à-vis des échecs de tests dus aux instantanés. Une simulation ciblée de composants enfants peut vous aider à réduire la taille de vos instantanés et à les garder lisibles pour vos revues de code.
Moteurs de rendu multiples
Dans de rares cas, vous pourrez vous retrouver à exécuter un test pour un composant qui, lui, recourt à plusieurs moteurs de rendu. Par exemple, peut-être exécutez-vous des tests à base d’instantanés sur un composant en utilisant react-test-renderer
, alors que sous le capot le composant utilise ReactDOM.render
pour obtenir le contenu d’un composant enfant. Dans un tel scénario vous pouvez enrober les mises à jour avec les appels aux fonctions act()
des moteurs appropriés.
import { act as domAct } from "react-dom/test-utils";
import { act as testAct, create } from "react-test-renderer";
// ...
let root;
domAct(() => {
testAct(() => {
root = create(<App />);
});
});
expect(root).toMatchSnapshot();
Pas trouvé votre bonheur ?
Si nous avons oublié de couvrir un scénario courant, n’hésitez pas à nous en faire part sur notre outil de suivi de tickets pour le site web de la documentation.