Comment arrêter de s'inquiéter et commencer à écrire des tests basés sur les propriétés

Récemment, de plus en plus souvent, il y a des références à un certain outil magique - les tests basés sur les propriétés (tests basés sur les propriétés, si vous avez besoin de google littérature anglaise). La plupart des articles sur ce sujet parlent de ce qu'est une approche cool, puis ils montrent sur un exemple élémentaire comment écrire un tel test en utilisant un cadre spécifique, au mieux ils suggèrent plusieurs propriétés communes, et ... c'est tout. De plus, le lecteur étonné et enthousiaste essaie de mettre tout cela en pratique et repose sur le fait que les propriétés ne sont en quelque sorte pas inventées. Et malheureusement, elle y cède souvent. Dans cet article, je vais essayer de prioriser un peu différemment. Je vais quand même commencer par un exemple plus ou moins concret pour expliquer de quel animal il s'agit. Mais un exemple, je l'espère, n'est pas tout à fait typique pour des articles de ce genre. Ensuite, je vais essayer d'analyser certains des problèmes associés à cette approche et comment ils peuvent être résolus. Et ci-après - propriétés, propriétés et uniquement propriétés, avec des exemples où elles peuvent être poussées. Intéressant?

Test du stockage de valeurs-clés en trois courts tests


Donc, disons que pour une raison quelconque, nous devons implémenter une sorte de stockage de valeur-clé. Il peut s'agir d'un dictionnaire basé sur une table de hachage, ou basé sur une arborescence, il peut être entièrement stocké en mémoire, ou capable de fonctionner avec un disque - peu nous importe. L'essentiel est qu'il devrait avoir une interface qui vous permet de:

  • écrire la valeur par clé
  • vérifier s'il existe une entrée avec la clé souhaitée
  • lire la valeur par clé
  • obtenir une liste des éléments enregistrés
  • obtenir une copie du référentiel

Dans l'approche classique basée sur des exemples, un test typique ressemblerait à ceci:

storage = Storage() storage['a'] = 42 assert len(storage) == 1 assert 'a' in storage assert storage['a'] == 42 

Ou alors:

 storage = Storage() storage['a'] = 42 storage['b'] = 73 assert len(storage) == 2 assert 'a' in storage assert 'b' in storage assert storage['a'] == 42 assert storage['b'] == 73 

Et en général, ces tests peuvent et devront être écrits un peu plus que dofiga. De plus, plus l'implémentation interne est compliquée, plus il y a de chances de manquer quelque chose de toute façon. Bref, un travail long, fastidieux et souvent ingrat. Comme ce serait bien de le pousser sur quelqu'un! Par exemple, faites en sorte que l'ordinateur génère des cas de test pour nous. Tout d'abord, essayez de faire quelque chose comme ceci:

 storage = Storage() key = arbitrary_key() value = arbitrary_value() storage[key] = value assert len(storage) == 1 assert key in storage assert storage[key] == value 

Il s'agit du premier test basé sur les propriétés. Il ressemble presque à celui traditionnel, bien qu'un petit bonus soit déjà frappant - il n'y a pas de valeurs prises au plafond, à la place, nous utilisons des fonctions qui renvoient des valeurs et des clés arbitraires. Il y a un autre avantage, beaucoup plus sérieux - vous pouvez l'exécuter de nombreuses fois et vérifier le contrat sur différentes données d'entrée, que si vous essayez d'ajouter un élément au stockage vide, il y sera vraiment ajouté. D'accord, c'est très bien, mais jusqu'à présent, ce n'est pas très utile par rapport à l'approche traditionnelle. Essayons d'ajouter un autre test:

 storage = arbitrary_storage() storage_copy = storage.copy() assert len(storage) == len(storage_copy) assert all(storage_copy[key] == storage[key] for key in storage) assert all(storage[key] == storage_copy[key] for key in storage_copy) 

