Archives Wild Code School
React - Avancé

Snapshot testing – React, Enzyme, Jest

Quête 1 sur 1

Le snapshot testing simplifie l'écriture de test en comparant le résultat testé à un instantané (snapshot) généré automatiquement, au lieu de le comparer à une valeur définie manuellement par le développeur.

Cette quête vous propose de mettre en place des tests unitaires et des tests d'intégration en utilisant la méthode du snapshot testing.

Objectifs

    • Apprendre à écrire et maintenir des tests unitaires et d'intégration en utilisant le snapshot testing
  • Comprendre les avantages et les inconvénients du snapshot testing

Configuration du projet

  • Initialisez un projet avec Create React App

  • Installez les packages nécessaires pour les tests :

shell
yarn add --dev enzyme enzyme-adapter-react-16 enzyme-to-json

  • Dans le fichier src/setupTests.js, collez la configuration suivante :
js
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });

  • Ajoutez la configuration suivante dans package.json :
json
  "jest": {
    "snapshotSerializers": ["enzyme-to-json/serializer"]
  }

  • Vérifiez que l'application fonctionne dans le navigateur (yarn start)

criture d'un premier test de snapshot

Un premier test, couvrant le comportement du composant App, est déjà écrit dans App.test.js.

Remplacez ce test par un test unitaire avec Enzyme, qui va comparer le rendu du composant App avec un snapshot généré automatiquement :

js
import React from 'react';
import App from './App';
import { shallow } from 'enzyme';

describe('App', () => {
  it('renders correctly', () => {
    const appWrapper = shallow(<App />);
    expect(appWrapper).toMatchSnapshot();
  });
});

Avant d'exécuter les tests, remarquez que ce test ne spécifie aucune valeur à laquelle comparer le résultat du rendu du composant.

À la place, on demande au test de vérifier que le rendu coïncide avec un snapshot qui va être généré automatiquement lors de l'exécution du test.

Exécutez le test (yarn test) et remarquez la sortie :

shell
 PASS  src/App.test.js
  App
    ✓ renders correctly (12ms)

 › 1 snapshot written.
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Le test passe et le snapshot correspondant a été écrit dans un fichier.

Ce fichier se trouve au même niveau que le fichier de tests, dans un dossier __snapshots__, sous le nom App.test.js.snap.

Dans ce fichier de snapshot se trouve un instantané de l'état du composant, qu'on a généré en exécutant expect(appWrapper).toMatchSnapshot() :

js
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`App renders correctly 1`] = `
<div
  className="App"
>
  <header
    className="App-header"
  >
    <img
      alt="logo"
      className="App-logo"
      src="logo.svg"
    />
    <p>
      Edit 
      <code>
        src/App.js
      </code>
       and save to reload.
    </p>
    <a
      className="App-link"
      href="https://reactjs.org"
      rel="noopener noreferrer"
      target="_blank"
    >
      Learn React
    </a>
  </header>
</div>
`;

Remarquez que le snapshot porte un titre généré à partir de l'arborescence du fichier de tests : App renders correctly.

Ce snapshot, censé représenter l'état correct du rendu du composant, sera désormais comparé au rendu actuel à chaque fois que le test sera exécuté.

Faites-en l'expérience : dans App.js, supprimez l'élément img.

Le test devrait désormais échouer, vous indiquant la différence entre le snapshot enregistré et le résultat actuel :

shell
 FAIL  src/App.test.js
  App
    ✕ renders correctly (16ms)

  ● App › renders correctly

    expect(received).toMatchSnapshot()

    Snapshot name: `App renders correctly 1`

    - Snapshot
    + Received

    @@ -2,15 +2,10 @@
        className="App"
      >
        <header
          className="App-header"
        >
    -     <img
    -       alt="logo"
    -       className="App-logo"
    -       src="logo.svg"
    -     />
          <p>
            Edit
            <code>
              src/App.js
            </code>

       6 |   it('renders correctly', () => {
       7 |     const appWrapper = shallow(<App />);
    >  8 |     expect(appWrapper).toMatchSnapshot();
         |                        ^
       9 |   });
      10 | });
      11 |

      at Object.it (src/App.test.js:8:24)

 › 1 snapshot failed.
Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or press `u` to update them.

Remarquez qu'à la dernière ligne, Jest vous propose d'appuyer sur la touche u pour mettre à jour le snapshot, afin d'indiquer que la version actuelle est la nouvelle version correcte.

