React Avancé

Formateur: Fabio Ginja

Introduction à React

Préparation aux tests

Vitest

Vitest est un test runner comme Jest et qui possède la même syntaxe pour passer de l'un à l'autre avec facilité. Il est cependant plus rapide que Jest et l'option par défaut lorsqu'on utilise vite pour créer notre projet.

{
  "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);
  });
});

describe permet de créer un bloc qui regroupe plusieurs tests liés.

it, ou test décrit un test. Le premier argument décrit ce qu'on cherche à tester, et le second est la fonction qui contient nos assertions.

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:

  1. getByRole: sélectionner selon le rôle d'un élément, ou avec la propriété name (documentation w3.org)
     
  2. getByLabelText: parfait pour sélectionner des éléments d'un formulaire
     
  3. getByPlaceholderText: si un élément ne possède pas de label
     
  4. getByText: pour sélectionner une div/span/p par son texte
     
  5. 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:

  1. getByAltText: sélectionner un élément par le texte de la propriété alt (img, area, input)
     
  2. 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:

  1. Le premier est la fonction à exécuter lorsque celui-ci est déclenché
  2. 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