Ici, au lieu de prendre le stockage vide, nous générons arbitrairement avec certaines données, et vérifions que sa copie est identique à l'original. Oui, le générateur doit être écrit à l'aide d'une API publique potentiellement boguée, mais en règle générale, ce n'est pas une tâche si difficile. Dans le même temps, s'il y a des bugs sérieux dans la mise en œuvre, alors les chances sont élevées que les chutes commencent au cours du processus de génération, donc cela peut également être considéré comme une sorte de test de fumée bonus. Mais maintenant, nous pouvons être sûrs que tout ce que le générateur a pu fournir peut être copié correctement. Et grâce au premier test, nous savons avec certitude que le générateur peut créer du stockage avec au moins un élément. Il est temps pour le prochain test! Dans le même temps, nous réutilisons le générateur:

 storage = arbitrary_storage() backup = storage.copy() key = arbitrary_key() value = arbitrary_value() if key in storage: return storage[key] = value assert len(storage) == len(backup) + 1 assert key in storage assert storage[key] == value assert all(storage[key] == backup[key] for key in backup) 

Nous prenons un stockage arbitraire et vérifions que nous pouvons y ajouter un autre élément. Ainsi, le générateur peut créer un référentiel avec deux éléments. Et vous pouvez également y ajouter un élément. Et ainsi de suite (je me souviens immédiatement d'une chose telle que l'induction mathématique). En conséquence, les trois tests écrits et le générateur permettent de vérifier de manière fiable qu'un nombre arbitraire d'éléments différents peut être ajouté au stockage. Seulement trois courts tests! C'est essentiellement l'idée des tests basés sur les propriétés:

  • on trouve des propriétés
  • vérification des propriétés sur un tas de données différentes
  • profit!

Soit dit en passant, cette approche ne contredit pas les principes du TDD - les tests peuvent être écrits de la même manière avant le code (du moins personnellement, je le fais habituellement). C’est une autre question que de faire passer un tel test au vert peut être beaucoup plus difficile que traditionnel, mais quand il réussira finalement, nous serons sûrs que le code est vraiment conforme à une certaine partie du contrat.

C'est bien beau, mais ...


Avec tout l'attrait d'une approche de test basée sur la propriété, il y a un tas de problèmes. Dans cette partie, je vais essayer de distinguer les plus courants. Et à part les problèmes liés à la complexité réelle de la recherche de propriétés utiles (que je reviendrai dans la section suivante), à ​​mon avis, le plus gros problème pour les débutants est souvent une fausse confiance dans une bonne couverture. En effet, nous avons écrit plusieurs tests qui génèrent des centaines de cas de test - qu'est-ce qui pourrait mal tourner? Si vous regardez l'exemple de la partie précédente, il y a en fait beaucoup de choses. Pour commencer, les tests écrits ne donnent aucune garantie que storage.copy () fera vraiment une copie «profonde», et pas seulement une copie du pointeur. Un autre trou - il n'y a pas de vérification normale que la clé dans le stockage retournera Faux si la clé que vous recherchez n'est pas dans le magasin. Et la liste continue. Eh bien, un de mes exemples préférés - disons que nous écrivons une sorte, et pour une raison quelconque, nous pensons qu'un test qui vérifie l'ordre des éléments suffit:

 input = arbitrary_list() output = sort(input) assert all(a <= b for a, b in zip(output, output[1:])) 

Et une telle mise en œuvre passera parfaitement

 def sort(input): return [1, 2, 3] 

J'espère que la morale ici est claire.

Le problème suivant, qui dans un sens peut être qualifié de conséquence des deux précédents, est que l'utilisation de tests basés sur les propriétés est souvent très difficile à obtenir une couverture vraiment complète. Mais à mon avis, cela est résolu très simplement - vous n'avez pas besoin d'écrire uniquement des tests basés sur les propriétés, personne n'a annulé les tests traditionnels. De plus, les gens sont disposés de telle sorte qu'il leur est beaucoup plus facile de comprendre les choses avec des exemples concrets, ce qui plaide également en faveur de l'utilisation des deux approches. En général, j'ai développé pour moi-même approximativement l'algorithme suivant - pour écrire des tests traditionnels très simples, idéalement afin qu'ils puissent servir d'exemple de la façon dont l'API est censée être utilisée. Dès qu'il y a eu un sentiment que les tests «pour la documentation» suffisent, mais que la couverture est encore loin d'être complète - commencez à ajouter des tests basés sur les propriétés.

