Tests unitaires
Tests unitaires
Dans la structuration de vos projets, il est toujours intéressant d'organiser son code dans les modules qui traite un et seul sujet ou problème, sans mélanger d'autres objectifs, fonctions, ou dépendances.
Il n'est pas toujours évident où mettre la logique de son code.
Est-ce qu'on la met dans les handlers de notre API directement ? Et, si un jour, on voulait invoquer la fonctionnalité sans passer par une requête HTTP (via un CLI par exemple) ? On sera coincé. Du coup, on pourrait mettre la logique business dans une autre fonction ou classe. Dans cette fonction/classe, est-ce qu'on intègre directement les instructions IO (vers la base de données) ? Ou serait-il mieux d'encore extraire ces fonctionnalités-là, pour rendre notre logique business indépendant de la couche de stockage donnée.
Je répète, il n'est pas toujours évident de décider. L'architecture et la rédaction de son code dépend de plusieurs facteurs dont il faut trouver l'équilibre :
Le temps et argent disponibles : il serait bien de réfléchir un design pendant 1 semaine avant de se lancer dans le code. Le design sera parfait, mais ça coûtera cher et sera peut-être en retard.
La complexité finale de votre projet. Un projet bien avec beaucoup d'abstractions devient de plus en plus difficile à comprendre.
L'architecture du logiciel est donc le sujet de diverses études, écoles, normes, frameworks :
Broker
Event-bus
...
L'architecture peut être stricte, ou flexible, et en plein d'évolution dans le même projet.
Une technique concrète qui aide à la structuration de son code et utiliser des tests pour décider comment structurer vos modules.
Est-ce que je pourrais tester clairement et facilement une logique business ?
Est-ce que mon test est indépendant d'autres facteurs qui n'ont rien avoir avec la logique business (surtout provenant des dépendances externes) ?
Cas d'étude
Considérez le code suivant, pour transferer de l'argent entre un annonceur de publicité et un éditeur lorsqu'une publicité est vue sur Internet :
Comme vous voyez, cette classe n'implémente que la transaction entre l'annonceur, l'éditeur, l'utilisateur et la publicité. Vous allez remarquer aussi que :
Dans la classe, on suppose que les données ont déjà été chargées. Cette classe ne s'en occupe pas
Dans la classe, on ne s'occupe pas de la sauvegarde des données. En effet, la fonction
transfer
va simplement retourner un objet avec les mises à jour à apporter, puis on laisse un autre module sauvegarder ses données dans la baseCette classe ne se préoccupe pas du tout de QUI va l'appeler, ni COMMENT, ni QUAND.
La classe pourrait fonctionner comme une boîte noire : il y a des entrées bien définies, et des sorties bien définies
Qu'est-ce qu'on vient de faire ?
On a isolé la logique business
On a enlevé les dépendances (notamment le stockage, MySQL, etc)
Cette classe devient un cas parfait pour ce qu'on appelle un test unitaire qui a pour objectif de faire le suivant :
Un test unitaire a pour objectif de tester toutes les combinaisons de input, et mesurer toutes les combinaisons de output, pour être sûr que le output soit toujours valable, et consistent.
Via un test unitaire, on pourrait :
Détecter un comportement inattendu si jamais on refait une passe sur l'implémentation (par exemple, pour optimiser)
Détecter des bogues (le test plante)
Assurer que les valeurs ne change pas (en quantité, type, format, etc)
Tester les edge-case : par exemple, utiliser des valeurs non-valides comme input, assurer qu'une exception d'un certain type est lancé, par exemple.
Implémentation des tests
Retournons à notre projet d'exemple, qui vous avez téléchargé pour les chapitres précédentes :
Dans ce projet, nous avons ajouté des tests unitaires dans le dossier test/unit/suites
, notamment des tests pour notre classe Adview.ts
.
Nos tests utilisent des paquets externes :
Puisque ce sont des paquets utilisés uniquement en développement, on les aurait installé avec l'option --save-dev
:
Attention l'option --save-dev
: on veut utiliser ces packages uniquement en développement et pas en déploiement.
Ensuite, nous avons crée nos premiers tests dans le dossier test/unit/suites
.
Pourquoi cette organisation ?
On range tous nos tests dans le répertoire
test
pour pouvoir facilement l'exclure de notre build de productionOn range les tests unitaires à part des autres tests à venir (intégration, e2e)
On peut ranger encore plus par thème (
suites
)
Regardons les tests pour AdView.ts
Dans mocha
, on utilise uns structure BDD (behaviour driven development), qui veut dire qu'on va préciser un comportement souhaité, puis chaque test va assurer ce comportement.
Dans le fichier, on commence par le mot clé describe
qui précise le module qu'on est en train de décrire.
Ensuite, avec la fonction it
, nous spécifions le comportement souhaité :
À un moment, il faut valider que le comportement est validé ou pas. Normalement, nous faisons des assertions dans le négatif : si tout fonctionne bien, la fonction quitte sans erreurs. Si un problème est détecté, on devrait quitter la fonction avec une erreur.
Dans pratiquement tous les langages de programmation, on a la notion d'assertion (ou la fonction assert
), qui teste une condition, et arrête le processus avec un code d'erreur si la condition ne passe pas.
La librairie chai
nous propose un nombre de clauses qui implémente la fonction assert
mais dans une façon plus proche à l'anglais :
Si une condition ne passe pas, le processus (le test) s'arrête avec un code d'erreur.
On pourrait même tester que les exceptions sont bien lancées :
Pour exécuter notre test, on ajoute un script à package.json :
On peut ensuite lancer nos tests avec :
Le résultat :
Essayez vous même dans votre DevContainer !
Essayez d'apporter une modification qui change le comportement du module Adview pour voir comment les tests réagissent !
Exclure nos tests du build final
Nous avons ajouté des fichiers .ts
à notre projet qu'on ne veut pas forcément inclure dans le build final. Si on essaye de lancer tsc
sans la libraire mocha
installé, il y aura une erreur.
Nous allons donc exclure notre dossier test
des builds en production. Pour cela, il faut modifier tsconfig.json
:
Une bonne conception des tests
Attention ! La rédaction des tests peut être assez longue.
De façon générale, je compte 1 unité de temps pour la création d'un module, et 2 ou 3 fois plus pour la conception et rédaction des tests.
Pourquoi ?
Il faut imaginer TOUS les scenarii possibles : l'usage normal, les edge-cases, les erreurs, etc
Il faut être capable de créer les données entrantes, et les conditions de test avant de le lancer. Cela peut être longue et ardue.
En revanche, le temps et la douleur que les tests enlèvent dans le futur fait que ça vaut le coup !!
Et s'il y a des dépendances ?
Mais, vous dites, cela est très bien, mais pour la plupart, nos plateformes vont interagir avec des dépendances externes. Notamment, une base de données.
À ce moment-là, nous élargirons l'étendu de nos tests avec des tests d'intégration.
Dernière mise à jour