ABC...Tests ⚙️
2 juin 2023 | 17 mins de lecturePublié sur dev.to et medium.com
Les tests unitaires pour le développement Web
NB : Cet article fait suite à une conférence sur le même sujet ; voici le support cette présentation : https://slides.com/damienchazoule/abc-tests
Once Upon A Time...
Les tests unitaires sont l'affaire de tous les développeurs, qu'ils soient experts frontend, ou backend (ou même fullstack).
Dès 1994, les premiers tests unitaires voient le jour pour le langage SmallTalk : SUnit.
3 ans plus tard, Kent Beck (créateur de xUnit) rencontre Erich Gamma et portent SUnit pour le langage Java, j'ai nommé JUnit !
Au début des années 2000 (plus exactement 2001), la famille xUnit s'agrandit avec l'arrivée un p'tit nouveau : JSUnit.
L'objectif des tests unitaires est de vérifier le bon fonctionnement de tout ou partie d'une application / d'un développement. De même, ces tests peuvent signaler d'éventuelles régressions post-livraison d'un projet (lors de la phase de maintenance).
Les tests unitaires font partie intégrante de l'Agilité, avec notamment la méthode eXtreme Programming (XP pour les intimes) qui inclut le pattern TDD lors des itérations d'un projet. Le Test Driven Development, c'est d'écrire les tests unitaires avant même le développement de fonctionnalités / de composants techniques, ainsi que les commentaires dans le code et / ou la documentation technique.
Différents Types De Tests
Il existe plusieurs "degrés" / niveaux de tests unitaires, mais chacun fonctionne de la même manière, à savoir :
- La configuration de l'environnement de test
- L'exécution du test / de la suite de tests unitaires
- La validation / le contrôle du résultat des tests
- Enfin, la réinitialisation de l'environnement de test
Les 1ers tests à effectuer (les tests "simples" / "basiques"), c'est de tester les fonctions, les algorithmes, la logique métier dans le code.
NB : Ce type de test a notamment aidé mon fils (6 ans à l'époque) à comprendre qu'il est possible d'additionner 2 nombres et d'obtenir un résultat négatif. Même une fonction sum()
peut être amusante à tester 😉
Le 2ème degré de tests unitaires concerne les frameworks orientés composants (Angular, React, Vue, etc...). Ces tests ont pour objectif de valider le comportement d'un unique composant / ou d'un ensemble de composants (dans un contexte complexe, le routeur par exemple). Ici, on s'approchera davantage du pattern BDD*.
Enfin, il existe les tests de bout-en-bout (E2E) pour contrôler un parcours utilisateur / un cas d'usage métier. Par exemple, si vous devez vérifier le bon fonctionnement d'un formulaire, afin d'obtenir une estimation, ou autre chose... Ces types de tests sont là pour ça !
Simple / Basic Testing
Make your choice....
Les solutions de tests unitaires en JavaScript sont nombreuses. Porter par l'écosystème AngularJS (R.I.P), dans les années 2010, le moteur de tests Karma et sa librairie d'assertions Jasmine on connut leurs heures de gloire. À noter que Jasmine n'est ni plus ni moins que l'évolution de JSUnit !
À la même période, le framework de test Mocha commence à faire parler de lui, une solution alternative à Karma qui s'interface avec n'importe quelle librairie d'assertions. Quelque temps plus tard, à l'issue du fork d'AngularJS (mené par Evan You), Vue embarquera le couple Mocha x Chai en tant que solution de tests par défaut pour son framework orienté composants.
Avec React, Facebook a repensé une nouvelle solution de tests unitaires beaucoup plus simple en terme de configuration. De son p'tit nom Jest, est loin d'être un "plaisantin", puisqu'il porte à la fois le moteur de tests mais également la librairie d'assertions. Il est "LA" solution tout-en-un pour ceux qui souhaitent tester leur application / leur code, du fait de sa simplicité (mais aussi de sa rapidité).
À ce stade-là, je vous aurai déjà dit de choisir Jest sans hésiter. Mais en 2023, la nouvelle génération de librairies JavaScript et là ! Je veux bien sûr parler de Vite, mais surtout de Vitest. Tout est dans le nom 🚀 Ce dernier s'interface à la perfection avec le bundler Vite, et reprend les grandes lignes de son prédécesseur Jest.
How To Test ?
Passons à la pratique, en créant un nouveau projet JavaScript (avec le bundler de nouvelle génération) :
npm create vite@latest abc-tests -- --template react-swc
npm install --save-dev vitest
NB : Ci-dessus, j'échafaude le projet avec le framework React + Speedy Web Compiler pour une compilation plus rapide qu'avec Babel !
Commençons doucement par créer un 1er fichier utils.js
, puis en écrivant une simple fonction :
/**
* Format date to 'dd/mm/yyyy' pattern
*
* @param {Date} dateToFormat
* @returns {string} Formatted date
*/
export const formatDate = dateToFormat => {
if (dateToFormat instanceof Date) {
const dd = dateToFormat.getDate();
const mm = dateToFormat.getMonth() + 1;
const yyyy = dateToFormat.getFullYear();
return `${dd}/${mm}/${yyyy}`;
}
throw new Error('Not a Date');
};
utils.js
Maintenant, testons cette 1ère fonction de formatage à partir du code ci-dessous, et de l'instruction de terminal suivante : npx vitest
import { describe, it, expect } from 'vitest';
import { formatDate } from './utils';
describe('formatDate', () => {
it('should returns formatted date', () => {
const mayTheFourth = new Date('2023-05-04');
expect(formatDate(mayTheFourth)).toEqual('21/5/2023');
});
it('should throws an error', () => {
expect(formatDate('2023-05-04')).toThrowError('Not a Date');
});
});
utils.test.js
Et voilà, c'est relativement simple finalement ! Non ? Ici, les deux cas de tests sont couverts, à savoir en cas de succès, mais aussi en cas d'erreur lorsque la date n'est pas de type Date
. Voici d'autres exemples :
import { suite, test, expect } from 'vitest';
import { toCapitalize } from './utils';
suite("concat or evaluate, that's the question", () => {
test('it should concat 2 values', () => {
expect('11' + 1).toEqual('111');
});
test('it should evaluate 2 values', () => {
expect('11' - 1).toEqual(10);
});
});
NB : Il y a quelques mots-clés à retenir lorsqu'il s'agit de tests unitaires orientés frontend. Les mots-clés describe
et suite
permettent de déclarer une suite de tests, généralement associé à une fonctionnalité. Les mots-clés it
et test
sont relativement explicites, il s'agit tout simplement du test. Enfin, expect
, le principal / l'assertion ! À noter que it
est plus usité que test
du fait de l'association de mots suivant "it should..." ; cela fonctionne moins bien avec "test" 😋
Il est évidemment possible de tester des fonctions asynchrones, notamment dans le cadre de tests d'API. Voici une simulation d'une fonction de service :
/**
* Get next value from current
*
* @param {number} currentValue
* @returns {Promise} { nextValue }
* @throws {Error} NaN
*/
export const getNextValue = currentValue => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof currentValue === 'number') {
const nextValue = currentValue + 1;
resolve({ nextValue });
} else {
reject(new Error('Not an Number'));
}
}, 1000);
});
};
counterService.js
Et voici les tests associés, en cas de succès, ainsi qu'en cas d'erreur :
import { describe, it, expect } from 'vitest';
import * as CounterService from './counterService';
describe('CounterService', () => {
it('should returns next value', async () => {
const { nextValue } = await CounterService.getNextValue(41);
expect(nextValue).toEqual(42);
});
it('should throws an error', async () => {
expect(await CounterService.getNextValue('41')).toThrowError('Not an Number');
});
});
counterService.test.js
Certains tests peuvent paraître dérisoires ou mêmes évidents à première vue, mais c'est rarement le cas. Dans le cadre de fonctions de mapping (entre backend et frontend), l'intérêt est grand, puisqu'il permet de diagnostiquer rapidement les régressions / les changements au niveau de la signature des APIs. Je vous conseille donc grandement de découper au mieux votre code afin de faciliter les tests.
Component Testing
Make your choice (again)...
C'est ici que le pattern Behavior Driven Development* prend tout son sens.
De même que pour les tests unitaires "classiques", il existe plusieurs solutions permettant de tester les composants de son application.
Historiquement, AirBnB arrive en 1er avec Enzyme ! Equipé d'une API proche de jQuery (pour manipuler les composants), Enzyme permet de monter / rendre un composant React dans un DOM virtuel (le fameux JSDOM). Ce framework de tests unitaires dispose d'un mode shallow
permettant de monter un seul et unique composant (sans enfant), donc de manière "unitaire" ; et d'un mode mount
pour rendre le composant parent et les composants enfants associés à ce dernier, et donc techniquement d'atteindre les fonctions componentDidMount
/ componentDidUpdate
du cycle de vie des composants React.
Le concurrent direct d'Enzyme n'est autre que Testing Library. Arrivée plus tard que ce dernier. Testing Lib(rary) dispose d'une API moderne plus explicite (et simple : queryBy*
/ getBy*
/ findBy*
) permettant de rendre un composant et ses enfants via une fonction unique. Cette librairie a pour ambition d'offrir de meilleurs pratiques de tests pour les frameworks orientés composants. En effet, Testing Lib est agnostique et peut s'utiliser avec Angular, React, Vue, Svelte, Cypress, etc...
How To Test ?
Une fois encore, passons à la pratique en ajoutant les dépendances requises et en configurant l'environnement de test :
npm i -D @testing-library/jest-dom @testing-library/react jsdom
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
// https://vitejs.dev/config/
export default defineConfig({
test: { environment: 'jsdom' }, // 👈 TODO
plugins: [react()]
});
vite.config.js
Considérons le composant <App />
(par défaut) ci-dessous :
import { useState } from 'react';
import * as CounterService from './counterService';
import styles from './app.module.css';
export default function App({ defaultValue = 0, ...props }) {
const [count, setCount] = useState(defaultValue);
const handleClick = async currentValue => {
if (props.onClick) props.onClick({ prevValue: currentValue });
try {
const { nextValue } = await CounterService.getNextValue(currentValue);
setCount(nextValue);
} catch (err) {
// eslint-disable-next-line
console.log(err.message);
}
};
return (
<div className={styles.app}>
<h1>Vite + React</h1>
<div className={styles.card}>
<button onClick={() => handleClick(count)}>Increment</button>
<p>Count: {count}</p>
</div>
</div>
);
}
App.jsx
Puis, exécutons quelques tests orientés "comportement" :
import { expect, describe, afterEach, it, vi } from 'vitest';
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
import matchers from '@testing-library/jest-dom/matchers';
import App from './App';
expect.extend(matchers);
describe('<App />', () => {
afterEach(cleanup);
it('should renders', () => {
render(<App />);
expect(screen.getByText('Vite + React')).toBeInTheDocument();
});
it('should match snapshot', () => {
const container = render(<App />);
expect(container).toMatchSnapshot();
});
it('should increment the count value', async () => {
render(<App defaultValue={41} />);
expect(screen.getByText('Count: 41')).toBeInTheDocument();
const btn = screen.getByRole('button', { name: 'Increment' });
fireEvent.click(btn);
expect(await screen.findByText('Count: 42')).toBeInTheDocument();
});
it('should triggers the click event', () => {
const onClickMock = vi.fn();
render(
<App
defaultValue={41}
onClick={onClickMock}
/>
);
const btn = screen.getByRole('button', { name: 'Increment' });
fireEvent.click(btn);
expect(onClickMock).toHaveBeenCalled();
expect(onClickMock).toHaveBeenCalledWith({ lastCount: 41 });
});
});
App.test.jsx
Ci-dessus, plusieurs cas de tests :
- Le premier
should renders
permet simplement de rendre un composant et de contrôler son contenu ; - Le deuxième
should match snapshot
créé une image de votre composant et permet de détecter les éventuelles différences lorsque votre composant évolue. Dans ce cas précis, il faudra mettre à jour votresnapshot
; - Le 3ème cas de test contrôle la valeur de la variable
count
avant et après le clique sur le bouton ; - Enfin, dans le 4ème cas de test, on injecte une fonction "mocké" à notre composant, on clique sur le bouton, puis on vérifie que cette fonction a bien été appelée avec les bons arguments ;
fireEvent
permet bien des choses liées aux événements dans le DOM, notamment remplir les champs d'un formulaire. Pour des cas simples, pourquoi pas... Pour des cas plus complexes, mieux vos laisser cela aux tests de bout-en-bout...
Mock Service Worker (MSW)
Impossible d'évoquer les tests unitaires d'appel d'API sans parler de Mocker Service Worker. MSW agit tel un middleware entre votre application et les APIs, en interceptant les requêtes au niveau du réseau. Ainsi, il n'est plus nécessaire de mocker Axios ou Fetch pour contrôler les algorithmes relatifs aux appels et retour d'APIs.
MSW supporte les requêtes REST(ful) et GraphQL, et propose deux modes : le mode worker
(idéal pour le développement et le déboguage de votre application), et le mode server
pour le backend et les tests unitaires.
Extrait tout droit de la documentation, voici comment installer et initialiser le worker
:
npm i axios && npm i -D msw
npx msw init public --save
Enrichissons le "faux" service précédemment créé en ajoutant une fonction faisant l'appel au "vrai" service :
import axios from 'axios';
/**
* Post current value to get next
*
* @param {number} currentValue
* @returns {Promise} { nextValue }
* @throws {Error} 40x or 50x
*/
export const postCurrentValue = async currentValue => {
try {
const response = await axios.post('/api/counter', { currentValue });
return response.data; // { nextValue }
} catch {
throw new Error('Not an 20x');
}
};
counterService.js
NB : Ci-dessous, voici le code à exécuter au niveau du point d'entrée (index.js
) de votre projet pour mocker le retour de l'API /api/counter
, lors de l'exécution de l'application.
import { rest, setupWorker } from 'msw';
const handlers = [
rest.post('/api/counter', async (req, res, ctx) => {
const { currentValue } = await req.json();
if (typeof currentValue === 'number') {
const nextValue = currentValue + 1;
return res(ctx.delay(1000), ctx.json({ nextValue }));
}
return res(ctx.status(422, 'Unprocessable Entity'));
})
];
const worker = setupWorker(...handlers);
worker.start();
Et maintenant, voici le fonctionnement du mode server
(alternative au mode worker
) dans le cadre de tests unitaires (toujours dans le cas d'un succès, mais aussi d'un échec) :
import { describe, beforeAll, afterEach, afterAll, it, expect } from 'vitest';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import * as CounterService from './counterService';
describe('postCurrentValue', () => {
const server = setupServer();
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('should returns next value', async () => {
server.use(
rest.post('/api/counter', (req, res, ctx) => {
return res(ctx.json({ nextValue: 42 }));
})
);
const result = await CounterService.postCurrentValue(41);
expect(result.nextValue).toBeDefined();
});
it('should throws an error', async () => {
server.use(
rest.post('/api/counter', (req, res, ctx) => {
return res(ctx.status(400, 'Bad Request'));
})
);
const err = await CounterService.postCurrentValue('41').catch(err => err);
expect(err.message).toEqual('Not an 20x');
});
});
counterService.test.js
End-To-End Testing
Make your choice (again and again)...
Pour les tests plus complexes, notamment les cas d'usage métier, il y a les tests de bout-en-bout. L'intérêt de ces tests est de prouver le bon déroulement d'un parcours fonctionnel, et de jouer votre application dans des cas réels / proche de l'usage final.
Pour ce faire, Cypress est devenue un acteur incontournable des tests de bout-en-bout. Riche d'une documentation détaillé et d'une communauté active, ce framework permet d'émuler un parcours utilisateur, directement dans un navigateur (Chrome, Firefox ou Edge) et de suivre son avancement visuellement. Avec l'arrivée de Testing Library, Cypress a enrichi son API, et dispose maintenant des fonctions getBy*
(getByText
, getByRole
, etc...) pour manipuler les éléments du DOM.
En alternative à Cypress, il y a Playwright. Sountenu par Microsoft, ce dernier offre une configuration simple et rapide pour les tests de bout-en-bout. À l'inverse de Cypress, Playwright joue les cas d'usage métier depuis un ou plusieurs navigateurs "headless" (Chromium, Firefox, Webkit) en parallèle. Il est également disponible dans plusieurs langages : JavaScript, Java, Python et C# (contrairement à son homologue). Enfin, ce framework bénéficie d'une API similaire à Testing Lib depuis Octobre 2022, pour le plus grand bonheur des développeurs... 🙏
How To Test ?
Quoi de plus concret que de vous montrer Playwright en action. Pour ce faire, laissez-moi vous démontrer la puissance du mode codegen
:
npm install --save-dev @playwright/test
npx playwright install
npx playwright codegen --viewport-size=800,600 http://localhost:5173
NB : npx playwright install
permet d'installer les navigateurs "headless" sur lesquels vous allez exécuter vos tests, à savoir Chromium, Firefox et WebKit.
NB : L'application DIY Redux est un développement réalisé dans le cadre de travaux autour du State Management (cf. React Vs. Vue).
Le mode codegen
de Playwright est magique ! Il permet de simuler le cas de test fonctionnel, tout en enregistrement chacune de vos actions. Il suffit ensuite d'adapter les sélecteurs, ajouter les assertions en fonctions de votre besoin, et d'exécuter Playwright à nouveau (playwright test
) pour avoir un cas de test de bout-en-bout complet.
import { test, expect } from '@playwright/test';
test.use({
viewport: {
height: 600,
width: 800
}
});
test('it should fill form and move to next (then to next)', async ({ page }) => {
await page.goto('http://localhost:5173/');
await expect(page.getByRole('heading', { name: 'Identity' })).toBeVisible();
await page.getByPlaceholder('Abramov').click();
await page.getByPlaceholder('Abramov').fill('Chazoule');
await page.getByPlaceholder('Dan').click();
await page.getByPlaceholder('Dan').fill('Damien');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByRole('heading', { name: 'Professional' })).toBeVisible();
const firstComboBox = await page.getByRole('combobox').nth(0);
firstComboBox.selectOption('Front');
const secondComboBox = await page.getByRole('combobox').nth(1);
secondComboBox.selectOption('JavaScript');
await page.getByPlaceholder('Go, Rust...').click();
await page.getByPlaceholder('Go, Rust...').fill('TypeScript');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByRole('heading', { name: 'Fantasy' })).toBeVisible();
await page.getByPlaceholder('Cliff').click();
await page.getByPlaceholder('Cliff').fill('Ragnar');
await page.getByRole('button', { name: 'Previous' }).click();
await expect(page.getByPlaceholder('Go, Rust...')).toHaveValue('TypeScript');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByPlaceholder('Cliff')).toHaveValue('Ragnar');
});
Code Coverage
Et la couverture de code dans tout ça ? 🤔 La couverture de code est une métrique qui détermine le taux de code exécuté. Cet indicateur met en avant le pourcentage de code déclaré, de branches couvertes, de fonctions exécutées, ainsi que les lignes de code non couvertes (explicitement).
Attention ! Il faut bien différencier les tests et le taux de couverture ! En effet, le taux de couverture de code est calculé en fonction des tests unitaires exécutés, mais pour autant un test effectué avec succès, ne veut pas forcément dire que le code est entièrement couvert... De même, il faut aussi s'interroger à propos de l'intérêt d'une couverture optimal ; le 100% est parfois utopique ou sans intérêt, s'il n'apporte aucune valeur ajoutée au projet (sécurité, évolutivité, maintenabilité).
Avec Vitest, l'outil de couverture de code n'est pas inclus par défaut, contrairement à Jest qui se repose sur Babel pour fournir cette fonctionnalité, sans installer de dépendance supplémentaire.
Pour autant, il est possible d'obtenir une couverture de code grâce à la librairie C8 :
npm i -D @vitest/coverage-c8
npx vitest --coverage
-------------------|---------|----------|---------|---------|---------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line
-------------------|---------|----------|---------|---------|---------------
All files | 96.73 | 93.33 | 100 | 96.73 |
App.jsx | 92.1 | 83.33 | 100 | 92.1 | 16-18
counterService.js | 100 | 100 | 100 | 100 |
utils.js | 100 | 100 | 100 | 100 |
-------------------|---------|----------|---------|---------|---------------
Ainsi, le résultat ci-dessus met en avant le fait que la plupart des lignes sont couverte, sauf pour les lignes 16 à 18 du fichier App.jsx
(d'après les tests précédents). En y regardant de plus près, on s'aperçoit qu'il s'agit d'un console.log()
. Quel est l'intérêt de passer par ici ? Manque t-il du code !? Ou bien est-ce normal...
Le Mot De La Fin
Bien souvent, les tests unitaires sont souvent synonymes de surcout pour le projet (notamment pour les projets frontend car non garant des données, contrairement au backend). Ceci est à la fois vrai, mais aussi faux... Les tests unitaires sont une habitude à prendre. Plus les développeurs auront l'habitude de tester leur code, moins cela coutera aux projets. De même, le flex du test unitaire, permet de mieux découper ses algorithmes, de mieux organiser ses projets techniques, et ainsi garantir une meilleure maintenabilité du code source.
L'erreur à ne pas faire côté frontend est de laisser un projet "à l'abandon" sans maintenance régulière (supérieur à 6 mois) et surtout sans test (ni commentaire)... Le Web évolue rapidement, les projets frontend doivent se mettre à jour à cette même fréquence. De même, les tests unitaires garantissent un niveau de qualité et une compréhension du code ! Ainsi la pérennité d'un projet Web dépend en partie de ses tests unitaires 👌
À la question "est-ce douter de tester ?", je réponds que je ne doute plus ! Je teste mes développements car c'est naturel (et amusant) 🙂
Pour Aller Plus Loin...
Toujours dans l'objectif de fournir du code de bonne qualité pour vos projets frontend, il est possible d'interface l'outil de qualimétrie SonarQube avec Vitest. En effet, Vitest est en capacité de fournir deux types de rapports : les rapports de couverture et les rapports de tests. En combinant ces deux outils avec votre CI/CD (GitHub Actions, GitLab CI, etc...) vous serez en mesure de garantir un certain niveau de qualité, en définissant un taux de couverture à atteindre (bien que controversé) mais surtout en vous assurant du bon déroulement des tests unitaires et cela de manière automatique 👍
NB : SonarQube est un outil relativement visuel, et qui permet aux acteurs du projet (autre que les développeurs) d'identifier rapidement la robustesse du code généré (indicateurs rouge / vert).
Enfin, nous avons vu précédemment les tests unitaires "basique" / "simple", les tests orientés composants, mais aussi les tests de bout-en-bout ; il existe une dernière catégorie de tests : le Mutation Testing ! L'objectif de ces types de tests est d'effectuer une multitude de cas d'usage "défectueux" afin de savoir si notre application est robuste. À l'heure actuelle, je n'ai trouvé qu'un outil permettant de faire cela. L'écosystème JavaScript va continuer d'évoluer dans les prochaines années ; on peut donc s'attendre à voir ce genre de test se démocratiser. Affaire à suivre...