Maintenant à la question des cadres, à quoi s'attendre d'eux et pourquoi ils sont nécessaires du tout - après tout, personne n'interdit avec vos mains de conduire un test dans un cycle, provoquant à l'intérieur une vie aléatoire et appréciant. En fait, la joie sera jusqu'à la première chute de test, et c'est bon si localement, et pas dans certains CI. Tout d'abord, comme les tests basés sur les propriétés sont aléatoires, vous avez certainement besoin d'un moyen de reproduire de manière fiable un cas abandonné, et tout cadre qui se respecte vous permet de le faire. Les approches les plus populaires consistent à générer une certaine graine sur la console, que vous pouvez manuellement retirer dans le lanceur de test et lire de manière fiable la casse supprimée (pratique pour le débogage), ou créer un cache sur le disque avec de "mauvais" sids, qui sera automatiquement vérifié en premier lorsque le test démarre ( aide à la répétabilité en CI). Un autre aspect important est la minification des données (diminution dans les sources étrangères). Étant donné que les données sont générées de manière aléatoire, c'est-à-dire, une chance complètement non fausse d'obtenir sur un cas de test en baisse avec un conteneur de 1000 éléments, ce qui est toujours un «plaisir» à déboguer. Par conséquent, de bons cadres après avoir trouvé un cas feylyaschy appliquent un certain nombre d'heuristiques pour essayer de trouver un ensemble plus compact de données d'entrée, qui continuera néanmoins de planter le test. Et enfin - souvent la moitié de la fonctionnalité de test est un générateur de données d'entrée, donc la présence de générateurs et de primitives intégrés qui vous permettent d'en construire rapidement des plus complexes à partir de générateurs simples aide également beaucoup.

Il y a aussi des critiques occasionnelles selon lesquelles il y a trop de tests logiques basés sur les propriétés. Cependant, ceci est généralement accompagné d'exemples dans le style de

 data = totally_arbitrary_data() perform_actions(sut, data) if is_category_a(data): assert property_a_holds(sut) else if is is_category_b(data): assert property_b_holds(sut) 

En fait, il est assez courant (pour les débutants) d'anti-modèle, ne faites pas ça! Il est préférable de diviser un tel test en deux tests différents et d'ignorer les données d'entrée inappropriées (dans de nombreux cadres, il existe même des outils spéciaux pour cela) si les chances d'y accéder sont faibles, ou d'utiliser des générateurs plus spécialisés qui ne produiront immédiatement que des données appropriées. Le résultat devrait être quelque chose comme

 data = totally_arbitrary_data() assume(is_category_a(data)) perform_actions(sut, data) assert property_a_holds(sut) 

et

 data = data_from_category_b() perform_actions(sut, data) assert property_b_holds(sut) 

Propriétés utiles et leurs habitats


D'accord, qu'est-ce qui est utile pour les tests basés sur les propriétés, il semble clair, les principaux pièges ont été résolus ... bien que non, l'essentiel n'est toujours pas clair - d'où viennent ces propriétés? Essayons de chercher.

Au moins ne tombe pas


L'option la plus simple consiste à insérer des données arbitraires dans le système testé et à vérifier qu'elles ne se bloquent pas. En fait, il s'agit d'une direction complètement distincte avec le nom à la mode fuzzing, pour lequel il existe des outils spécialisés (par exemple AFL aka American Fuzzy Lop), mais avec un peu d'étirement, cela peut être considéré comme un cas spécial de test basé sur les propriétés, et s'il n'y a aucune idée en tête Si ce n'est pas de l'escalade, vous pouvez commencer avec. Néanmoins, en règle générale, de tels tests ont explicitement rarement un sens, car les chutes potentielles ressortent généralement très bien lors de la vérification d'autres propriétés. Les principales raisons pour lesquelles je mentionne cette «propriété» sont de diriger le lecteur vers des fuzzers et en particulier AFL (il y a beaucoup d'articles en anglais sur ce sujet), enfin, pour compléter le tableau.

Test d'oracle


L'une des propriétés les plus ennuyeuses, mais en fait une chose très puissante qui peut être utilisée beaucoup plus souvent qu'il n'y paraît. L'idée est que parfois il y a deux morceaux de code qui font la même chose, mais de manières différentes. Et puis vous ne pouvez surtout pas comprendre pour générer des données d'entrée arbitraires, les pousser dans les deux options et vérifier que les résultats correspondent. L'exemple d'application le plus fréquemment cité est lors de l'écriture d'une version optimisée d'une fonction pour laisser une option lente mais simple et exécuter des tests sur elle.

 input = arbitrary_list() assert quick_sort(input) == bubble_sort(input) 

Cependant, l'applicabilité de cette propriété ne se limite pas à cela. Par exemple, il s'avère très souvent que la fonctionnalité implémentée par le système que nous voulons tester est un sur-ensemble de quelque chose déjà implémenté, souvent même dans la bibliothèque de langues standard. En particulier, la plupart des fonctionnalités d'un stockage de valeurs-clés (en mémoire ou sur disque, basées sur des arbres, des tables de hachage ou certaines structures de données plus exotiques telles que l'arbre merkle patricia) peuvent être testées avec un dictionnaire standard standard. Tester toutes sortes de CRUD - là aussi.

