Le sombre état des bibliothèques de tests property-based
(stevana.github.io)- Le property-based testing s’est diffusé dans plusieurs langages après QuickCheck, mais en juillet 2024, de nombreuses bibliothèques ne fournissent toujours pas correctement les tests stateful et les tests parallèles déjà formalisés en 2009
- L’écart principal tient à la vérification des changements d’état séquentiels via un modèle de machine à états, puis à la réutilisation de ce même modèle pour des vérifications de linearisability afin de détecter les race conditions en exécution parallèle
- Parmi les bibliothèques étudiées, beaucoup n’ont pas de tests stateful ou seulement un support expérimental, et les tests parallèles sont encore plus rares : des issues liées à ce sujet restent ouvertes depuis des années dans FsCheck, Gopter, RapidCheck, SwiftCheck, jsverify, etc.
- Une implémentation Haskell d’environ 400 lignes reproduit le property-based testing stateful et parallèle, et utilise comme modèle une implémentation de référence basée sur un fake, plus familière aux développeurs qu’une spécification traditionnelle par machine à états
- Un fake testé par contrat peut être réutilisé au-delà de la validation d’un composant unique, notamment dans des tests d’intégration rapides et déterministes où il remplace des dépendances réelles par injection
Le fossé fonctionnel apparu après QuickCheck
- Le property-based testing s’est propagé dans plusieurs communautés de programmation sous le slogan « n’écrivez pas des tests, générez-les »
- La page Wikipédia de QuickCheck, la bibliothèque Haskell d’origine, recense 57 réimplémentations dans d’autres langages
- Le premier article sur QuickCheck, QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs, a été présenté à l’ICFP 2000, et le code source complet de la première implémentation tenait dans environ 300 lignes en annexe de l’article
- Le QuickCheck initial ne pouvait tester que des fonctions pures, puis Testing monadic code with QuickCheck (2002) a posé les bases pour traiter du code à effets comme l’état mutable, les E/S fichier ou le réseau
L’apparition des tests stateful et parallèles
- Quviq AB a été fondée en 2006 par John Hughes et Thomas Arts, avec comme cas d’usage initial les tests du projet Erlang chez Ericsson
- Erlang n’est pas un langage purement fonctionnel et la concurrence y est courante, ce qui rendait le QuickCheck monadique existant insuffisamment pratique à lui seul
- Le QuickCheck Erlang propriétaire de Quviq incluait ensuite deux fonctionnalités absentes de nombreuses implémentations open source
- le property-based testing stateful séquentiel à l’aide de modèles de machines à états
- des tests parallèles réutilisant ce même modèle de machine à états séquentiel pour détecter des race conditions
- Les tests stateful apparaissent sous leur forme actuelle dans QuickCheck testing for fun and profit (2007)
- Les tests parallèles sont détaillés dans Finding Race Conditions in Erlang with QuickCheck and PULSE (ICFP 2009), qui s’appuie comme technique centrale sur Linearizability: a correctness condition for concurrent objects (1990) de Herlihy et Wing
- Le code de la bibliothèque Quviq QuickCheck n’a pas été partagé dans les articles ; seuls l’API et des exemples de tests utilisant cette API étaient publics
Résultats de l’étude des bibliothèques en 2024
- L’état de l’art actuel repose sur les tests stateful fondés sur des modèles de machines à états, et sur les tests parallèles combinant ce même modèle séquentiel avec la linearisability
- L’étude synthétise des lectures de documentation, d’issue trackers et d’une partie du code source, arrêtées à juillet 2024
- De nombreuses bibliothèques ne proposent pas de tests stateful, ou seulement de manière limitée
- QuickCheck (Haskell) a une issue ouverte depuis 2016 sur l’ajout des tests stateful
- SwiftCheck a également une issue ouverte depuis 2016 sur l’ajout des tests stateful
- jsverify conserve depuis 2015 une issue sur l’ajout des tests stateful
- proptest (Rust) renvoie vers un projet séparé, proptest-state-machine
- Le support des tests parallèles est encore plus rare
- Gopter indique dans son README « No parallel commands … yet? » et a une issue ouverte depuis 2017
- FsCheck a une issue ouverte depuis 2016 sur l’ajout du support parallèle
- RapidCheck a une issue ouverte depuis 2015 sur l’ajout du support parallèle
- propcheck a une issue ouverte depuis 2020 sur l’ajout des tests parallèles
- Parmi les exemples open source prenant en charge les deux fonctionnalités figurent PropEr, Hedgehog, qcheck-stm, quickcheck-state-machine et stateful-check
- Même lorsqu’une fonction parallèle existe, elle peut être limitée
- Dans QuickTheories, un commentaire du code source indique que les tests parallèles doivent en général limiter la command list à 10 éléments ou moins, car le nombre d’end states possibles augmente rapidement avec le nombre de commandes
- Les exemples LevelDB et Redis de ScalaCheck sont présentés comme des exemples séquentiels avec
threadCount = 1 - Le support des race conditions dans fast-check ne semble pas, contrairement aux tests parallèles de Quviq QuickCheck, réutiliser un modèle de machine à états séquentiel ni employer la linearisability
- Il n’existe pas d’exemple clair où les tests parallèles auraient été ajoutés plus tard sans difficulté ; s’ils ne sont pas prévus dès la conception de l’API, ils peuvent exiger une refonte importante
Pourquoi la diffusion de ces fonctionnalités a été lente
- John Hughes avance trois explications
- les tests stateful et parallèles sont moins utiles que les tests de fonctions pures
- écrire des modèles de machines à états demande une manière de penser différente des tests classiques et nécessite de la formation
- l’open source seul favorisait mal l’adoption industrielle, tandis qu’un produit closed source accompagné de formation et de conseil l’a facilitée
- Tester uniquement des fragments de fonctions pures avec le property-based testing apporte déjà beaucoup, mais les systèmes industriels contiennent aussi des bases de données, des protocoles stateful et des structures de données concurrentes ; les tests stateful et parallèles sont donc presque aussi importants
- Une spécification stateful n’est pas toujours plus difficile qu’une spécification de fonction pure
- un modèle de magasin clé-valeur peut déjà aller très loin avec une simple liste de paires clé-valeur
- dans le cas de LevelDB, un modèle simple a trouvé en quelques minutes un contre-exemple réduit de 17 étapes, puis après un correctif de Google un autre contre-exemple de 31 étapes en quelques minutes encore
- le second problème venait d’un bug dans le background compaction process ; la compaction est importante pour améliorer les performances de lecture et récupérer de l’espace disque, mais elle n’était pas explicitement incluse dans le modèle
- Le closed source a peut-être aidé l’adoption dans l’industrie, mais n’a pas aidé l’adoption open source selon cette analyse
- Reproduire les résultats des articles sans licence Quviq QuickCheck demanderait tellement de reverse engineering que cela serait presque impossible
Proposition : une implémentation petite, ouverte et des spécifications plus simples
- Deux pistes d’amélioration sont proposées
- fournir une implémentation open source courte du property-based testing stateful et parallèle, à l’image des quelque 300 lignes du QuickCheck d’origine
- réduire la charge d’écriture des spécifications en réutilisant, à la place des machines à états, des concepts déjà familiers aux développeurs comme les mocks et les test doubles
- Pour vérifier cette hypothèse, deux démonstrations sont proposées
- une implémentation du property-based testing stateful et parallèle en environ 400 lignes de code
- l’utilisation, à la place d’une state machine, d’une implémentation de référence en mémoire, autrement dit un fake, comme modèle
Résumé des tests purement property-based
- Dans les tests de fonctions pures, on génère des entrées et on vérifie que la sortie de la fonction satisfait une certaine relation avec l’entrée
- Par exemple,
reversepeut être testé avec la propriétéreverse (reverse xs) == xspour une liste arbitrairexs - QuickCheck génère par défaut 100 tests et, en cas d’échec, réduit l’entrée pour présenter le plus petit contre-exemple possible
- Une propriété erronée comme
reverse xs == xsest réduite à un contre-exemple minimal comme[0,1] - Les motifs de propriétés les plus fréquents sont l’inverse, l’idempotence, l’associativité, les axiomes des types de données abstraits, les propriétés métamorphiques, etc.
- inverse :
deserialise (serialise i) == i - idempotence :
sort (sort xs) == sort xs - associativité :
(i + j) + k == i + (j + k)
- inverse :
Tests property-based à état
- Les composants avec état ne produisent pas toujours la même sortie pour la même entrée
- le résultat du premier
incrd’un compteur et celui du deuxièmeincrdépendent de l’état précédent - les bases de données et les systèmes de fichiers sont eux aussi influencés, pour leurs sorties futures, par l’historique des entrées précédentes
- le résultat du premier
- Si les tests de fonctions pures manipulent une entrée unique, les tests à état génèrent des séquences d’entrées pour vérifier comment le système évolue dans le temps
- Le modèle est représenté par un faux composant de forme
m -> i -> (m, o)- à partir de l’état de modèle précédent
met de l’entréei, on calcule le modèle suivant et la sortieo - on compare à chaque étape la sortie du système réel à celle du faux composant
- en cas d’écart, on réduit la séquence d’entrées pour trouver un petit contre-exemple
- à partir de l’état de modèle précédent
-
Exemple de compteur
- on prend comme cible de test un compteur Haskell utilisant une variable mutable globale
incrincrémente le compteur etgetlit la valeur actuelle- un seul
Counter Intsuffit comme modèle, et l’instanceStateModeldéfinit l’état initialCounter 0,Incr,Get,Incr_ (),Get_ Int,runFake,runRealet le générateur de commandes - si l’on introduit un bug comme
incr42Bug, où le compteur n’augmente pas quand sa valeur est 42, QuickCheck trouve un échec après 66 tests, effectue 29 réductions et présente comme contre-exemple minimal unGetaprès 43 incréments - si l’on ne fait pas
resetdu compteur global réel entre les tests, le modèle démarre toujours à 0 mais le compteur réel conserve l’état du test précédent, ce qui provoque un décalage
-
Interface des bibliothèques à état
- l’interface
StateModelconsidère le système sous test comme une black box, avec les commandes en entrée et les réponses en sortie - ses éléments centraux sont
Command state,Response state,initialState,runFake,runReal,generateCommand - les éléments optionnels sont les suivants
Reference: utilisé quand une ressource créée par une réponse précédente, comme un file handle, est référencée dans une commande ultérieurePreconditionFailure: représente un échec de précondition, par exemple lorsqu’on empêche une lecture depuis un handle qui ne correspond pas à un fichier ouvertCommandMonad:IOpar défaut, mais d’autres monades peuvent être utiliséesmonitoring,commandName: utilisés pour la couverture et les statistiques
- lors de la génération des commandes, on ne peut pas créer de vraies valeurs comme un file handle réel ; on génère donc des références symboliques de forme
Var Int, remplacées par les références réelles pendant l’exécution - après réduction, on supprime les commandes qui violent les préconditions ou utilisent des références symboliques hors de leur portée
- l’interface
-
Exemple de buffer circulaire
- on teste via la FFI Haskell une file circulaire écrite en C, avec comme modèle une simple file basée sur une liste
- l’implémentation C ne fait pas de vérification d’erreur ; appeler
getsur une file vide peut donc renvoyer une zone mémoire non initialisée - l’implémentation réelle est efficace grâce à des index circulaires, mais n’est pas manifestement correcte, tandis que le faux modèle est moins efficace mais cela n’a pas d’importance pour les tests
- comme
newrenvoie une référence de file, le modèle gère plusieurs files viaMap (Var Queue) FQueue - au départ, la précondition interdisant
putsur une file pleine manquait ; quand on mettait0, puis1dans une file de taille 1 avant d’appelerget, le modèle attendait0selon l’ordre FIFO, mais le code C renvoyait1 - ce n’était pas un bug d’implémentation mais un oubli de précondition dans le modèle, corrigé en ajoutant la précondition
QueueIsFull - l’absence de la commande
Sizedans le générateur a été révélée par la sortie de couverture ; une fois ajoutée, elle a permis de découvrir un bug dans le calcul de la taille de la file - si l’on met un élément dans une file de taille 1 puis qu’on appelle
Size, la valeur attendue est 1 mais la valeur réelle est 0 ; une correction proposée consiste à définir la taille du buffer interne àn + 1dansnew - ensuite,
abs(q->inp - q->outp) % q->sizepasse pour la taille 1 mais échoue à nouveau pour la taille 2, et la correction finale est(q->inp - q->outp + q->size) % q->size
-
L’énigme des cruches de Die Hard 3
- l’énigme consistant à obtenir exactement 4 L avec des cruches de 3 L et 5 L est résolue avec des tests à état
- même sans implémentation réelle, exécuter uniquement le modèle et le faux composant permet de faire échouer le test quand un état donné est atteint, afin d’obtenir une séquence d’actions réduite
- après 199 tests et 11 réductions, la séquence proposée suit le déroulé suivant
- remplir la cruche de 5 L
- verser de la cruche de 5 L dans celle de 3 L
- vider la cruche de 3 L
- verser à nouveau de la cruche de 5 L dans celle de 3 L
- remplir la cruche de 5 L
- verser de la cruche de 5 L dans celle de 3 L
- la trace affiche les états intermédiaires, ce qui permet de vérifier comment la grande cruche atteint 4 L
Tests property-based en parallèle
- les bugs du code concurrent sont difficiles à reproduire et à valider lors du correctif, car l’interleaving des threads change à chaque exécution
- l’objectif est de permettre aux utilisateurs d’effectuer des tests en parallèle, comme avec des tests séquentiels basés sur l’état, sans avoir à écrire beaucoup de code de test supplémentaire
- dans l’exemple du compteur, si
increffectuereadIORefpuiswriteIORefde manière non atomique, deux threads peuvent écraser mutuellement leur incrément, ce qui provoque une race condition - le test en parallèle collecte les instants d’invocation et de réponse des commandes pendant l’exécution pour construire un historique concurrent, puis vérifie si cet historique peut s’expliquer par un interleaving séquentiel
- si au moins un interleaving est compatible avec le modèle séquentiel, l’historique est considéré comme linéarisable et donc correct
- si aucun interleaving séquentiel ne permet d’expliquer les réponses observées, le résultat est traité comme non linéarisable
-
Génération et shrink des commandes parallèles
- un programme parallèle est représenté par
ParallelCommandset plusieursFork, les commandes à l’intérieur de chaqueForkétant exécutées en parallèle - l’implémentation d’exemple traite des exécutions mono-, bi- et tri-thread
- en exécution parallèle, comme avec
Fork [Write "a" "foo", Write "a" "bar"], les états possibles du modèle peuvent varier selon l’interleaving - le modèle parallèle effectue la génération et le shrink des commandes non pas à partir d’un état unique, mais d’un ensemble d’états
parallelSafevérifie que la précondition reste satisfaite pour toutes les permutations des commandes dans unFork- par exemple, si
Write "a"etDelete "a"se trouvent dans le même fork, une commande peut invalider la précondition de l’autre - lors du shrink, seules sont conservées les commandes qui préservent la précondition et la portée des références symboliques
- un programme parallèle est représenté par
-
Exécution parallèle et vérification de la linéarisabilité
- l’exécution parallèle enregistre les événements
InvokeetOkde chaque commande dans l’historique - si une réponse contient une nouvelle référence, l’environnement est étendu avec un compteur atomique afin d’éviter les collisions de numéros de référence entre threads
- tous les interleavings possibles de l’historique sont énumérés sous forme d’arbre
Rose linearisablevérifie si un chemin quelconque de cet arbre fait correspondre les réponses observées avec le modèle séquentielrunFake- comme les tests parallèles réutilisent finalement le modèle séquentiel, l’utilisateur obtient des tests parallèles avec peu de code supplémentaire après avoir écrit le modèle séquentiel
- l’exécution parallèle enregistre les événements
-
Exemple de compteur parallèle
- pour activer le test parallèle du compteur, le seul code supplémentaire ajouté est l’instance
ParallelModel Counteret la property - avec
incrRaceConditionnon atomique, une race condition est détectée - même si une race existe aussi dans un test case plus petit, si l’échec ne se reproduit pas à cause d’un interleaving différent, QuickCheck peut considérer que ce test plus petit passe et arrêter le shrink
- la bonne solution est d’utiliser un ordonnanceur de threads déterministe, ce qu’emploie l’article sur les tests parallèles
- l’implémentation d’exemple utilise à la place un workaround plus simple consistant à insérer un court
sleepautour des lectures/écritures en mémoire partagée afin d’augmenter la probabilité de reproduire le même interleaving - ce
sleepn’est pas nécessaire pour trouver la race, mais pour réduire le contre-exemple trouvé à une forme plus petite - après l’ajout du
sleep, le contre-exemple minimal se réduit àParallelCommands [Fork [Incr,Incr],Fork [Get]]
- pour activer le test parallèle du compteur, le seul code supplémentaire ajouté est l’instance
-
Exemple de registre de processus
- on utilise comme exemple un système, semblable au registre de processus d’Erlang, qui spawn des threads et enregistre, recherche, désenregistre et kill des
ThreadIdpar nom - le modèle séquentiel suit les identifiants de thread créés, les paires nom-thread enregistrées et les identifiants de thread killés
RegisteretUnregisterpeuvent échouer, la réponse utilise doncEither ErrorCall ()- dans l’implémentation réelle, les informations de localisation des erreurs sont supprimées via
abstractErrorpour correspondre au fake monitoringaffiche la couvertureRegisterFailed,RegisterSucceeded,UnregisterFailed,UnregisterSucceeded- si l’on introduit volontairement un bug où
registerécrase le registre existant, on obtient un contre-exemple séquentiel où il est impossible de désenregistrer"e"pourtant déjà enregistré - en test parallèle, un contre-exemple plus long apparaît et, avec
SleepyIORef, il est réduit à une forme commeFork [Register "b" (Var 0), Register "c" (Var 0)] - le problème est une race où un autre thread peut s’intercaler entre la vérification via
readRegistryet l’appel àatomicModifyIORef - après application d’un verrou global à
register,unregisteretkill, les tests parallèles passent
- on utilise comme exemple un système, semblable au registre de processus d’Erlang, qui spawn des threads et enregistre, recherche, désenregistre et kill des
Modèles basés sur des fake et tests d’intégration
- Au lieu d’une specification traditionnelle de machine à états avec post-conditions, on utilise un fake en mémoire comme implémentation de référence
- L’article de 2019 d’Edsko de Vries, article de 2019, est présenté comme le premier à proposer une implémentation de fake au-dessus d’une spécification de machine à états fondée sur les post-conditions
- Le fake est présenté comme une approche plus accessible pour les programmeurs peu familiers des spécifications formelles, un peu à la manière d’un mock
- Le fake a aussi l’avantage de pouvoir remplacer des composants dépendants dans les tests d’intégration
- Il n’est pas nécessaire de démarrer ou d’activer la vraie dependency
- Cela permet de construire des tests d’intégration plus rapides et déterministes
- Le fait qu’un fake puisse être incorrect est traité par des contract tests
- Comme les tests property-based parallèles et fondés sur l’état vérifient la correspondance entre le fake et l’implémentation réelle, le fake joue le rôle d’une dépendance testée par contrat
-
Séparer tests et déploiement avec un fake de queue
- L’interface de queue
IQueueexposeiNew,iPut,iGet,iSize - L’implémentation réelle connecte directement un wrapper de queue en C
- L’implémentation fake stocke l’état du modèle dans
IORefet le met à jour viafNew,fPut,fGet,fSize - Le composant est écrit contre l’interface
IQueue q - En test, on utilise une instance
fake, et en déploiement une instancereal - On pose l’hypothèse qu’avec des tests property-based fondés sur l’état, le fake est fidèle au real
- L’interface de queue
-
Fake de système de fichiers
- L’interface de système de fichiers
IFileSystem hexposeiMkDir,iOpen,iWrite,iClose,iRead - L’implémentation réelle utilise le vrai système de fichiers sous
/tmp/qc-test - Le fake est implémenté comme un
FakeFSen mémoire avec un ensemble de répertoires, une map du contenu des fichiers, une map des handles ouverts et le prochain handle fOpen,fWrite,fClose,fReadmodélisent les échecs de précondition comme un fichier occupé, un répertoire inexistant ou un handle fermé- Si le fake du système de fichiers est testé et jugé fidèle au vrai système de fichiers, les composants qui en dépendent peuvent être testés en intégration avec le fake puis remplacés en déploiement par le vrai système de fichiers
- Si un bug apparaît après remplacement par le réel, il faut examiner comment un décalage entre fake et real a pu passer les tests property-based fondés sur l’état
- L’interface de système de fichiers
-
Systèmes de composants plus vastes
- La même approche s’étend à des systèmes où A dépend de B et B dépend de C
- On définit une interface pour chaque composant
iC :: IO ICiB :: IC -> IO IBiA :: IB -> IO IA
- La stratégie de test est la suivante
- On valide C avec des tests property-based parallèles et fondés sur l’état afin d’obtenir un fake C testé par contrat
- Dans les tests d’intégration de B, on utilise le fake C
- Pour tester A, on utilise un fake B qui lui-même utilise le fake C
- Cette approche se généralise au même pattern pour davantage de composants ou de services
Conclusion
- Les tests property-based parallèles et fondés sur l’état peuvent être implémentés en environ 400 lignes de code, une taille comparable à la première implémentation de QuickCheck, qui comptait environ 300 lignes sans shrinking
- Utiliser un fake comme modèle donne aux tests parallèles et fondés sur l’état une forme de spécification plus familière, et permet de les réutiliser pour tester de plus grands systèmes de manière compositionnelle
- Si chaque communauté de langage poursuit ses expérimentations, il existe une marge d’amélioration pour l’état des bibliothèques de tests property-based
1 commentaires
Avis de Hacker News
Avec l’arrivée du fuzzing guidé par la couverture, et son bon support dans Go, je me demande ce qu’on rate si l’on n’utilise pas de bibliothèque de tests basés sur les propriétés
https://www.tedinski.com/2018/12/11/fuzzing-and-property-tes...
En regardant le test de fuzzing ci-dessous et la vérification d’invariant correspondante, j’ai l’impression que c’est en pratique presque la même chose qu’un test de propriété
https://github.com/ncruces/aa/blob/505cbbf94973042cc7af4d6be...
https://github.com/ncruces/aa/blob/505cbbf94973042cc7af4d6be...
Il existe de vraies différences, mais la frontière est assez floue, et il n’est pas si important de déterminer précisément ce qui relève du fuzzing ou des tests basés sur les propriétés
Les tests rapides avec des assertions détaillées relèvent des tests basés sur les propriétés ; ceux qui tournent longtemps et ne cherchent que des crashs relèvent du fuzzing ; entre les deux, c’est ambigu
https://hypothesis.works/articles/what-is-property-based-tes...
Quand j’étais chez Google, un outil interne qui combinait les deux était vraiment excellent. On écrivait un test basé sur les propriétés comme d’habitude, puis à l’exécution le framework de test compilait spécialement le code pour obtenir la couverture et ajustait les entrées aléatoires afin de l’augmenter. Bien sûr, tout cela tournait de façon entièrement automatique sur un cluster de plusieurs machines
Les tests basés sur les propriétés traditionnels sont généralement implémentés uniquement sous forme de bibliothèque, donc ils ne disposent pas forcément d’informations de couverture pour guider la génération des entrées aléatoires
Cela dit, selon la bibliothèque, on peut bénéficier de pas mal de fonctionnalités pratiques. L’une des choses utiles est le shrinking ; voir la section « Shrinking » ici : https://tech.fpcomplete.com/blog/quickcheck-hedgehog-validit...
Les combinateurs permettant de composer des générateurs sont aussi excellents, et certaines bibliothèques disposent d’un ensemble de valeurs « mauvaises » connues pour déclencher des comportements exceptionnels
J’aimerais prendre un peu de recul et poser une question plus méta sur les tests. Est-ce qu’un test réussi signifie que le code est correct, et inversement ? Y a-t-il dans le contrat de Go un passage indiquant que fournir la même entrée au même code produit la même sortie ?
En manipulant le type Arbitrary, qui représente un ensemble d’objets aléatoires, on peut facilement écrire des fonctions réutilisables pour générer des entrées de test. Ce genre de bibliothèque pourrait probablement être utilisé assez facilement avec le framework de fuzzing de Go
Cela dit, je trouve que les combinateurs classiques comme map, filter, chain ou oneOf peuvent être un peu maladroits, donc je travaille sur une nouvelle bibliothèque de tests de propriétés pour JavaScript. L’objectif est de la rendre plus agréable à utiliser, mais elle est encore expérimentale et pas encore publique
clojure.spec.alphaoffrait une excellente expérience, qu’on l’utilise ou non avectest.check, mais après avoir essayéhypothesisen Python, j’ai trouvé ça vraiment médiocreHypothesis semblait incapable, par conception, de gérer des jeux de données simples mais « gros », et ici « gros » ne veut en fait pas dire si gros que ça. [0] C’était tellement pénible que nous avons carrément retiré Hypothesis et les tests fondés sur la génération de la suite de tests Python au travail
[0] https://github.com/HypothesisWorks/hypothesis/issues/3493
Hypothesis essayait de réduire les entiers générés à 0 pour voir si le bug existait aussi à 0, et le test rejetait le cas, au lieu d’échouer, parce qu’il contenait 0. Sur de petits cas, cela se limitait à une inefficacité, mais sur de grands cas, c’en était au point qu’Hypothesis abandonnait
Dans ce fil, quelqu’un a suggéré d’utiliser une autre stratégie de génération d’instances, qui ne puisse pas produire de 0. Autrement dit, ne pas générer la valeur préférée du réducteur d’Hypothesis pour ensuite la rejeter, mais éviter de la produire dès le départ. Je me demande si cela a été essayé
Je me demande aussi en quoi
clojure.spec.alphagère cela différemmentLe commentaire de mjaniczek sur https://news.ycombinator.com/item?id=40876437 cite ce cas comme un inconvénient de l’approche d’Hypothesis
L’idée est que « le générateur devient maintenant un parseur de liste d’octets susceptible d’échouer, ce qui introduit une certaine inefficacité, et l’utilisateur peut créer des générateurs étranges que le réducteur interne ne parvient pas à réduire parfaitement. Malgré tout, l’expérience développeur est la meilleure des trois approches… »
Bien sûr, la personne n’accepterait probablement pas l’idée d’avoir écrit son test d’une façon « étrange »
filterd’une manière qui provoque son propre problèmeSi l’on génère aléatoirement puis que l’on filtre ce qui correspond à une certaine propriété, le processus de génération revient en pratique à gratter un ticket de loterie
La réponse simple à la question posée dans l’article — « pourquoi n’exige-t-on pas que les recherches publiées soient reproductibles avec des outils open source, ou au moins avec des outils mis gratuitement à disposition du public et des autres chercheurs ? » — est que la conséquence immédiate d’une telle exigence serait que les articles ne satisfaisant pas cette condition ne seraient pas publiés
Par exemple, même des travaux comme l’article sur Quviq QuickCheck, qui semblent avoir été utiles aux auteurs et à d’autres personnes, n’auraient pas été publiés, et la communauté aurait perdu ce cadeau qu’est cette information
Toute exigence a un effet d’exclusion, et il y aura toujours des cas limites d’articles qui peuvent être utiles même sans satisfaire une exigence donnée
Si l’on considère cet argument comme valide, on peut s’en servir comme bouclier pour aller aussi loin qu’on veut. Si l’on abandonne la reproductibilité comme exigence, on n’a plus besoin d’expliquer quoi que ce soit que l’on ne souhaite pas expliquer. Pas besoin de fournir les données sur les échantillons, ni les tests de significativité statistique. Un résumé vague affirmant avoir atteint tel ou tel résultat devient suffisant
Même la célèbre note laissée par Fermat dans la marge de son exemplaire personnel de l’Arithmetica deviendrait alors un article de recherche pleinement valide. Après tout, on ne voudrait pas perdre l’information précieuse selon laquelle un mathématicien célèbre pensait disposer d’une preuve concise et élégante d’un théorème. Même si, bien sûr, il est très probable qu’elle n’existait pas réellement
Mon avis sur cette question politique est que les critères actuels sont trop laxistes. Personne n’est contraint de publier quoi que ce soit. Il existe dans le monde beaucoup de recherches qui ne sont publiées nulle part, par exemple pour des raisons de valeur propriétaire, et elles ne vont pas disparaître
Mais si l’on travaille dans le monde académique, a fortiori avec des financements de recherche, et que l’on affirme avoir pour objectif de faire progresser la connaissance scientifique mondiale, il est juste d’exiger que l’on poursuive réellement cet objectif. Il ne faudrait pas se contenter de faire semblant de le poursuivre pour gravir les échelons d’une carrière universitaire
Avec tout ce qui est nécessaire pour exécuter le code. C’est peut-être même déjà ce qui se fait
Ajouter davantage d’exigences à cette fin a peu de chances de réduire le nombre d’articles publiés
Le problème le plus grave des articles publiés est que, pour publier le plus possible et le plus vite possible, on ferme souvent délibérément les yeux sur les erreurs. Rendre la vérification des articles plus facile pourrait améliorer la situation, mais je n’en attendrais pas trop. Les gens sont très doués pour trouver des raccourcis
Avec
proptesten Rust, j’écris assez souvent des tests de propriétés avec état, et en général les coder soi-même est assez simpleUn exemple non trivial ayant trouvé 6 bugs se trouve ici : https://github.com/sunshowers-code/buf-list/blob/main/src/cu...
Les tests parallèles peuvent parfois être utiles, mais il est souvent plus simple de lancer beaucoup de tests en parallèle
Au niveau supérieur, j’utilise une vraie part d’aléatoire, puis plusieurs boucles imbriquées en dessous pour passer de cas de faible complexité à des cas de plus forte complexité. Ensuite, je crée une graine à fournir à un générateur pseudo-aléatoire déterministe et je l’affiche. Si le test échoue, il suffit de copier-coller la graine en erreur pour reproduire le cas d’échec
J’ai trouvé que ces tests de propriétés manuels étaient plus rapides, plus flexibles et globalement moins pénibles que n’importe quel framework ou bibliothèque
Cela dit, pour des tests de concurrence vraiment robustes, je recommande vivement la bibliothèque AWS Shuttle (https://github.com/awslabs/shuttle). Elle peut débusquer des conditions de concurrence d’une complexité difficile à croire. J’ai aussi écrit un petit tutoriel : https://grantslatton.com/shuttle
Chez AWS, cette bibliothèque a été utilisée pour valider un système de fichiers sur mesure écrit pour faire tourner AWS S3
J’ai parcouru rapidement l’article lié, “Testing Telecoms Software with Quviq QuickCheck”, mais je n’ai pas vu de réponse immédiate à la question : « pourquoi ne vaudrait-il pas mieux construire soi-même cette opération avec état ? »
Le texte original renvoie à cette partie avec le modèle de paires clé-valeur d’un magasin clé-valeur, mais je ne vois pas pourquoi on ne pourrait pas simplement écrire une machine à états, ni pourquoi un framework serait nécessaire. La semaine dernière au travail, pour tester des interactions avec un système de fichiers, j’ai littéralement fait cela, et ça s’est finalement résumé à quelque chose comme
type Instruction = | Read of stuff | Write of stuff | Seek of stuff | …La propriété devient alors : « étant donnée cette liste de commandes, … ». Le type
StateModeldemande en gros de faire la même chose. J’ai du mal à voirStateModelfaire vraiment sa part du travail ; il semble seulement supprimer, d’après mon expérience, une très petite quantité de code de test, au prix de beaucoup plus de code de framework à comprendreSi l’on veut générer uniquement des séquences de transitions d’état « valides », il faut généralement un état de modèle qui détermine quelles étapes de test sont valides dans un état donné. Il faut aussi éviter que, pendant la réduction, la suppression d’étapes de test casse les préconditions respectées lors de la génération initiale de chaque étape, ce qui produirait de faux échecs
Si l’on veut simplement des séquences d’opérations arbitraires entièrement aléatoires, où n’importe quelle opération est valide dans n’importe quel état, un framework
proptestavec état peut être excessif. Mais si l’on doit maintenir un état de modèle et préciser les préconditions de plusieurs opérations, un framework dédié enlève beaucoup de travailJ’ai écrit l’an dernier un billet de blog sur ce sujet, qui peut servir de référence si l’on veut un exemple plus approfondi : https://readyset.io/blog/stateful-property-testing-in-rust
Comme d’autres l’ont dit, les tests de machines à états parallèles sont aussi un avantage intéressant que l’on peut obtenir avec un framework dédié, mais ce n’est pas le seul
On peut mélanger les styles de test. C’est votre code, après tout
C’est là son avantage
L’auteur se concentre sur les aspects machine à états et parallélisme des tests fondés sur des propriétés, mais il existe aussi d’autres aspects qui pourraient avoir un effet plus important.
L’un d’eux est le test fondé sur des propriétés guidé par la couverture ; voir l’article de Dan Luu : https://danluu.com/testing/
Un autre, qui est celui pour lequel j’ai un biais, consiste à automatiser la réduction tout en conservant toutes les invariants créés lors de la génération des valeurs.
En résumé, les fonctions de réduction dérivées à la QuickCheck qui opèrent sur des valeurs (
shrink : a -> [a]) ont des contraintes et des problèmes, au point que les gens finissent par désactiver la réduction plutôt que de traiter le problème.La « réduction intégrée » par rosiers (par exemple Hedgehog) respecte les contraintes du générateur, mais pose problème avec le bind monadique, c’est-à-dire lorsque le résultat d’un générateur est utilisé pour bifurquer vers un autre générateur.
La seule approche qui semble magiquement « juste fonctionner » est la réduction interne de Hypothesis. Elle utilise un niveau d’indirection qui réduit une liste de choix aléatoires plutôt que la valeur elle-même. L’inconvénient est que le générateur devient alors un parseur de liste d’octets susceptible d’échouer, ce qui introduit une certaine inefficacité, et que l’utilisateur peut créer des générateurs étranges que le réducteur interne ne parvient pas à réduire parfaitement. Malgré tout, parmi les trois approches, c’est celle qui offre la meilleure expérience développeur, et si l’on considère que le simple fait que les gens écrivent des tests est déjà un petit miracle, c’est celle qui semble la plus intéressante à construire en tant qu’auteur de bibliothèque de test.
À la place, il faut privilégier les générateurs applicatifs pour une réduction optimale : https://github.com/hedgehogqa/haskell-hedgehog/issues/473#is...
Autrement dit, les générateurs applicatifs ne « se servent pas du résultat d’un générateur pour bifurquer vers un autre générateur », et la nature « parallèle » de l’applicatif optimise la réduction. Ici, parallèle n’est pas à comprendre au sens des threads de l’article, mais au sens monadique. Comme l’applicatif est « parallèle », les générateurs peuvent être réduits indépendamment. À l’inverse, un générateur monadique est « sériel » : en réduire un change nécessairement le comportement des générateurs qui suivent.
S’il existe une présentation publique, j’aimerais beaucoup avoir le lien.
Je ne pense pas qu’il soit réellement prêt pour la production, et cela semble même être ainsi par conception.[0]
Ayant pas mal utilisé
clojure.spec.alpha, avec ou sanstest.check, je n’étais pas complètement étranger à l’idée générale, même s’il y a des différences.[0] https://github.com/HypothesisWorks/hypothesis/issues/3493
Il est similaire à Hypothesis, mais utilise un arbre de génération plutôt qu’une séquence linéaire. Il repose sur les foncteurs sélectifs, qui constituent aussi une bonne interface utile pour des choses comme les validateurs.
D’après https://hackage.haskell.org/package/falsify, cette bibliothèque fournit des tests fondés sur des propriétés avec prise en charge de la réduction intégrée interne. Intégrée au sens de Hedgehog, c’est-à-dire qu’il n’est pas nécessaire d’écrire séparément un réducteur et un générateur ; interne au sens de Hypothesis, c’est-à-dire qu’elle fonctionne aussi correctement à travers les binds monadiques.
J’ai essayé d’utiliser les tests fondés sur des propriétés, mais j’ai toujours eu l’impression d’être entre deux chaises.
Si je comprends suffisamment bien une propriété pour la tester strictement, je peux généralement la pousser dans le système de types afin qu’elle soit vraie par construction. Si je veux seulement un simple smoke test, une entrée arbitraire est plus facile.
Par exemple, on a souvent deux implémentations, l’une naïve, lente mais simple, et l’autre optimisée, et l’on peut comparer leurs sorties sur des entrées arbitraires. C’est une propriété simple et facile à comprendre, mais généralement difficile à intégrer dans le système de types.
De même, il peut y avoir des propriétés du type « l’ordre dans lequel les entrées sont présentées ne devrait pas importer », ou bien une manière de découper les données telle que
max(valeur maximale de A, valeur maximale de B) = maximum(A union B). Comment encoder cela dans le système de types ?Ou encore : « pour des A et B arbitraires, une solution optimale trouvée dans A est moins bonne qu’une solution optimale trouvée dans A union B », ou l’idempotence du type
f(f(A)) = f(A).Toutes ces propriétés sont faciles à comprendre, mais pas faciles à exprimer dans la plupart des systèmes de types.
Mais il existe beaucoup de contraintes que les vérificateurs de types grand public ne savent pas gérer. Les types dépendants aideraient beaucoup, mais ils semblent encore cantonnés à des niches comme les assistants de preuve.
Je me demande si la version originale QuviQ Erlang QuickCheck ne manque pas dans la liste.
Le produit complet est propriétaire, mais une version gratuite, QuickCheck Mini, est aussi disponible : http://www.quviq.com/downloads/
Clojure dispose désormais lui aussi d’une bibliothèque quickcheck avec état : https://github.com/griffinbank/test.contract
Les tests parallèles sont intéressants, mais ils n’ont pas encore été une grande source de douleur.
Pour les tests C#/.NET, j’utilise CsCheck[0] et j’en suis plutôt satisfait
Il est bien plus accessible que Hedgehog ou FsCheck, et aussi assez rapide
[0] https://github.com/AnthonyLloyd/CsCheck
Il prend aussi en charge les tests de linéarisabilité/parallèles décrits dans l’article
Références :
https://github.com/AnthonyLloyd/CsCheck?tab=readme-ov-file#m...
https://github.com/AnthonyLloyd/CsCheck?tab=readme-ov-file#c...
L’existence d’une variante C# séparée semble donc pertinente