React par-ci, React par-là… vous en entendez parler tous les jours, mais savez-vous tester des composants à la mode de chez nous ? Beaucoup de développeurs pensent encore que les tests unitaires sont une véritable perte de temps, et pourtant, c’est un gage de qualité (pas le seul) qui nous permet de vérifier, la non-régression d’une application, tout au long de la création de features. Des librairies existent pour faciliter l’écriture de tests telles que jest-enzyme. Venez marcher dans mes pas à travers ce tutoriel qui vous mènera vers des horizons ensoleillés saupoudrés de tests.
Présentation du projet
Dans ce tutoriel, nous allons créer un formulaire composé de trois étapes permettant de visualiser une espèce de chat ou de chien grâce aux API thedogapi et thecatapi. Nous partirons d’une branche contenant une première version statique. Chaque composant sera testé unitairement avec la librairie jest-enzyme.
Les étapes du formulaire sont les suivantes :
- Choix de l’animal, chat ou chien
- Choix de la race parmi les races de l’animal sélectionné
- Affichage d’une image et de la description de la race sélectionnée, un bouton permet de recharger une nouvelle image
Prérequis
Préparation de l’environnement
- Télécharger les sources du projet :
git clone https://github.com/ineat/react-tdd-training.git
- Se positionner sur la branche de travail tech/init-project-and-interface :
git checkout tech/init-project-and-interface
- Se placer dans le projet et télécharger les librairies requises :
cd react-tdd-training && npm install
- Démarrer le projet :
npm start
- Accéder à l’application http://localhost:3000
Architecture du projet
Le projet est composé de 7 composants :
- Animal :
- image : url de l’image affichée
- alt : texte affiché si l’image n’est pas trouvée
- description : le texte qui apparait lorsque le curseur est sur l’image
- AnimalSelector : contient 2 composants Animal, un chat et un chien
- BreedSelector : affiche la liste des races de l’animal sélectionné
- AnimalViewer : affiche une image de la race sélectionnée, un bouton permet d’afficher une nouvelle image
- ProgressBar : affiche la liste des étapes du formulaire et l’étape active en surbrillance
- Step : un écran du formulaire, il affiche un titre et un composant enfant (AnimalSelector, BreedSelector ou AnimalViewer)
- title: titre de l’étape
- children: composant enfant
- App: Composant principal qui orchestre les autres composants
Les composants se verront rajouter de nouvelles propriétés au fur et à mesure du tutoriel. Pour mieux visualiser les composants, voici des captures d’écran de l’application avec l’affichage des composants :
Un dossier api est également présent, il permet de contacter les API, nous n’aurons pas besoin de nous intéresser sur le contenu.
Présentation de Jest Enzyme
Jest enzyme est une librairie permettant de simplifier les tests sur les composants React.
Lorsque nous écrirons les tests unitaires, nous utiliserons avant chaque test les méthodes de rendering : shallow et mount.
Le shallow rendering permet de nous restreindre à tester unitairement nos composants car les composants enfants ne sont pas rendus.
Le full DOM rendering va, contrairement au shallow, rendre le composant et tous les composants enfants, permettant ainsi de tester les interactions entre les composants.
Exemples :
Création d’un composant :
const wrapper = shallow(<Progressbar activeStepIndex={1} />); const steps = wrapper.find('.step-indicator'); expect(steps.get(0).props.className).toEqual('step-indicator active');
Il est également possible de tester les composants avec du snapshot testing. Cette méthode est très utile pour s’assurer de la non-régression de l’UI. Quand vous exécutez pour la 1ère fois le test de snapshot, un fichier va être enregistré avec le contenu du snapshot. Si vous modifiez, le contenu de votre composant, le test de snapshot sera en erreur. Il faudra alors vérifier les différences et s’assurer qu’elles sont voulues et si tout est en ordre, vous n’aurez qu’à mettre à jour le snapshot.
Attention : Le snapshot testing est à utiliser avec parcimonie car, finalement, quand le test ne passe pas, il suffit de le mettre à jour sans avoir à réécrire du code. Donc vous pouvez très bien réécrire l’entièreté du composant et mettre à jour. Cela ne vaut pas (à mon sens), les tests unitaires plus classiques.
Nous allons mettre en place les tests unitaires tout au long du tutoriel.
Tester des composants simples
Composant Animal
- Importez React de la librairie React
- Importez shallow de la librairie enzyme
- Importez le composant Animal que nous allons tester
- Ecrire un bloc describe
import * as React from 'react'; import { shallow } from 'enzyme'; import Animal from '.'; describe('Animal', () => { ... });
Passons à l’écriture du test. Nous voulons que la propriété onSelectAnimal soit appelée lorsque nous cliquons sur l’image. Nous allons créer un objet props qui va contenir les 4 propriétés du composant, un mock sera initialisé pour la propriété onSelectAnimal. Le composant sera alors créé avec la fonction shallow, on simulera alors un clic sur l’image puis nous vérifierons que le mock a été contacté.
Dans le bloc Describe :
const onSelectAnimalMock = jest.fn(); const props = { img: 'dat image', alt: 'Arg arg!', description: 'I love dat', onSelectAnimal: () => onSelectAnimalMock('Arg') }; it('Should call "onSelectAnimal" when clicking on the image.', () => { const wrapper = shallow(<Animal {...props} />); wrapper.find('.animal').simulate('click'); expect(onSelectAnimalMock).toHaveBeenCalled(); });
Lancez les tests unitaires avec la commande npm run test et constatez que le test unitaire plante.
Implémentation du test :
import * as React from 'react'; import './Animal.scss'; export default ({ image, alt, description, onSelectAnimal }) => ( <div className="animal-container"> <img className="animal" src={image} alt={alt} onClick={onSelectAnimal}/> <span className="description"> {description} </span> </div> );
Pour tester le reste de l’UI et s’assurer de la non-régression, nous pouvons écrire un snapshot test :
it('Should render.', () => { expect(mount(<Animal {...props} />)).toMatchSnapshot(); });
Cependant, si vous voulez ne pas utiliser les snapshots, vous pouvez également tester les propriétés passées à la balise <img/> ainsi que le contenu du <span/>.
it('Should display the description.', () => { const wrapper = shallow(<Animal {...props} />); const text = wrapper.find('.description').text(); expect(text).toEqual(props.description); }); it('Should display the image by using given image and given alt text.', () => { const wrapper = shallow(<Animal {...props} />); const { src, alt } = wrapper.find('.animal').props(); expect(src).toEqual(props.image); expect(alt).toEqual(props.alt); });
Comme vous avez pu le constater, les tests unitaires commencent souvent par la création du composant avec le shallow. Nous pouvons extraire cette partie dans le hook beforeEach.
Fichier complet:
import * as React from 'react'; import { mount, shallow } from 'enzyme'; import Animal from '.'; describe('Animal', () => { const onSelectAnimalMock = jest.fn(); const props = { image: 'dat image', alt: 'Arg arg!', description: 'I love dat', onSelectAnimal: () => onSelectAnimalMock('Arg') }; it('Should render.', () => { expect(mount(<Animal {...props} />)).toMatchSnapshot(); }); describe('Check props', () => { let wrapper; beforeEach(() => { wrapper = shallow(<Animal {...props} />); }); it('Should call "onSelectAnimal" when clicking on the image.', () => { wrapper.find('.animal').simulate('click'); expect(onSelectAnimalMock).toHaveBeenCalled(); }); it('Should display the description.', () => { const text = wrapper.find('.description').text(); expect(text).toEqual(props.description); }); it('Should display the image by using given image and given alt text.', () => { const { src, alt } = wrapper.find('.animal').props(); expect(src).toEqual(props.image); expect(alt).toEqual(props.alt); }); }); });
Composant AnimalSelector
const wrapper = shallow(<AnimalSelector {...props} />); wrapper.find(Animal).at(0).prop('onSelectAnimal')();
- Faire un shallow du composant en mettant un mock dans la propriété onSelectAnimal
- Récupérer et appeler la propriété onSelectAnimal
- Vérifier que le mock a été appelé avec ‘cat’ ou ‘dog’ selon le composant
import * as React from 'react'; import { shallow, mount } from 'enzyme'; import AnimalSelector from '.'; import Animal from '../Animal'; describe('Animal selector', () => { const props = { onSelectAnimal: jest.fn() }; it('Should render.', () => { expect(mount(<AnimalSelector {...props} />)).toMatchSnapshot(); }); it('Should call "onSelectAnimal" with "cat" type for first image.', () => { const wrapper = shallow(<AnimalSelector {...props} />); wrapper.find(Animal).at(0).prop('onSelectAnimal')(); expect(props.onSelectAnimal).toHaveBeenCalledWith('cat'); }); it('Should call "onSelectAnimal" with "dog" type for second image.', () => { const wrapper = shallow(<AnimalSelector {...props} />); wrapper.find(Animal).at(1).prop('onSelectAnimal')(); expect(props.onSelectAnimal).toHaveBeenCalledWith('dog'); }); });AnimalSelector/index.jsx
import * as React from 'react'; import dogLogo from '../../../assets/images/dog.png'; import catLogo from '../../../assets/images/cat.png'; import './AnimalSelector.scss'; import Animal from '../Animal'; export default ({ onSelectAnimal }) => ( <div className="animal-selector"> <Animal image={catLogo} alt="Meow!" description="I love cats" onSelectAnimal={() => onSelectAnimal('cat')}/> <Animal image={dogLogo} alt="Wouf!" description="I love dogs" onSelectAnimal={() => onSelectAnimal('dog')}/> </div> );
Composant BreedSelector
- breeds qui sera un tableau d’objets ayant les attributs : id, name. Le template devra utiliser cette nouvelle propriété en parcourant la liste.
- onSelectBreed qui sera appelée avec l’id de l’espèce lors du clic sur <div className=”breed” />.
A vous de coder !
Créez le fichier BreedSelector.test.jsx dans le dossier BreedSelector.
Liste des tests unitaires :
- Vérifier que l’on affiche autant de <div className=”breed” /> que d’espèces contenues dans la propriété breeds et que le nom de l’espèce est affiché
- Ecrire le même test que précédemment mais avec un jeu de données différent
- Vérifier que l’on appelle la propriété onSelectBreed avec l’id de l’espèce sélectionnée
- Un test snapshot
Astuce pour vérifier que le nom de l’espèce est correct :
Lorsque vous allez récupérer les espèces dans le template, vous allez utiliser encore la méthode find du wrapper. Pour vérifier le nom de la 2ème espèce dans le template, vous allez pouvoir faire :
wrapper.find('.breed').at(1).text()
text permet de retourner le contenu au format texte de l’enfant du composant, dans notre cas, il ne contiendra que le nom de l’espèce.
BreedSelector.test.jsx
import * as React from 'react'; import { shallow, mount } from 'enzyme'; import BreedSelector from '.'; describe('Breed selector', () => { it('Should render.', () => { const props = { breeds: [ { id: 'id-99', name: 'Rex' }, { id: 'id-66', name: 'Arg' } ] }; expect(mount(<BreedSelector breeds={props.breeds} />)).toMatchSnapshot(); }); it('Should display as many breeds as given in args.', () => { const props = { breeds: [ { id: 'id-1', name: 'Border collie' }, { id: 'id-2', name: 'Labrador' }, { id: 'id-3', name: 'Cocker' }, { id: 'id-4', name: 'Jack Russel' } ], onSelectBreed: jest.fn() }; const wrapper = shallow(<BreedSelector breeds={props.breeds} />); const breeds = wrapper.find('.breed'); expect(breeds).toHaveLength(4); expect(breeds.at(0).text()).toEqual(props.breeds[0].name); expect(breeds.at(1).text()).toEqual(props.breeds[1].name); expect(breeds.at(2).text()).toEqual(props.breeds[2].name); expect(breeds.at(3).text()).toEqual(props.breeds[3].name); }); it('Should display as many breeds with name as given in another args.', () => { const otherProps = { breeds: [ { id: 'id-5', name: 'Berger Allemand' }, { id: 'id-6', name: 'Berger Australien' }, { id: 'id-8', name: 'Didier' } ] }; const wrapper = shallow(<BreedSelector breeds={otherProps.breeds} />); const breeds = wrapper.find('.breed'); expect(breeds).toHaveLength(3); expect(breeds.at(0).text()).toEqual(otherProps.breeds[0].name); expect(breeds.at(1).text()).toEqual(otherProps.breeds[1].name); expect(breeds.at(2).text()).toEqual(otherProps.breeds[2].name); }); it('Should call "onSelectBreed" prop with breed id when clicking on breed id.', () => { const props = { breeds: [ { id: 'id-1', label: 'Border collie' } ], onSelectBreed: jest.fn() }; const wrapper = shallow(<BreedSelector {...props} />); const onSelectBreed = wrapper.find('.breed').prop('onClick'); onSelectBreed(); expect(props.onSelectBreed).toHaveBeenCalledWith(props.breeds[0].id); }); });
BreedSelector/index.jsx
import * as React from 'react'; import './BreedSelector.scss'; export default ({ breeds, onSelectBreed }) => ( <div className="breed-selector"> { breeds.map(breed => ( <div className="breed" key={ breed.id } onClick={() => onSelectBreed(breed.id)}> { breed.name } </div> )) } </div> );
Composant AnimalViewer
- img contiendra l’url de l’image à afficher
- description indiquera une description détaillée de la race sélectionnée. La description n’est pas toujours fournie, nous allons devoir gérer le cas en affichant un message par défaut quand le cas se présente.
- onReloadImage est une fonction qui sera appelée lorsque l’on cliquera sur le bouton “I want another one”
Créez le fichier AnimalViewer.test.jsx dans le dossier AnimalViewer.
A vous de coder !
Liste des tests unitaires :
- La propriété description est affichée dans la div ayant le className description lorsque la propriété n’est pas vide
- On affiche “No description” dans cette même div quand la propriété description est vide
- La propriété onReloadImage est appelée lorsque l’on clique sur le bouton
- Un test snapshot
Vous n’avez plus besoin d’astuce, vous devriez réussir en vous aidant des tests précédents.
AnimalViewer.test.jsx
import * as React from 'react'; import { mount, shallow } from 'enzyme'; import AnimalViewer from '.'; describe('Animal viewer', () => { const defaultProps = { img: 'my-dog.jpg', description: 'Nice animal', onReloadImage: jest.fn() }; it('Should render.', () => { expect(mount(<AnimalViewer { ...defaultProps } />)).toMatchSnapshot(); }); it('Should call "onReloadImage" prop when clicking on button.', () => { const wrapper = shallow(<AnimalViewer { ...defaultProps } />); wrapper.find('.reload-button').simulate('click'); expect(defaultProps.onReloadImage).toHaveBeenCalled(); }); it('Should display "No description" when no description are provided.', () => { const props = { ...defaultProps, description: null }; const wrapper = shallow(<AnimalViewer { ...props } />); expect(wrapper.find('.description').text()).toEqual('No description'); }); it('Should display the description when the description is provided.', () => { const wrapper = shallow(<AnimalViewer { ...defaultProps } />); expect(wrapper.find('.description').text()).toEqual(defaultProps.description); }); });
AnimalViewer/index.jsx
import * as React from 'react'; import './AnimalViewer.scss'; export default ({ img, description, onReloadImage }) => ( <div className="animal-viewer"> <div className="animal-details"> <img src={img} className="picture" alt="Moohhh"/> <div className="description">{description || 'No description'}</div> </div> <button className="reload-button" onClick={onReloadImage}>I want another one !</button> </div> );
Composant Step
Ce composant affiche un titre d’étape et un composant enfant donné en propriété. Pour le moment, les 3 étapes sont affichées car nous ne cachons pas les étapes non actives. Une nouvelle propriété va être ajoutée :
- active: Affiche l’étape quand le valeur est true
A vous de coder !
Créez le fichier Step.test.jsx dans le dossier Step.
Liste des tests unitaires :
- La propriété title est affichée dans la <div className=”section-title” />
- Lorsque la propriété active est à true, la section doit avoir la classe section active et ne doit pas avoir la classe section hidden
- Lorsque la propriété active est à false, la section doit avoir la classe section hidden et ne doit pas avoir la classe section active
Astuce pour vérifier la classe d’un élément :
Si nous voulons faire une assertion sur la classe de <div className=”my-class active”/> :
expect(wrapper.find('.my-class').hasClass('active')).toBe(true);
Step.test.jsx
import * as React from 'react'; import { shallow } from 'enzyme'; import Step from '.'; describe('Step', () => { const props = { title: 'Random title', active: true }; it('Should display given title.', () => { const wrapper = shallow( <Step {...props}> <div>Coucou</div> </Step> ); expect(wrapper.find('.section-title').text()).toEqual(props.title); }); it('Should hide the given section when the active prop is set to false.', () => { const hiddenProps = { ...props, active: false } const wrapper = shallow( <Step {...hiddenProps}> <div>Coucou</div> </Step> ); expect(wrapper.find('.section').hasClass('hidden')).toBe(true); expect(wrapper.find('.section').hasClass('active')).toBe(false); }); it('Should display the given section when the active prop is set to true.', () => { const wrapper = shallow( <Step {...props}> <div>Coucou</div> </Step> ); expect(wrapper.find('.section').hasClass('active')).toBe(true); expect(wrapper.find('.section').hasClass('hidden')).toBe(false); }); });
Step/index.jsx
import * as React from 'react'; import './Step.scss'; export default ({ title, children, active}) => { const sectionClass = active ? 'section active' : 'section hidden'; return ( <section className={sectionClass}> <div className="section-title">{title}</div> {children} </section> ); };
Composant ProgressBar
Ce composant nous permet de savoir à quel endroit nous nous trouvons dans le formulaire. Actuellement, la 1ère étape est toujours mise en surbrillance. Nous allons devoir dynamiser tout cela en ajoutant une nouvelle propriété :
- activeStepIndex : indice de l’étape du formulaire en cours de visionnage
A vous de coder !
Créez le fichier Progressbar.test.jsx dans le dossier Progressbar.
Liste des tests unitaires :
- La première étape doit être mise en surbrillance avec la classe active si l’index fourni est 1, les autres étapes ne doivent pas être mises en surbrillance
- La deuxième étape doit être mise en surbrillance avec la classe active si l’index fourni est 2, les autres étapes ne doivent pas être mises en surbrillance
- La 3ème étape doit être mise en surbrillance avec la classe active si l’index fourni est 3, les autres étapes ne doivent pas être mises en surbrillance
Astuce pour vérifier la classe d’un élément enfant :
Si nous voulons vérifier la classe du 1er composant, nous pouvons faire comme ceci :
const steps = wrapper.find('.step-indicator'); expect(steps.at(0).hasClass('active')).toEqual(true);
Progressbar.test.jsx
import * as React from 'react'; import { mount, shallow } from 'enzyme'; import Progressbar from '.'; describe('Progressbar', () => { it('Should render.', () => { expect(mount(<Progressbar activeStepIndex={1} />)).toMatchSnapshot(); }); it('Should highlight the first step when the given active step index is 1.', () => { const wrapper = shallow(<Progressbar activeStepIndex={1} />); const steps = wrapper.find('.step-indicator'); expect(steps.at(0).hasClass('active')).toEqual(true); expect(steps.at(1).hasClass('active')).toEqual(false); expect(steps.at(2).hasClass('active')).toEqual(false); }); it('Should highlight the second step when the given active step index is 2.', () => { const wrapper = shallow(<Progressbar activeStepIndex={2}/>); const steps = wrapper.find('.step-indicator'); expect(steps.at(0).hasClass('active')).toEqual(false); expect(steps.at(1).hasClass('active')).toEqual(true); expect(steps.at(2).hasClass('active')).toEqual(false); }); it('Should highlight the third step when the given active step index is 3.', () => { const wrapper = shallow(<Progressbar activeStepIndex={3}/>); const steps = wrapper.find('.step-indicator'); expect(steps.at(0).hasClass('active')).toEqual(false); expect(steps.at(1).hasClass('active')).toEqual(false); expect(steps.at(2).hasClass('active')).toEqual(true); }); });
Progressbar/index.jsx
import * as React from 'react'; import './Progressbar.scss'; export default ({ activeStepIndex }) => { const getStepIndicatorClass = (index, activeIndex) => index === activeIndex ? 'step-indicator active' : 'step-indicator'; const steps = [{ index: 1, label: 'Animal' }, { index: 2, label: 'Breed' }, { index: 3, label: 'Picture' }]; return ( <ul id="progressbar"> { steps.map(step => <li className={getStepIndicatorClass(step.index, activeStepIndex)} key={step.index}> {step.label} </li> ) } </ul> ); };
Félicitations, vous avez appris à tester des composants UI simples ! Nous allons maintenant terminer en testant un composant avec un state.
Tester des composants complexes
Nous avons déjà énormément progressé dans l’écriture de nos composants. Il va maintenant falloir les orchestrer dans le fichier App.jsx. Pour mettre en place l’intelligence du composant, nous allons devoir gérer un state
DISCLAIMER : Depuis la création des hooks avec React, nous ne sommes plus obligés de créer une classe et de gérer un state. Cependant, jest-enzyme ne permet pas encore de tester simplement les hooks (Des pull requests sont en cours, cela devrait arriver prochainement).
Initialisation du state
Le composant App va avoir besoin d’un state composé de 4 attributs :
- stepIndex : indice de l’étape affichée
- selectedType : le type de l’animal sélectionné (dog ou cat)
- breeds : la liste des espèces de l’animal sélectionné
- selectedBreed : un objet constitué d’un id, d’une url renvoyant vers une image et d’une description
A vous de coder !
Créez le fichier de test App.test.jsx.
Nous allons commencer par tester l’état du state à l’initialisation du composant. Lorsque vous souhaitez tester le state, il faut appeler la méthode state du wrapper.
const wrapper = shallow(<App />); expect(wrapper.state('stepIndex')).toEqual(1);
Liste des tests unitaires :
- stepIndex vaut 1 à l’initialisation
- selectedType vaut une chaine vide à l’initialisation
- breeds vaut un tableau vide à l’initialisation
- selectedBreed vaut un objet ayant les 3 attributs id, img, description initialisés avec une chaine vide
App.test.jsx
import * as React from 'react'; import { shallow } from 'enzyme'; import App from './App'; describe('App', () => { let wrapper; beforeEach(() => { wrapper = shallow(<App />); }); describe('State', () => { it('Should initialize the step to 1.', () => { expect(wrapper.state('stepIndex')).toEqual(1); }); it('Should initialize the selected type to an empty string.', () => { expect(wrapper.state('selectedType')).toEqual(''); }); it('Should initialize breeds to an empty array.', () => { expect(wrapper.state('breeds')).toEqual([]); }); it('Should initialize the selected breed with an object composed of "id", img" and "description" set to empty string.', () => { expect(wrapper.state('selectedBreed')).toEqual({ id: '', img: '', description: '' }) }); }); });
App.jsx
Ajouter un constructeur dans la méthode avec le contenu suivant :
constructor() { super(); this.state = { stepIndex: 1, selectedType: '', breeds: [], selectedBreed: { id: '', img: '', description: '' } }; }
Afficher l’étape courante
Si vous vous rappelez de ce qui a été fait dans le chapitre précédent, nous avons ajouté une propriété active au composant Step. Nous allons, dans le composant App, passer cette propriété en nous basant sur le state stepIndex. La première étape sera affichée quand l’indice vaudra 1, la 2ème étape sera affichée quand l’indice vaudra 2, etc.
A vous de coder !
Liste des tests unitaires :
- Le 1er composant Step à la propriété active à true quand stepIndex vaut 1. Les autres composants Step auront leur propriété active à false
- Le 2ème composant Step à la propriété active à true quand stepIndex vaut 2. Les autres composants Step auront leur propriété active à false
- Le 3ème composant Step à la propriété active à true quand stepIndex vaut 3. Les autres composants Step auront leur propriété active à false
Astuce pour modifier le state du wrapper :
Vous aurez besoin de modifier le state pour faire les tests de l’affichage du 2ème et du 3ème composant Step. La méthode setState du wrapper permet de le faire :
wrapper.setState({ stepIndex: 2 });
App.test.jsx
describe('steps', () => { it('Should display the first step and hide the others when the active index is 1.', () => { const steps = wrapper.find(Step); expect(steps.at(0).prop('active')).toEqual(true); expect(steps.at(1).prop('active')).toEqual(false); expect(steps.at(2).prop('active')).toEqual(false); }); it('Should display the second step and hide the others when the active index is 2.', () => { wrapper.setState({ stepIndex: 2 }); const steps = wrapper.find(Step); expect(steps.at(0).prop('active')).toEqual(false); expect(steps.at(1).prop('active')).toEqual(true); expect(steps.at(2).prop('active')).toEqual(false); }); it('Should display the third step and hide the others when the active index is 3.', () => { wrapper.setState({ stepIndex: 3 }); const steps = wrapper.find(Step); expect(steps.at(0).prop('active')).toEqual(false); expect(steps.at(1).prop('active')).toEqual(false); expect(steps.at(2).prop('active')).toEqual(true); }); });
App.jsx
<Step description="Select your animal" active={this.state.stepIndex === 1}> <AnimalSelector /> </Step> <Step description="Select your breed" active={this.state.stepIndex === 2}> <BreedSelector /> </Step> <Step description="Enjoy your picture" active={this.state.stepIndex === 3}> <AnimalViewer /> </Step>
Dynamiser la barre de progression
Le composant ProgressBar a maintenant la propriété activeStepIndex. Dans le composant App, nous allons passer le state stepIndex dans cette propriété.
A vous de coder !
Liste des tests unitaires :
- La propriété activeStepIndex vaut 1 quand le state stepIndex vaut 1
- La propriété activeStepIndex vaut 2 quand le state stepIndex vaut 2
- La propriété activeStepIndex vaut 3 quand le state stepIndex vaut 3
App.test.jsx
describe('progress bar', () => { it('Should highlight the first step when the active step is 1.', () => { expect(wrapper.find(ProgressBar).prop('activeStepIndex')).toEqual(1); }); it('Should highlight the second step when the active step is 2.', () => { wrapper.setState({ stepIndex: 2 }); expect(wrapper.find(ProgressBar).prop('activeStepIndex')).toEqual(2); }); it('Should highlight the third step when the active step is 3.', () => { wrapper.setState({ stepIndex: 3 }); expect(wrapper.find(ProgressBar).prop('activeStepIndex')).toEqual(3); }); });
App.jsx
<ProgressBar activeStepIndex={this.state.stepIndex} />
Etape 1 – Sélectionner l’animal et récupérer la liste des espèces
Nous allons gérer le clic sur l’animal. Lorsque nous allons cliquer sur le chat ou le chien, un appel vers l’API cat ou dog sera effectué pour récupérer la liste des espèces et nous passerons à la 2ème étape.
Il va donc falloir mocker le module api qui contient toutes les fonctions permettant de récupérer les données relatives à nos amis poilus. Pour mocker le module, nous pouvons ajouter cette ligne au début du fichier de tests :
jest.mock('./api/animal');
Toutes les fonctions à l’intérieur seront mockées, les vraies API ne seront pas contactées.
A vous de coder !
Dans le fichier de test :
- Importez la fonction getBreeds du module api/animal
- Mockez le module api/animal.
- Ajoutez un nouveau describe
- Ajoutez un beforeEach qui va résoudre la fonction getBreeds
import { getBreeds as getBreedsMock } from './api/animal'; jest.mock('./api/animal'); describe('App', () => { let wrapper; beforeEach(() => { wrapper = shallow(<App />); }); //PREVIOUS DESCRIBE describe('selectAnimal', () => { beforeEach(() => { getBreedsMock.mockResolvedValue({}); }); //ADD UNIT TEST }); });
Dans le fichier App.jsx :
- Une nouvelle méthode sera ajoutée : selectAnimal. Elle prendra en paramètre le type ‘cat’ ou ‘dog’ et contactera la fonction getBreeds.
- La propriété onSelectAnimal du composant AnimalSelector contiendra l’appel à la méthode selectAnimal
class App extends Component { ... render() { return ( ... <Step description="Select your animal" active={this.state.stepIndex === 1}> <AnimalSelector onSelectAnimal={this.selectAnimal} /> </Step> ... ); } selectAnimal = (selectedType) => { // DO THINGS } }
Liste des tests unitaires :
- Nous appelons la fonction getBreeds avec le type d’animal sélectionné
- Nous enregistrons dans le state selectedType le type d’animal sélectionné
- Dès que la promise getBreeds est résolue, nous enregistrons dans le state breeds le retour de la promise.
- Dès que la promise getBreeds est résolue, nous passons à l’étape suivante en enregistrant dans le state stepIndex la valeur 2
App.test.jsx
import * as React from 'react'; import { shallow } from 'enzyme'; import App from './App'; import Step from './components/ui/Step'; import ProgressBar from './components/ui/Progressbar'; import AnimalSelector from './components/ui/AnimalSelector'; import { getBreeds as getBreedsMock } from './api/animal'; jest.mock('./api/animal'); describe('App', () => { let wrapper; beforeEach(() => { wrapper = shallow(<App />); }); //PREVIOUS DESCRIBE describe('selectAnimal', () => { beforeEach(() => { getBreedsMock.mockResolvedValue({}); }); it('Should get all breeds from given animal and store them in the state.', done => { const givenType = 'a thing'; const expectedBreeds = [ { id: '1', name: 'NAME-1', description: 'Cool pet' } ]; getBreedsMock.mockImplementation(type => { if (type === givenType) { return Promise.resolve(expectedBreeds); } }); wrapper.find(AnimalSelector).prop('onSelectAnimal')(givenType); setTimeout(() => { expect(wrapper.state('breeds')).toEqual(expectedBreeds); done(); }); }); it('Should save the selected type.', done => { const expectedType = 'cat'; wrapper.find(AnimalSelector).prop('onSelectAnimal')(expectedType); setTimeout(() => { expect(wrapper.state('selectedType')).toEqual(expectedType); done(); }); }); it('Should display the second step.', done => { const expectedStep = 2; wrapper.find(AnimalSelector).prop('onSelectAnimal')('arg'); setTimeout(() => { expect(wrapper.state('stepIndex')).toEqual(expectedStep); done(); }); }); }); });
App.jsx
import ProgressBar from './components/ui/Progressbar'; import AnimalSelector from './components/ui/AnimalSelector'; import BreedSelector from './components/ui/BreedSelector'; import AnimalViewer from './components/ui/AnimalViewer'; import Step from './components/ui/Step'; import { getBreeds } from './api/animal'; import './App.scss'; class App extends Component { ... render() { return ( <div className="app"> ... <main> <Step description="Select your animal" active={this.state.stepIndex === 1}> <AnimalSelector onSelectAnimal={this.selectAnimal} /> </Step> ... </main> </div> ); } selectAnimal = (selectedType) => { getBreeds(selectedType).then(breeds => { this.setState({ selectedType, stepIndex: 2, breeds }); }); } }
Etape 2 – Afficher les espèces
Pour afficher les espèces de l’animal sélectionné, vous allez devoir passer le state breeds dans la propriété breeds du composant BreedSelector.
A vous de coder !
Liste des tests unitaires :
- Le composant BreedSelector affiche la liste des espèces stockées dans le state breeds
App.test.jsx
describe('BreedSelector', () => { it('Should pass the "breeds" state into props of BreedSelector.', () => { const expectedBreeds = [ { id: '1', name: 'NAME-1', description: 'Cool pet' }, { id: '2', name: 'NAME-2', description: 'Cool pet 2' }, ]; wrapper.setState({ breeds: expectedBreeds }); expect(wrapper.find(BreedSelector).prop('breeds')).toEqual(expectedBreeds); }); });
App.jsx
<BreedSelector breeds={this.state.breeds} />
Etape 2 – Sélectionner une espèce
Nous allons gérer le clic sur l’espèce. Lorsque nous allons cliquer sur une espèce, un appel vers l’API cat ou dog sera effectué pour récupérer les infos de l’espèce sélectionnée et nous passerons à la 3ème étape.
A vous de coder !
Dans le fichier de test :
- Importez la fonction getPictureAndDescription du module api/animal
- Ajoutez un nouveau describe
- Ajoutez un beforeEach qui va résoudre la fonction getPictureAndDescription
// PREVIOUS IMPORTS import { getBreeds as getBreedsMock, getPictureAndDescription as getPictureAndDescriptionMock } from './api/animal'; jest.mock('./api/animal'); describe('App', () => { let wrapper; beforeEach(() => { wrapper = shallow(<App />); }); // PREVIOUS DESCRIBE describe('selectBreed', () => { beforeEach(() => { getPictureAndDescriptionMock.mockResolvedValue({}); }); // ADD UNIT TEST }); });
Dans le fichier App.jsx :
- Une nouvelle méthode sera ajoutée : selectBreed. Elle prendra en paramètre l’id de l’espèce sélectionnée et contactera la fonction getPictureAndDescription.
- La propriété onSelectBreed du composant BreedSelector contiendra l’appel à la méthode selectBreed
class App extends Component { ... render() { return ( ... <Step description="Select your breed" active={this.state.stepIndex === 2}> <BreedSelector onSelectBreed={this.selectBreed} /> </Step> ... ); } selectBreed = (selectedBreedId) => { // DO THINGS } }
Liste des tests unitaires :
- Nous appelons la fonction getPictureAndDescription avec le type d’animal sélectionné et l’identifiant de l’espèce
- Dès que la promise getPictureAndDescription est résolue, nous enregistrons dans le state selectedBreed :
- l’id de l’espèce sélectionnée
- l’url de l’image
- la description
- Dès que la promise getPictureAndDescription est résolue, nous passons à l’étape suivante en enregistrant dans le state stepIndex la valeur 3
App.test.jsx
describe('selectBreed', () => { beforeEach(() => { getPictureAndDescriptionMock.mockResolvedValue({}); }); it('Should get random picture and description from selected breed and store it in the state.', done => { const givenType = 'a thing'; const expectedBreed = 'breed-id'; const expectedImage = 'nice picture'; const expectedDescription = 'random description'; getPictureAndDescriptionMock.mockImplementation((type, breed) => { if (type === givenType && breed === expectedBreed) { return Promise.resolve({ img: expectedImage, description: expectedDescription }); } }); wrapper.setState({ selectedType: givenType }); wrapper.find(BreedSelector).prop('onSelectBreed')(expectedBreed); setTimeout(() => { expect(wrapper.state('selectedBreed').img).toEqual(expectedImage); expect(wrapper.state('selectedBreed').description).toEqual(expectedDescription); expect(wrapper.state('selectedBreed').id).toEqual(expectedBreed); done(); }); }); it('Should display the third step.', done => { const expectedStep = 3; wrapper.find(BreedSelector).prop('onSelectBreed')('arg'); setTimeout(() => { expect(wrapper.state('stepIndex')).toEqual(expectedStep); done(); }); }); });
App.jsx
// PREVIOUS IMPORTS import { getBreeds, getPictureAndDescription } from './api/animal'; class App extends Component { // CONSTRUCTOR render() { return ( <div className="app"> ... <main> ... <Step description="Select your breed" active={this.state.stepIndex === 2}> <BreedSelector breeds={this.state.breeds} onSelectBreed={this.selectBreed} /> </Step> ... </main> </div> ); } // PREVIOUS METHOD selectBreed = (selectedBreedId) => { getPictureAndDescription(this.state.selectedType, selectedBreedId).then(({ img, description }) => { this.setState({ selectedBreed: { id: selectedBreedId, description, img }, stepIndex: 3 }); }); } }
Etape 3 – Afficher l’image et la description
Pour afficher l’image et la description de l’espèce sélectionnée, vous allez devoir passer les attributs img et description du state selectedBreed dans les propriétés img et descriptions du composant AnimalViewer.
A vous de coder !
Liste des tests unitaires :
- Le composant AnimalViewer affiche la description de l’espèce stockée dans le state selectedBreed
- Le composant AnimalViewer affiche l’image de l’espèce stockée dans le state selectedBreed
App.test.jsx
describe('AnimalViewer', () => { it('Should pass the "imgUrl" state into props of AnimalViewer.', () => { const expectedImgUrl = 'arg.jpg'; wrapper.setState({ selectedBreed: { img: expectedImgUrl, description: '' } }); expect(wrapper.find(AnimalViewer).prop('img')).toEqual(expectedImgUrl); }); it('Should pass the "description" state into props of AnimalViewer.', () => { const expectedDescription = 'nice cat'; wrapper.setState({ selectedBreed: { img: '', description: expectedDescription } }); expect(wrapper.find(AnimalViewer).prop('description')).toEqual(expectedDescription); }); });
App.jsx
<Step description="Enjoy your picture" active={this.state.stepIndex === 3}> <AnimalViewer img={this.state.selectedBreed.img} description={this.state.selectedBreed.description} /> </Step>
Etape 3 – Charger une nouvelle image
Nous allons gérer le clic sur le bouton “I want another one”. Lorsque nous allons cliquer, un appel vers l’API cat ou dog sera effectué pour récupérer les infos de l’espèce sélectionnée et mettre à jour l’attribut img du state selectedBreed.
A vous de coder !
Dans le fichier App.jsx :
- Une nouvelle méthode sera ajoutée : getNewPicture. Elle n’aura pas de paramètre et contactera la fonction getPictureAndDescription avec en paramètre le type de l’animal et l’id de l’espèce sélectionnée contenu dans le state selectedType et selectedBreed.
- La propriété onReloadImage du composant AnimalViewer contiendra l’appel à la méthode getNewPicture
class App extends Component { ... render() { return ( ... <Step description="Enjoy your picture" active={this.state.stepIndex === 3}> <AnimalViewer
img={this.state.selectedBreed.img}
description={this.state.selectedBreed.description}
onReloadImage={this.getNewPicture} /> </Step> ... ); } getNewPicture = () => { // DO THINGS } }
Liste des tests unitaires :
- Nous appelons la fonction getPictureAndDescription avec le type d’animal sélectionné et l’identifiant de l’espèce
- Dès que la promise getPictureAndDescription est résolue, nous enregistrons dans le state selectedBreed :
- la nouvelle image img
App.test.jsx
describe('getNewPicture', () => { it('Should get another random picture from selected breed.', done => { const givenType = 'a thing'; const givenBreed = 'breed-id'; const expectedImage = 'nice picture'; getPictureAndDescriptionMock.mockImplementation((type, breed) => { if (type === givenType && breed === givenBreed) { return Promise.resolve({ id: givenBreed, img: expectedImage, description: '' }); } }); wrapper.setState({ selectedType: givenType }); wrapper.setState({ selectedBreed: { id: givenBreed, img: 'other-image' } }); wrapper.find(AnimalViewer).prop('onReloadImage')(); setTimeout(() => { expect(wrapper.state('selectedBreed').img).toEqual(expectedImage); done(); }); }); });
App.jsx
// PREVIOUS IMPORTS class App extends Component { // CONSTRUCTOR render() { return ( <div className="app"> ... <main> ... <Step description="Enjoy your picture" active={this.state.stepIndex === 3}> <AnimalViewer img={this.state.selectedBreed.img} description={this.state.selectedBreed.description} onReloadImage={this.getNewPicture} /> </Step> </main> </div> ); } // PREVIOUS METHODS getNewPicture = () => { getPictureAndDescription(this.state.selectedType, this.state.selectedBreed.id).then(({ img }) => { this.setState({ selectedBreed: { ...this.state.selectedBreed, img }, }); }); } }
Bravo, vous avez fini de tester le composant, il ne vous reste plus qu’à démarrer l’application avec la commande npm run start !
Pour aller plus loin
Ce formulaire est loin d’être parfait et il existe sûrement 1000 autres façons de l’implémenter. Il faudrait ajouter des animations pendant les changements d’étapes ainsi que des loaders. Nous pourrions également naviguer entre les étapes en cliquant sur les étapes de la barre de progression ou ajouter un bouton retour dans les étapes 2 et 3. Essayez-de le faire en testant pour voir si vous avez bien assimilé les notions.
Conclusion
Comme nous avons pu le voir au fur et à mesure des tests, il est très facile de tester des composants à l’aide de jest-enzyme. Il est cependant regrettable que les hooks ne soient pas encore gérés, mais cela devrait être possible dans les jours / semaines à venir. Garder en tête que pour tester facilement un composant, il faut qu’il soit simple. Si vous constatez qu’un composant demande trop d’efforts à tester, découpez-le ! Extrayez des composants plus petits (et pourquoi pas réutilisables pour vous faire gagner du temps). Votre code n’en sera que plus lisible et vos collègues vous remercieront.