Une autre application intéressante que j'ai personnellement utilisée - parfois lors de la mise en œuvre d'un modèle numérique d'un système, certains cas particuliers peuvent être calculés analytiquement et comparés avec eux les résultats de la simulation. Dans ce cas, en règle générale, si vous essayez de pousser des données complètement arbitraires dans l'entrée, même avec la mise en œuvre correcte, les tests commenceront toujours à tomber en raison de la précision limitée (et, par conséquent, de l'applicabilité) des solutions numériques, mais pendant le processus de réparation en imposant des restrictions sur les données d'entrée générées, ces mêmes restrictions devenir connu.

Exigences et invariants


L'idée principale ici est que souvent les exigences elles-mêmes sont formulées de manière à être faciles à utiliser en tant que propriétés. Dans certains articles sur ces sujets, les invariants sont mis en évidence séparément, mais à mon avis, la frontière ici est trop instable, car la plupart de ces invariants sont des conséquences directes des exigences, donc je vais probablement tout vider ensemble.

Une petite liste d'exemples provenant de divers domaines appropriés pour vérifier les propriétés:

  • le champ de classe doit avoir une valeur précédemment attribuée (getter-setters)
  • le référentiel doit pouvoir lire un élément précédemment enregistré
  • l'ajout d'un élément précédemment inexistant au référentiel n'affecte pas les éléments ajoutés précédemment
  • dans de nombreux dictionnaires, plusieurs éléments différents avec la même clé ne peuvent pas être stockés
  • la hauteur équilibrée des arbres ne devrait plus être K cdotlog(N)N- nombre d'éléments enregistrés
  • le résultat du tri est une liste d'articles commandés
  • le résultat du codage base64 ne doit contenir que des caractères base64
  • l'algorithme de création d'itinéraire doit renvoyer une séquence de mouvements autorisés qui mèneront du point A au point B
  • pour tous les points des isolignes construites doivent être satisfaits f(x,y)=const
  • l'algorithme de vérification de signature électronique doit retourner True si la signature est vraie et False sinon
  • en raison de l'orthonormalisation, tous les vecteurs de la base doivent avoir une longueur unitaire et zéro produit scalaire mutuel
  • les opérations de transfert et de rotation des vecteurs ne doivent pas modifier sa longueur

En principe, on pourrait dire que tout est complet, l'article est complet, utiliser des oracles de test ou rechercher des propriétés dans les exigences, mais il y a des «cas spéciaux» plus intéressants que je voudrais signaler séparément.

Tests d'induction et d'état


Parfois, vous devez tester quelque chose avec un état. Dans ce cas, la manière la plus simple:

  • écrire un test qui vérifie l'exactitude de l'état initial (par exemple, que le conteneur qui vient d'être créé est vide)
  • écrire un générateur qui utilisant un ensemble d'opérations aléatoires amènera le système à un état arbitraire
  • écrire des tests pour toutes les opérations en utilisant le résultat du générateur comme état initial

Très similaire à l'induction mathématique:

  • prouver la déclaration 1
  • prouver la déclaration N + 1, en supposant que la déclaration N est vraie

Une autre méthode (donnant parfois un peu plus d'informations sur l'endroit où elle s'est cassée) consiste à générer une séquence d'événements acceptable, à l'appliquer au système testé et à vérifier les propriétés après chaque étape.

Avant et en arrière


Si soudain, il était nécessaire de tester quelques fonctions pour la conversion directe et inverse de certaines données, alors considérez que vous êtes très chanceux:

 input = arbitrary_data() assert decode(encode(input)) == input 

Idéal pour les tests:

  • sérialisation-désérialisation
  • chiffrement déchiffrement
  • encodage-décodage
  • transformer la matrice de base en quaternion et vice versa
  • transformation directe et inverse des coordonnées
  • transformée de Fourier directe et inverse

Un cas spécial mais intéressant est l'inversion:

 input = arbitrary_data() assert invert(invert(input)) == input 

Un exemple frappant est l'inversion ou la transposition d'une matrice.

Idempotence


Certaines opérations ne modifient pas le résultat d'une utilisation répétée. Exemples typiques:

  • tri
  • toute normalisation des vecteurs et des bases
  • rajout d'un élément existant à un ensemble ou à un dictionnaire
  • réenregistrer les mêmes données dans une propriété de l'objet
  • coulée de données sous forme canonique (les espaces en JSON conduisent à un style unifié par exemple)

L'idempotence peut également être utilisée pour tester la sérialisation-désérialisation si la méthode de décodage habituelle (encoder (entrée)) == ne convient pas en raison de différentes représentations possibles pour des données d'entrée équivalentes (encore une fois, des espaces supplémentaires dans certains JSON):

 def normalize(input): return decode(encode(input)) input = arbitrary_data() assert normalize(normalize(input)) == normalize(input) 

Différentes façons, un seul résultat


Ici, l'idée se résume à exploiter le fait qu'il existe parfois plusieurs façons de faire la même chose. Cela peut sembler être un cas particulier de l'oracle de test, mais en réalité ce n'est pas tout à fait le cas. L'exemple le plus simple utilise la commutativité de certaines opérations:

 a = arbitrary_value() b = arbitrary_value() assert a + b == b + a 

Cela peut sembler trivial, mais c'est un excellent moyen de tester:

  • addition et multiplication de nombres dans une représentation non standard (bigint, rationnel, c'est tout)
  • "Addition" de points sur des courbes elliptiques en champs finis (bonjour, cryptographie!)
  • union d'ensembles (qui à l'intérieur peuvent avoir des structures de données complètement non triviales)

De plus, l'ajout d'éléments au dictionnaire a la même propriété:

 A = dict() A[key_a] = value_a A[key_b] = value_b B = dict() B[key_b] = value_b B[key_a] = value_a assert A == B 

L'option est plus compliquée - j'ai longtemps réfléchi à la façon de la décrire en mots, mais seule une notation mathématique me vient à l'esprit. En général, ces transformations sont courantes f(x)dont la propriété détient f(x+y)=f(x) cdotf(y), et l'argument et le résultat de la fonction ne sont pas nécessairement un simple nombre, mais des opérations +et  cdot- juste quelques opérations binaires sur ces objets. Ce que vous pouvez tester avec ceci:

  • addition et multiplication de toutes sortes de nombres étranges, vecteurs, matrices, quaternions ( a cdot(x+y)=a cdotx+a cdoty)
  • opérateurs linéaires, en particulier toutes sortes d'intégrales, différentielles, convolutions, filtres numériques, transformées de Fourier, etc. ( F[x+y]=F[x]+F[y])
  • opérations sur des objets identiques dans différentes représentations, par exemple

    • M(qa cdotqb)=M(qa) cdotM(qb)qaet qbSont des quaternions simples, et M(q)- opération de conversion d'un quaternion en une matrice de base équivalente
    • F[a circb]=F[a] cdotF[b]aet bSont des signaux  circ- convolution  cdot- multiplication, et F- Transformée de Fourier


Un exemple d'une tâche un peu plus «ordinaire» - pour tester un algorithme de fusion de dictionnaire délicat, vous pouvez faire quelque chose comme ceci:

 a = arbitrary_list_of_kv_pairs() b = arbitrary_list_of_kv_pairs() result = as_dict(a) result.merge(as_dict(b)) assert result == as_dict(a + b) 

Au lieu d'une conclusion


C’est essentiellement tout ce que je voulais dire dans cet article. J'espère que c'était intéressant, et un peu plus de gens commenceront à mettre tout cela en pratique. Pour vous faciliter la tâche, je vais vous donner une liste de frameworks avec différents degrés de validité pour différentes langues:


Et, bien sûr, des remerciements particuliers aux personnes qui ont écrit des articles merveilleux, grâce auxquels j'ai appris cette approche il y a quelques années, ont cessé de s'inquiéter et ont commencé à écrire des tests basés sur les propriétés:

Source: https://habr.com/ru/post/fr434008/


All Articles