Appuyez sur la touche u. La sortie devrait vous indiquer que le snapshot a été mis à jour :

shell
 PASS  src/App.test.js
  App
    ✓ renders correctly (10ms)

 › 1 snapshot updated.
Snapshot Summary
 › 1 snapshot updated from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 updated, 1 total
Time:        1.986s, estimated 2s

Remarquez le changement dans le fichier App.test.js.snap : l'élément img n'y apparaît plus.

Avec les snapshots, le comportement n'est pas spécifié explicitement dans les cas de tests ; il est spécifié dans des fichiers de snapshots. Après que le développeur a modifié un comportement (ce qui fait échouer les tests existants), il ordonne la mise à jour des snapshots correspondants, qui à partir de cet instant spécifient le nouveau comportement.

Note : les fichiers de snapshots sont écrits automatiquement ; ils n'ont pas vocation à être modifiés manuellement par le développeur. Par ailleurs, au même titre que les tests conventionnels, ils font partie du projet et doivent être pris en compte dans Git.

criture de fonctionnalités et tests de snapshot

On voudrait afficher avec notre application une citation fournie au hasard par https://api.adviceslip.com.

Modifiez le composant App.js comme suit :

js
class App extends React.Component {
  state = {
    advice: '',
  };

  async componentDidMount() {
    const response = await fetch('https://api.adviceslip.com/advice');
    const {
      slip: { advice },
    } = await response.json();
    this.setState({ advice });
  }

  render() {
    return (
      <div className="App">
        <p className="Advice">{this.state.advice || '…'}</p>
      </div>
    );
  }
}

Pour tester le comportement du composant avant la réponse de l'API puis après celle-ci, nous allons mocker l'appel à l'API, c'est à dire le remplacer par un appel fictif qui n'appelera pas l'API mais retournera une valeur fictive.

Le mock nous permet de tester le fonctionnement de notre composant en fonction des différentes valeurs possiblement retournées par la fonction mockée, ou en l'occurence de tester ce qu'il se passe avant et après la résolution de l'appel à la fonction.

Pour faire ce mock, nous allons utiliser fetch-mock. Installez fetch-mock ainsi que node-fetch (qui permet d'utiliser fetch côté serveur) :

shell
yarn add --dev node-fetch fetch-mock

Vérifiez que l'application fonctionne dans le navigateur et modifiez aussi les tests correspondants, qui deviennent comme suit :

js
...
import fetchMock from 'fetch-mock';

describe('App', () => {
  beforeEach(() => {
    fetchMock.get(
      'https://api.adviceslip.com/advice',
      { slip: { advice: 'Always trust random advice.' } },
    );
  });

  afterEach(() => {
    fetchMock.reset();
  });

  describe('before advice fetched', () => {
    it('renders loading indicator', () => {
      const appWrapper = shallow(<App />);
      expect(appWrapper).toMatchSnapshot();
    });
  });

  describe('after advice fetched', () => {
    it('renders advice', done => {
      const appWrapper = shallow(<App />);
      setImmediate(() => {
        expect(appWrapper).toMatchSnapshot();
        done();
      });
    });
  });
});

La sortie de l'exécution des tests devrait maintenant indiquer :

  • qu'un snapshot est obsolète (c'est le test qu'on vient d'écraser)

  • que deux nouveaux snapshots ont été écrits

Dans App.test.js.snap, vérifiez que chaque snapshot reflète correctement le comportement voulu :

  • … avant que la citation ait été reçue

  • Always trust random advice. après que la citation a été reçue

Remarquez que les snapshots spécifient toutes les propriétés du comportement testé, sans que vous ayez eu à en spécifier aucune : par exemple, si vous changez la classe d'un élément dans App, les tests échoueront.

À ce titre, les snapshots sont recommandés pour tester un comportement dont les propriétés sont nombreuses et fastidieuses à spécifier, comme le rendu d'un composant React.

Note : quand le développeur met à jour un snapshot ou en crée un nouveau, il doit s'assurer que le snapshot reflète le comportement voulu, pour éviter que le test passe alors qu'il spécifierait un comportement non voulu (faux négatif). De la même manière, les autres développeurs doivent examiner les snapshots lors de la revue de code.

On voudrait maintenant ajouter un bouton pour permettre à l'utilisateur de remplacer la citation affichée par une autre citation au hasard.

