Validations et Reproductibilité
Validations
Motivation
Il ne suffit pas d'écrire un programme, il faut aussi vérifier (action) qu'il fonctionne "correctement", par le biais de tests et d'analyses (outils), afin de le valider (décision) i.e. le pousser en production, accepter sa livraison, etc. La vérification n'est pas une fin en soit, mais un moyen d'apporter des garanties de fiabilité. Elle va donc dépendre des exigences relatives au projet, qui sont fonctions, notamment, de sa criticité.
Ainsi, un exercice de TP n'a pas besoin d'apporter le même niveau de garanti qu'un programme utilisé dans une centrale nucléaire. Les validations, vérifications, tests, mais surtout le temps et les moyens investis dedans, ne seront pas les mêmes dans ces deux contextes. Il convient ainsi d’employer ces outils de façon appropriée et proportionnée au contexte du projet.
Il est important de détecter les problèmes potentiels au plus tôt, de sorte à pouvoir les corriger tant que cela est peu coûteux, avant qu'il ne soit trop intriqué dans le système. Pensez à une maison en construction, il est bien moins coûteux d'en refaire immédiatement les fondations, que de les refaire une fois le toit posé. Ainsi les mauvais choix présent ont un coût futur, c'est ce qu'on appelle la dette technique.
Il est ainsi important, au début d'un projet, de prendre le temps de définir les procédures de validations, ainsi que d'installer l'environnement de test. Il est ensuite important de suivre ces procédures avec rigueur et constance, et d'investir le temps de (re-)faire les choses correctement (e.g. refactors), afin de ne pas en perdre encore plus par la suite. Cela est difficile lorsque le temps est contraint, mais reste nécessaire.
⚠ Les tests ne permettent pas de prouver l'absence d'erreurs, mais d'en détecter la présence. Les analyses peuvent prouver l'absence de certaines erreurs, sous l'hypothèse du modèle utilisé.
Les types de validations
Les validations regroupent un ensemble très diversifié d'éléments qu'on peut découper en plusieurs catégories :
- type :
- fonctionnel : comportement du programme.
- qualité : performances, maintenabilité, etc.
- mode :
- static : sans exécution.
- automatique : exécuté sans intervention humaine.
- manuel : exécuté avec intervention humaine.
| Fonctionnel | Qualité | |
|---|---|---|
| Static |
- preuves formelles. - analyse de modèles. |
- vérification de la syntaxe. - vérification des types. - preuves formelles (e.g. sécurité). - métriques de code (e.g. couplage). |
| Automatique |
- tests unitaires. - tests d'intégrations. - tests de non-regression. - tests de reproduction de bug. |
- mesures des performances. - tests de compatibilité. |
| Manuel |
- recette fonctionnelle. - tests exploratoires. |
- recette utilisateur. - tests de terrain. - test d'accessibilité. - test de conformité / légalité. |
Les validations statiques sont usuellement effectuées, soit lors de l'édition (e.g. linters, type checkers, etc), soit lors de la (pré-)compilation du code. Des analyses plus poussées peuvent être effectuées en représentant le code sous la forme d'un graphe, et en calculant diverses métriques ou preuves mathématiques dessus.
Les validations automatiques exécutent et vérifient le code sans requérir d'intervention humaine. Les tests unitaires visent à tester le comportement propre d'une fonction, i.e. indépendamment de ses dépendances, quand les tests d'intégrations visent à s'assurer du comportement global d'une fonction, i.e. avec ses dépendances. Les tests de reproduction de bug et de non-régression, sont, dans la forme, identiques aux tests unitaires et d'intégration.
Les tests de reproduction de bug permettent de reproduire un bug connu afin de pouvoir le déboguer, et d'en valider la résolution. Ils deviennent ensuite des tests de non-regression, dont l'objectif est de détecter les regressions du projet, e.g. ré-introduction d'un bug déjà corrigé, altération du comportement d'une fonctionnalité existante, etc.
Les validations manuelles nécessitent usuellement une intervention humaine, bien qu'elles puissent être automatisée en partie. Les tests de recettes consistent à valider les scénario des cas d'utilisations. Ils peuvent être fonctionnel, i.e. est-ce que le scénario s'exécute correctement, ou utilisateur, i.e. est-ce que le scénario et l'interface lui conviennent.
Dans le cadre de ce module, nous nous intéresserons aux tests fonctionnels, et plus particulièrement à ceux indiqués en gras dans le tableau ci-dessus (unitaires, intégration, reproduction de bug, non-régression, recette fonctionnelle, tests de terrains).
Comment tester ?
=> on ne peut pas tout tester + tests unitaire/intégration vs fonctionnel/system => si je fais ça puis ci, puis ça => combinatoire. => transitions entre états = meilleur. => tester ce qui peut échouer. => tester trivial = inutile. => tests indépendant, précédent ne doit pas influencer suivant. => clean state => arrange (before) / act (do) / assert (verify) / teardown (after). => pannel VSCode => outils dédié : lib + VSCode (pytest)Tests de non-regressions
- état précédente vs état actuel. -> éviter revenir en arrière lors ajout feature ou correction bug. -> refactor + important (forte chance de tout casser temporairement). -> besoin de détecter. -> revenir en arrière si trop de problèmes (Git). - snapshots => première fois enregistrer puis stocker. - peut être capture d'écran. Cas particuliers - repro bug: minimal -> puis devient non-regression.Tests de recettes fonctionnelles
automatisation / simuer user action => puppeteer-like (instrumentalisation) => pas toujours possible => Web : DOM, autres, peut pas sélectionner.Tests de terrain
Les tests de terrain correspondent à une utilisation en situation réelle du projet. Usuellement, on commence avec un public restreint (e.g. alpha test en interne), volontaire (e.g. bêta test publique), ou progressif (e.g. déploiement progressif). Le but étant d'avoir des retours utilisateurs sur les potentiels problèmes et améliorations possibles avant de le déployer sur l'ensemble.
On n'est pas sur l'ordinateur utilisateur ou sur le serveur lors de l'exécution => besoin de l'observabilité, pour comprendre ce qu'il se passe et pouvoir agir.
Pour cela il est nécessaire d'avoir des mécanismes de monitoring afin de détecter les problèmes rapidement (e.g. suivi de la charge serveur, cache miss, charge SQL, etc.), de logging (API - INPUT/OUTPUT si bien conçu - sans état = reproduire erreur / message d'erreur, stack, etc) afin de pouvoir comprendre ce qu'il s'est passé. Tracing (= ID) = suivre une requête ou une transaction.
Détecter les problème au plus tôt = plus facile à identifier.
=> prog par contrat / guards / asserts.
=> peut être désactivé (généralement debug/dev vs prod).
=> cas d'erreurs en premier (évite imbrications illisibles).
=> Exception vs code d'Erreur
=> Error = expected, exception exceptionnel.
=> code de retour : on peut oublier de vérifier, mais si strong type + expected, forte change qu'on vérifie.
=> verif inputs (e.g. formulaire) => regex.
=> éviter les if/else imbriqués.
Écrire du code fiable et testable
Il ne suffit pas de tester son code, il faut aussi l'écrire de sorte à ce qu'il minimise la probabilité de bugs et soit facilement testable. Il doit être ainsi facile à maintenir, i.e. facile à corriger, modifier, et étendre tout en réduisant la dette technique, et minimisant l'introduction de nouvelles erreurs.
En effet, cracher du code à toute vitesse, le nez dans le guidon, peut donner l'impression d'avancer rapidement. Cependant, vous produirez tout autant de dettes techniques, qui vous feront reculer d'autant plus. Au long terme, vous pourrez alors avoir une productivité nulle, voire négative. Il est ainsi bien plus efficace de prendre le temps de faire mieux. Ce qui ne signifie pas faire parfaitement dès le début.
Dans les sections suivantes, nous verrons comment écrire du code fiable et maintenable. Nous approfondirons la question de la qualité logicielle dans le cours R5-06-VCOD Développement Logiciel.
Le TDD
Le TDD (Test Driven Development) consiste à écrire les tests avant d'écrire le code. On écrit alors un test de sorte à ce qu'il échoue, puis on modifie le code de sorte à ce qu'il passe le test, sans anticiper les autres tests futurs, et on recommence. Cela a plusieurs avantages :
- le code écrit est testable et testé.
- les interfaces sont conçues à partir de leur usage.
- les tests documentent l'utilisation du code.
- les modifications sont simples, sans régressions.
Par exemple, si on veut écrire une fonction qui trie une liste, on écrit d'abord le test pour le cas nominal, puis le code pour le passer. On écrira ensuite un test pour un cas particulier/limite, e.g. trier une liste vide :
Lorsqu'on développe, il est fréquent de vérifier ses résultats en les affichant (e.g. avec ou ). Avec TDD, le fait d'écrire les tests permet de pouvoir les re-exécuter, prévenant ainsi les régressions. Plus le projet est grand, plus ces tests deviennent important, car tester "à la main" en affichant les résultats montre alors très vite ses limites. Ces tests documentent aussi les choix de conceptions fonctionnelles effectués, i.e. comment le code doit se comporter.
Lorsqu'on développe, il est difficile de prendre en considération tous les cas d'usages, cas limites, cas d'erreurs, etc. Le TDD permet de rester simple et d'avancer par itérations successives, permettant bien souvent de conserver un code simple, plutôt que de partir directement sur un code compliqué essayant de tout faire en même temps. Il évite aussi d'anticiper d'hypothétiques besoins futurs, ou de gérer des cas d'erreurs pas forcément nécessaires à supporter.
⚠ Le TDD n'empêche pas d'avoir des étapes de conceptions, d'autant plus sur des systèmes potentiellement complexes. Le tout est de ne pas anticiper les besoins. Pour cela, on peut avoir un développement "top-down", i.e. partir du haut niveau (généralement l'interface graphique), et developer les fonctionnalités bas niveau dont on a besoin quand on en a besoin. Cela permet aussi d'avoir très rapidement quelque chose à montrer et sur lequel échanger. Attention cependant, l'interface doit être à un état de prototype, i.e. la mise en forme finale devra se faire une fois le logiciel stabilisé.
Le TDD partant de l'écriture des tests, on part donc de l'usage. Ainsi, en sus de documenter l'usage (i.e. comment on utilise le code), cela permet de détecter rapidement si le code est peu ergonomique ou inconfortable à utiliser. Permettant ainsi de corriger le problème avant que ce code ne soit utilisé de part et d'autres du projet, rendant toutes modifications bien plus coûteuse. La présence des tests permet également d'effectuer des opérations de refactoring, tout en s'assurant de l'absence de regressions.
Contrairement à ce qu'on pourrait croire, le TDD n'induit pas de coûts supplémentaires à court terme. Vous allez de toute manière tester votre code après l'avoir écrit, sauf qu'au lieu de prendre du temps après avoir écrit le code pour afficher et vérifier les résultats, vous prendrez du temps avant pour écrire les tests, constituant aussi une étape de conception. Bien évidemment, à moyen et long terme, le TDD devient d'autant plus intéressant grâce à la protection contre les régressions, la qualité du code, et la documentation des comportements.
Fonctions pures à responsabilité unique
Idéalement, on veut des fonctions pures à responsabilité unique afin de faciliter nos tests. Les fonctions pures sont des fonctions déterministes sans effets de bord.
Déterminisme
Une fonction déterministe est une fonction qui, pour une entrée donnée, produit toujours le même résultat. Une fonction non-déterministe n'aura pas forcément le même comportement d'une exécution à l'autre, ce qui compromet la fiabilité des tests, et complexifie la reproduction de bugs (e.g. bug à très faible probabilité d'occurrence).
Le non-déterminisme d'une fonction peut provenir de la lecture d'une donnée externe non-constante dans son code. Cette donnée peut alors être vue comme un paramètre caché de la fonction, qui sera hérité en cascade par les fonctions qui l'appellent (directement ou indirectement). Il est ainsi préférable de passer une telle donnée en paramètre, explicitant la dépendance, et permettant de conserver une fonction pure.
💡 Une méthode est une fonction spéciale dont le premier paramètre est l'objet sur laquelle elles ont été appelée. Cela n'est ainsi pas considéré comme étant un effet de bord :
⚠ En cas d'exécutions concurrentes, certaines données peuvent se retrouver modifiées lors de leurs traitements. Par exemple, pendant le parcours d'une liste, sa modification par l'extérieur peut provoquer des erreurs, e.g. élément non-parcouru ou parcouru 2 fois. On préfère ainsi, autant que possible, travailler sur des données constantes (ou des copies).
Pour des fonctions intégrant de l'aléa, il est d'usage de prévoir la possibilité de fournir une graine (seed), afin de la rendre déterministe pour les besoins des tests et des reproductions.
Effets de bords
Un effet de bord est le fait d'interagir avec l'extérieur, e.g. écrire dans une variable globale. On peut voir les effets de bord comme un retour caché de la fonction, qui sera hérité en cascade par les fonctions qui l'appellent (directement ou indirectement). On préfère ainsi éviter les effets de bords, sauf pour les fonctions d'I/O, ou de logs, qu'on isolera autant que possible.
💡 Un paramètre mutable est à la fois une donnée d'entrée (lecture), mais aussi de sortie (écriture). Marquer les données d'entrées comme immuables permet d'éviter (et de détecter) les effets de bords involontaires. Cela permet aussi de pouvoir appeler la fonction avec des données immuables :
La responsabilité unique (SRP)
La responsabilité unique signifie qu'une fonction/classe ne doit faire qu'une chose. Cela rend son écriture plus aisée, mais aussi plus facile à tester. Imaginons une fonction qui, à la fois, extrait des données (avec N configurations possibles), et les affiche (avec M configurations possibles). Cela signifie que la fonction aura M*N configurations à potentiellement tester. La complexité de la fonction est ainsi exponentielle à son nombre de responsabilités, faisant qu'il n'est pas toujours possible de tester toutes les combinaisons possibles.
En revanche, si on sépare les responsabilités dans 2 fonctions distinctes, qu'on pourra tester individuellement, il n'y aura alors plus que M+N configurations à tester. De plus, cela permet de créer d'autres fonctions d'extractions et d'affichages, que l'on pourra combiner en fonction des besoins, au lieu d'avoir à modifier une fonction unique qui se complexifiera au fur et à mesure que des fonctionnalités lui seront ajoutées, avec de potentielles interactions fortuites entre les différents paramètres.
Une des conséquences du SRP sur les classes, est que son interface doit être minimale, i.e. uniquement contenir les primitives permettant de manipuler les données internes et d'en garantir la cohérence. Les fonctions de convenances (helpers) manipulent la classe à travers ses primitives. Elles doivent être externes à la classe, afin d'empêcher qu'elles accèdent directement à l'état interne. Cela permet ainsi de :
- réduire la surface de l'API, et ainsi le périmètre des tests.
- pouvoir réimplementer l'interface plus facilement, car minimale.
- réutiliser les fonctions de convenances pour d'autres objets possédant une interface compatible.
- pouvoir ajouter/retirer des fonctions de convenances sans modifier l'API de la classe.
Il convient aussi de privilégier, lorsque possible, les fonctions au méthodes protégées ou privées. Cela permet de séparer les accès à l'état interne, des calculs qui peuvent alors être testés séparément (et potentiellement réutilisés). En revanche, cela empêche de redéfinir le comportement de la fonction dans les classes filles.
À éviter:
À privilégier:
Dans le cas d'états internes complexes, on privilégiera l'utilisation de classes internes afin d'encapsuler des ensembles cohérents de sous états, ainsi que leur accès. Permettant, encore une fois, de les tester séparément et d'éventuellement les réutiliser. Cela permet de réduire la surface d'accès à ce sous état et ainsi de mieux en garantir la cohérence. Cela permet aussi de pouvoir facilement la remplacer par une implémentation alternative.
À éviter:
À privilégier:
Permettre l'exécution des tests
Dans les tests unitaires, on souhaite tester le comportement propre des fonctions, c'est à dire indépendamment de leur dépendances. Ainsi, on souhaite appeler les fonctions, avec un minimum de dépendances.
Inversion de contrôle
Imaginons un composant utilisant des valeurs issues d'un gestionnaire de paramètres/configurations. Si ce composant récupère de lui-même ces valeurs, alors il aura besoin d'un gestionnaire pour s'exécuter. Or un composant n'a pas à connaître l'origine des données qu'il manipule. Sa responsabilité est de traiter les données, pas de les récupérer. Le lien entre le composant et le gestionnaire doit donc être établi en dehors de la classe (glue code).
L'Inversion de Contrôle (IoC), évite cette dépendance superflue, permettant d'exécuter et de tester le composant sans le gestionnaire, e.g. avec d'autres sources de données. Lors d'opérations de refactor, certaines parties du code peuvent temporairement ne plus fonctionner. Or quand un système a de nombreuses dépendances de ce genre, l'ensemble du système est impacté, empêchant d'exécuter (et donc de tester) les différentes parties du code, tant que le refactor n'est pas terminé.
Doubles de test
Lorsqu'on test une fonction, on veut généralement en tester le comportement spécifique, pas le comportement de ses dépendances. Pour cela, on utilise des doubles de tests (test doubles), qui peuvent être de deux natures différentes :
- Stub : simule la dépendance.
- Espion : vérifie les accès à la dep.
- Mock : stub + spy
Les stubs sont utilisés lorsque :
- on veut éviter qu'un bug dans la dépendance fasse échouer le test.
- la dépendance est peu adaptée aux tests, e.g. non-déterministe, lente, etc.
- la dépendance n'est pas encore implémentée, e.g. en TDD.
Les espions (spy) sont utilisés pour vérifier que la dépendance est correctement utilisée. Par exemple, une classe peut être responsable de recevoir des tâches afin d'en différer l'exécution . Ainsi, on souhaite vérifier que est exécuté au bon moment, avec les bons arguments. Pour cela, on va créer un proxy encapsulant une vraie tâche, et dont la méthode contiendra l'assertion du test.
Les mocks, quant à eux, correspondent à une fusion entre un stub et un espion, i.e. simule la dépendance et en vérifie l'usage. Cela est utilisé lorsqu'on souhaite vérifier l'usage de la dépendance, mais que cette dernière est peu adaptée aux tests, e.g. lent.
💡 Il est possible d'automatiser la création des doubles de tests par le biais d'une fonction factory ou d'un objet builder.
Interfaces graphiques
Les résultats graphiques, e.g. images, fenêtre, etc. sont difficiles à tester sans une vérification humaine. On peut cependant effectuer des tests de non-régression en effectuant une capture de l'interface, puis en la comparant avec une capture précédente, pixels par pixels. Cela indique un changement, sans savoir s'il est volontaire ou la manifestation d'un bug. Il est alors possible d'afficher les deux captures avec leurs différences mises en valeur. Il est aussi possible d'inclure un score de similarité afin d'identifier rapidement les plus gros changements.
Cette méthode reste relativement peu fiable, l'image générée pouvant changer du fait d'un changement :
- de la configuration de l'ordinateur, e.g. thème, police, résolution écran.
- de la structure de l'interface, e.g. l'ajout d'un élément, son déplacement, etc.
- des données affichées.
- de temps, e.g. animations.
💡 Légèrement plus robuste, il est possible de comparer la structure de l'interface graphique au lieu de son rendu, e.g. le DOM en développement Web.
Pour ce genre d'objets difficiles à tester, on souhaite séparer les comportements testables, des comportements réellement difficiles à tester. Pour cela on veut que l'objet devienne humble, i.e. soit réduit à son strict minimum, et délégue au maximum ses opérations à un contrôleur, qui sera plus facile à tester. Pour une interface graphique, on parlera de vue passive (passive view).
Par exemple, cette vue passive aura pour rôle de convertir les événements bruts reçus (e.g. clic souris) en une action (e.g. ) envoyée au contrôleur. Le contrôleur enverra alors les données à afficher (e.g. ). Il est ainsi aisé d'instrumentaliser le contrôleur par le biais de ses méthodes, et d'en observer l'état.
💡 Cela facilite aussi les tests fonctionnels. En effet, au lieu de tester le résultat d'une série d'action, on peut alors préparer un état précis et en vérifier le rendu.
Réduire le nombre de cas
Une manière de faciliter les tests et de réduire le nombre de cas à tester. Par exemple, une mise à jour peut être :
- Partielle : rapide, plusieurs opérations possibles.
- Complète : plus coûteuse, une seule opération.
Les mises à jours partielles ont beau être plus "optimisées", elles sont bien souvent inutiles au regard des besoins réels en performances. Or elles introduisent plusieurs niveaux de complexité :
- il y aura N opérations à tester, pour un état possédant N propriétés indépendantes (une par propriété).
- on introduit une notion d'état antérieur, avec des bugs pouvant dépendre des mises à jours passées et de leur ordre.
- on peut risquer des désynchronisations, i.e. une mise à jour partielle ayant mal été appliquée.
Il est ainsi préférable de n'avoir qu'un seul cas à implémenter et à tester, même si cela est moins "optimisé". Se ramener à un cas unique réduit le nombre de possibilités, la complexité, le risque d'erreur, et aussi de tests nécessaires.
Cela implique aussi de réutiliser au maximum l'existant, pour n'avoir qu'une seule chose à tester, au lieu de devoir (re-)tester tous les doublons. De plus, lorsqu'un bug est détecté, il faudra le corriger dans tous les doublons, au risque d'en oublier un. L'existant, quant à lui, a déjà été testé et éprouvé, il sera donc a priori plus fiable qu'un code nouvellement (re-)écrit.
Pour favoriser cette réutilisation, il convient aussi de distinguer la structure des données, de leur sémantique et utilisation. Par exemple, en fonction du contexte, une classe peut être une simple liste , un peut être un simple dictionnaire, etc. quitte à utiliser des alias de type. Ainsi :
- pas de classe à développer, moins de risques d'erreurs.
- réutilisation des fonctions génériques existantes.
- fonctions codées peuvent ensuite être réutilisées dans un contexte plus large.
- interface standard, donc plus prévisible.
💡 Vous pouvez aussi encapsuler une structure existante en vu de n'en exposer qu'une sous partie. Par exemple, une pile peut être une simple liste dont on ne permet que l'ajout et la suppression en tête. Cela ne nécessitera pas de tests supplémentaires si ses méthodes se contentent de rediriger leurs appels :
💡 Les données (et leur structure) n'est pas là pour représenter le réel, mais pour permettre le stockage et le traitement d'informations en vu de produire un résultat. Il ne faut pas ainsi se laisser limiter par une volonté de réalisme excessive.
⚠ Réduire le nombre de cas ne signifie pas concevoir une fonction générique qui se contente d'en masquer la diversité, soit via des configurations en entrée, soit en les discriminant elle-même en interne :
À éviter
À éviter
⚠ Aussi, une fonction générique doit être stable, i.e. ne pas devoir être adaptée à chaque nouvelle utilisation, au risque de potentielles régressions.
Reproductibilité
Motivation
Process e.g. build/installation (dont deps), etc. => scripts, reproduit aisément, mais, pas forcément fonctionnel, e.g. spécificité distribution, incompatibilités de versions de deps dans dépôt (trop récente/ancienne) ou conflits d'usages.
Marche sur ma Machines = différences => testé sur nos machines, pas forcément reproduit sur autres machines / environnement de production.
Conteneur, pour une uniformisation de l'environnement, marche ici, marche chez les autres.
Reproduire/simuler env. target(s) ou distribuer conteneur.
Machine virtuelle (1A AdmSys), simule jusqu'au matériel.
- sécurité (e.g. untrusted ou unsafe/dangerous).
- GUI mais aussi CLI manipulation, possibilité d'installer de manière automatisée (cf mon git) - créer sa propre iso = pas super pratique + lent.
Docker favorisé car plus léger/rapide/pratiques, pas besoin d'émuler le matériel, besoin secu moindre.