React Avancé
Formateur: Fabio Ginja
Introduction à React
Préparation aux tests
Vitest
{
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest",
"preview": "vite preview"
},
}
yarn add -D vitest
On ajoute également la commande test à notre package.json:
Premier test
On peut désormais commencer à écrire des tests dans notre projet:
import { describe, it, expect } from 'vitest';
describe('something truthy and falsy', () => {
it('true to be true', () => {
expect(true).toBe(true);
});
test('addition to be correct', () => {
expect(2 + 1).toBe(3);
});
});
react-testing-library
Pour tester une application react, on utilisera react-testing-library. Pour ce faire, il faudra installer certaines dépendances et lier cette dernière à Vitest.
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import matchers from '@testing-library/jest-dom/matchers';
// ajoute les méthodes react-testing-library à Vitest pour l'autocompletion
expect.extend(matchers);
afterEach(() => {
// Après chaque test, on peut executer des fonctions au besoin
cleanup();
}); // Default on import: runs it after each test.
yarn add -D jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
On va créer un fichier tests/setup.ts afin d'ajouter les méthodes de react-testing-library à Vitest:
Configuration
Enfin, on va ajouter à notre vite.config.ts la configuration suivante:
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom', // tells Vitest to run our tests in a mock browser environment rather than the default Node environment
setupFiles: './tests/setup.ts',
},
});
On va créer un fichier tests/setup.ts afin d'ajouter les méthodes de react-testing-library à Vitest:
react-testing-library
React Testing Library
Le premier principe de React testing library:
The more your tests resemble the way your software is used, the more confidence they can give you.
Cela nous indique qu'on devrait tester notre application comme un utilisateur le ferait, sans forcément tester les détails d'implémentation.
La librairie nous permet d'interagir avec le dom pour simuler le comportement d'un utilisateur.
Queries
Les queries sont les méthodes qui vont nous permettre de trouver nos éléments sur la page HTML.
On a plusieurs type de queries: get, find et query.
Selon ce que l'on souhaite sélectionner, on devra utiliser une méthode plutôt qu'une autre.
getBy, getAllBy
La méthode getByX retourne le nœud demandé si et seulement si un seul nœud est trouvé.
Si 0 ou plusieurs nœuds sont trouvés alors cela throw une erreur.
import { render, screen } from '@testing-library/react'
test('should show login form', () => {
render(<Login />)
const input = screen.getByLabelText('Username')
// ❌ Si 0 ou plus
// ✅ Si 1
})
La méthode getAllByX retourne un tableau contenant un ou plusieurs nœuds correspondant.
Si aucun nœud est trouvé alors la méthode throw une erreur.
⚠️L'élément doit déjà exister lors du premier render de la page.
queryBy, queryAllBy
La méthode queryByX retourne le nœud demandé si et seulement si un seul nœud est trouvé.
Si aucun nœud n'est trouvé, alors retourne null.
Si plusieurs nœuds sont trouvés alors cela throw une erreur.
import { render, screen } from '@testing-library/react'
test('should show login form', () => {
render(<Login />)
const input = screen.queryByLabelText('Username')
// ✅ Si 0 ou 1
// ❌ Si > 1
})
La méthode getAllByX retourne un tableau contenant un ou plusieurs nœuds correspondant.
Si aucun nœud est trouvé alors retourne un tableau vide.
C'est donc la méthode qu'on utilisera pour vérifier qu'un élément est absent de la page.
findBy, findAllBy
La méthode findByX retourne une promesse qui retournera le nœud demandé si et seulement si un seul nœud est trouvé.
Si aucun nœud n'est trouvé, alors retourne null.
Si plusieurs nœuds sont trouvés alors cela throw une erreur.
import { render, screen } from '@testing-library/react'
test('should show login form', () => {
render(<Login />)
const input = screen.findByLabelText('Username')
// ✅ Si 0 ou 1 après 1000ms
// ❌ Si > 1 après 1000ms
})
La méthode findAllByX retourne une promesse un tableau contenant un ou plusieurs nœuds correspondant.
Si aucun nœud est trouvé alors retourne un tableau vide.
⏱La promesse a une durée maximum par défaut de 1000ms.
Résumé
Liste des queries
Les queries suivantes sont disponibles, et devraient être utilisées selon ces priorités:
-
getByRole: sélectionner selon le rôle d'un élément, ou avec la propriété name (documentation w3.org)
-
getByLabelText: parfait pour sélectionner des éléments d'un formulaire
-
getByPlaceholderText: si un élément ne possède pas de label
-
getByText: pour sélectionner une div/span/p par son texte
- getByDisplayValue: sélectionner un élément de formulaire par sa valeur courante
getByRole('button', {name: /submit/i})
Liste des queries (2)
Deux sélecteur HTML5 et ARIA compliant sont disponibles:
-
getByAltText: sélectionner un élément par le texte de la propriété alt (img, area, input)
- getByTitle: sélectionner un élément ayant la propriété title
<div data-testid="custom-element" />
Test IDs, à utiliser en dernier recours:
- getByTestId: sélectionner un élément par la propriété data-testid (cela sera invisible pour l'utilisateur)
const element = screen.getByTestId('custom-element')
Screen
Screen correspond à notre document.body
(approche recommandée), mais il est possible de sélectionner un élément de notre DOM:
import {render, screen, getByLabelText} from '@testing-library/dom'
render(
<div id="app">
<label htmlFor="Username">Username:</label>
<input id="Username" />
</div>,
)
// With screen:
const inputNode1 = screen.getByLabelText('Username')
// ⚠️ Without screen, you need to provide a container:
const container = document.querySelector('#app')
const inputNode2 = getByLabelText(container, 'Username')
// ⚠️ Non recommandé
const {container} = render(<MyComponent />)
const foo = container.querySelector('[data-foo="bar"]'
TextMatch
Prenons l'exemple suivant:
On peut tester des combinaisons avec ce playground
import {render, screen} from '@testing-library/dom'
render(<div>Hello World</div>)
// ✅ Matching a string:
screen.getByText('Hello World') // full string match
screen.getByText('llo Worl', {exact: false}) // substring match
screen.getByText('hello world', {exact: false}) // ignore case
// ✅ Matching a regex:
screen.getByText(/World/) // substring match
screen.getByText(/world/i) // substring match, ignore case
screen.getByText(/^hello world$/i) // full string match, ignore case
screen.getByText(/Hello W?oRlD/i) // substring match, ignore case, searches for "hello world" or "hello orld"
// ✅ Matching with a custom function:
screen.getByText((content, element) => content.startsWith('Hello'))
// ❌ function looking for a span when it's actually a div:
screen.getByText((content, element) => {
return element.tagName.toLowerCase() === 'span' && content.startsWith('Hello')
})
Méthodes assertives
Il existe également les méthodes suivantes:
import {render, screen} from '@testing-library/dom'
const element = screen.getByText('Hello World') // full string match
expect(element).toBeDisabled()
expect(element).toBeEnabled()
expect(element).toBeEmptyDOMElement()
expect(element).toBeInTheDocument()
expect(element).toBeValid()
expect(element).toBeInvalid()
expect(element).toBeRequired()
expect(element).toBeVisible()
expect(element).toContainElement(getByTestId('descendant'))
expect(element).toContainHTML('<span data-testid="child"></span>')
expect(element).toHaveAttribute('type', 'submit')
expect(element).toHaveClass()
expect(element).toHaveFocus()
expect(getByTestId('login-form')).toHaveFormValues({username: 'jane.doe',})
expect(element).toHaveStyle({'background-color': 'green',})
expect(element).toHaveTextContent('Content')
expect(element).toHaveValue()
expect(element).toHaveDisplayValue('banana')
expect(element).toBeChecked()
expect(element).toBePartiallyChecked()
expect(element).toHaveAccessibleDescription()
Debug
Pour nous aider à debugger, on a deux options.
On peut afficher le code HTML dans la console grâce à screen.debug()
import {render, screen} from '@testing-library/dom'
render(<div>Hello World</div>)
screen.debug()
On peut afficher le code HTML dans un testing playground grâce à screen.logTestingPlaygroundURL()
import {render, screen} from '@testing-library/dom'
render(<div>Hello World</div>)
screen.logTestingPlaygroundURL()
Console:
Open this URL in your browser
https://testing-playground.com/#markup=randomString
User Events
L'API userEvent permet de simuler le comportement d'un utilisateur. Par exemple userEvent.type
déclanche tout les évenement suivants: change,
keyDown
, keyPress
, et keyUp
.
const user = userEvent.setup()
expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
await user.type(screen.getByRole('textbox'), 'JavaScript');
expect(
screen.getByText(/Searches for JavaScript/)
).toBeInTheDocument();
function setup(jsx) {
return {
user: userEvent.setup(),
...render(jsx),
}
}
test('render with a setup function', async () => {
const {user} = setup(<MyComponent />)
})
On peut également créer un setup fonction:
Tester un callback
Lorsqu'on veut tester qu'une fonction passée à un composant est bien appelé, on peut le faire avec les outils de mock de vitest ou jest:
type ButtonProps = {callback?: () => unknown}
export const Button = ({ callback }: ButtonProps) => (
<button onClick={callback}>Button</button>
)
describe('Button', () => {
it('calls the onClick callback handler', async () => {
const onClick = vi.fn(); // replace vi by jest if using jest
const user = userEvent.setup()
render(<Button onClick={onClick} />);
await user.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});
Dans notre fichier test:
Optimisations
React Lazy
Permet d'afficher du JSX ou un autre component en attendant le chargement du 'lazy' component.
Il prend deux paramètres:
- Le premier est la fonction à exécuter lorsque celui-ci est déclenché
- Le second un array de paramètre(s) déclencheur(s)
import React, { Suspense, lazy } from 'react';
import Profile from './components/Profile';
const User = lazy(() => import('./components/User'));
const App = () => (
<React.Fragment>
<Profile />
<Suspense fallback={<div>Loading...</div>}>
<User />
</Suspense>
</React.Fragment>
);
Pure Component
Pure Components est une alternative à l'utilisation de la méthode shouldComponentUpdate des class components.
Ce dernier effectue un shallow check du state et des props. S'il n'y a eu aucun changement, le composant ne sera pas mis à jour.
import React, { PureComponent} from 'react';
class SomeComponent extends PureComponent {
// Some Code
render() {
return (
// Some JSX
);
};
};
Très efficace pour éviter des rendu inutiles.
N'utiliser que PureComponent si les props ou le state du parent peuvent vraiment provoquer un render inutile du component enfant.
React Memo
React Memo est une alternative à l'utilisation de la méthode shouldComponentUpdate pour les functional components.
Même objectif que les PureComponent.
Attention, n'effectue qu'une shallow comparaison et peut ne pas détecter des changements à l'intérieur d'un tableau, objet (reference types).
import React from 'react';
const SomeComponent = (props) => {
// Some Code
return (
// Some JSX
);
};
export default React.memo(SomeComponent);
useCallback
useCallback permet de renvoyer une fonction de rappel mémoïsée. Pour cela on doit passer la fonction que l'on souhaite mémoïser, ainsi qu'un tableau de dépendance indiquant les entrées de notre fonction:
import React, { useCallback } from 'react';
const SomeComponent = (props) => {
// ...
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
// ...
};
Cela permet d'éviter des rendu inutiles lorsqu'on passe une fonction à un composant utilisant shouldComponentUpdate, React.memo, ou PureComponent.
useMemo
useMemo permet de renvoyer une valeur mémoïsée. Pour cela on doit passer la fonction dont on souhaite obtenir le résultat, ainsi qu'un tableau de dépendance indiquant les entrées de notre fonction:
import React, { useMemo } from 'react';
const SomeComponent = (props) => {
// ...
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
// ...
};
Si les inputs n'ont pas changé, alors on gardera la valeur précédente du retour de la fonction.
Cela est très utile lorsqu'on fait appel à une fonction qui fait des calcul coûteux.
Si on ne fournis aucun tableau, une nouvelle valeur sera calculée à chaque appel.
Redux ToolKit
Advanced React - April 2023
By AdapTeach
Advanced React - April 2023
Slides de formation
- 174