Modifiez le composant App comme suit :

js
class App extends React.Component {
  state = {
    advice: '',
  };

  loadAdvice = async () => {
    const response = await fetch('https://api.adviceslip.com/advice');
    const {
      slip: { advice },
    } = await response.json();
    this.setState({ advice });
  };

  async componentDidMount() {
    await this.loadAdvice();
  }

  render() {
    return (
      <div className="App">
        <p className="Advice">{this.state.advice || '…'}</p>
        <button
          className="Advice-other-advice-button"
          onClick={this.loadAdvice}
        >
          Show other advice
        </button>
      </div>
    );
  }
}

Et ajoutez, à la suite des deux tests existants (mais toujours à l'intérieur du describe('App'), un test décrivant le comportement du composant quand on clique sur le bouton :

js
describe('when hitting "Show other advice" button', () => {
  beforeEach(() => {
    fetchMock.getOnce(
      'https://api.adviceslip.com/advice',
      { slip: { advice: 'Other random advice.' } },
      { overwriteRoutes: false }
    );
  });

  it('should render other advice', done => {
    const appWrapper = shallow(<App />);
    appWrapper.find('button').simulate('click');
    setImmediate(() => {
      expect(appWrapper).toMatchSnapshot();
      done();
    });
  });
});

Dans la sortie de l'exécution des tests, vous devriez constater le résultat suivant :

shell
 FAIL  src/App.test.js
  App
    before advice fetched
      ✕ renders loading indicator (15ms)
    after advice fetched
      ✕ renders advice (10ms)
    when hitting "Show other advice" button
      ✓ should render other advice (21ms)

Le test que vous venez d'ajouter (should render other advice) passe, ce qui est toujours le cas d'un test de snapshot qui est exécuté pour la première fois, et les deux autres tests échouent, puisque le rendu du composant App.js a changé.

Plus bas dans la sortie, constatez que l'élément button a été ajouté au rendu du composant, ce qui est conforme au comportement voulu. Mettez donc à jour les snapshots en appuyant sur la touche u.

Inspectez maintenant le snapshot nouvellement créé App when hitting "Show other advice" button should render other advice dans App.test.js.snap :

js
exports[
  `App when hitting "Show other advice" button should render other advice 1`
] = `
<div
  className="App"
>
  <p
    className="Advice"
  >
    Always trust random advice.
  </p>
  <button
    className="Advice-other-advice-button"
    onClick={[Function]}
  >
    Show other advice
  </button>
</div>
`;

Remarquez que le snapshot ne reflète pas le comportement voulu après le clic : le message affiché est toujours Always trust random advice. alors qu'il devrait être Other random advice. (voir la valeur que le mock doit retourner dans le test).

C'est une erreur dans l'écriture du test : au début du test, remplacez get par getOnce pour éviter que le mock de l'API retourne toujours la même valeur et empêche de tester un deuxième appel à l'API :

js
describe('App', () => {
  beforeEach(() => {
    fetchMock.getOnce(
      'https://api.adviceslip.com/advice',
      { slip: { advice: 'Always trust random advice.' } },
    );
  });

La sortie de l'exécution des tests devrait vous montrer le changement dans le résultat, qui devrait désormais être conforme à la spécification. Mettez à jour le snapshot (u).

Remarquez que le snapshot testing demande une vigilance accrue en raison du risque de faux négatif : la première valeur inscrite dans un snapshot fait autorité et est considérée correcte. Peu importe que le test soit mal écrit ou que le comportement ne soit pas correctement implémenté.

C'est aussi pour cette raison qu'on ne peut pas pratiquer le TDD en s'appuyant sur des tests de snapshot.

↗
A Place for Jest Snapshot Testing
codeburst.io
↗
React Snapshot Testing: The Bad Parts
medium.com

Challenge

Refactoriser l'application en s'appuyant sur des tests d'intégration avec snapshot

Dans un fichier App.integration.test.js qui reprend les mêmes cas de tests que App.test.js, couvrez le comportement d'App avec des tests d'intégration utilisant des snapshots, puis procédez à la refactorisation suivante :

  • Déplacer le code du bouton dans un composant dédié
  • Rendre ce bouton dans App :
js
<ShowOtherAdviceButton onClick={this.loadAdvice} />

Critères de validation

    • Le bouton est défini dans un composant ShowOtherAdviceButton et les fonctionnalités de l'application sont conservées

Ressources