Quels sont les bons tests unitaires pour couvrir le cas d'utilisation du laminage d'une matrice?

18

J'essaie de me familiariser avec les tests unitaires.

Disons que nous avons un dé qui peut avoir un nombre de côtés par défaut égal à 6 (mais qui peut être à 4, 5 côtés, etc.):

import random
class Die():
    def __init__(self, sides=6):
        self._sides = sides

    def roll(self):
        return random.randint(1, self._sides)

Les tests unitaires suivants seraient-ils valides / utiles?

  • tester un jet dans la gamme 1-6 pour un dé à 6 faces
  • tester un jet de 0 pour un dé à 6 faces
  • tester un jet de 7 pour un dé à 6 faces
  • tester un jet dans la gamme 1-3 pour un dé à 3 faces
  • tester un jet de 0 pour un dé à 3 faces
  • tester un jet de 4 pour un dé à 3 faces

Je pense juste que c'est une perte de temps car le module aléatoire existe depuis assez longtemps, mais je pense que si le module aléatoire est mis à jour (disons que je mets à jour ma version Python), alors au moins je suis couvert.

De plus, dois-je même tester d'autres variantes de rouleaux de dé, par exemple le 3 dans ce cas, ou est-il bon de couvrir un autre état de dé initialisé?

Cybran
la source
1
Qu'en est-il d'un dé à 5 faces ou d'un dé à zéro?
JensG

Réponses:

22

Vous avez raison, vos tests ne doivent pas vérifier que le randommodule fait bien son travail; un unittest ne devrait tester que la classe elle-même, pas comment elle interagit avec un autre code (qui devrait être testé séparément).

Il est bien sûr tout à fait possible que votre code utilise random.randint()mal; ou vous appelez à la random.randrange(1, self._sides)place et votre dé ne lance jamais la valeur la plus élevée, mais ce serait un type de bogue différent, pas celui que vous pourriez attraper avec un plus insignifiant. Dans ce cas, votre die appareil fonctionne comme prévu, mais la conception elle-même était défectueuse.

Dans ce cas, j'utiliserais la simulation pour remplacer la randint()fonction, et je vérifierais seulement qu'elle a été appelée correctement. Python 3.3 et supérieur est livré avec le unittest.mockmodule pour gérer ce type de test, mais vous pouvez installer le mockpackage externe sur les anciennes versions pour obtenir exactement les mêmes fonctionnalités

import unittest
try:
    from unittest.mock import patch
except ImportError:
    # < python 3.3
    from mock import patch


@patch('random.randint', return_value=3)
class TestDice(unittest.TestCase):
    def _make_one(self, *args, **kw):
        from die import Die
        return Die(*args, **kw)

    def test_standard_size(self, mocked_randint):
        die = self._make_one()
        result = die.roll()

        mocked_randint.assert_called_with(1, 6)
        self.assertEqual(result, 3)

    def test_custom_size(self, mocked_randint):
        die = self._make_one(sides=42)
        result = die.roll()

        mocked_randint.assert_called_with(1, 42)
        self.assertEqual(result, 3)


if __name__ == '__main__':
    unittest.main()

Avec la moquerie, votre test est maintenant très simple; il n'y a que 2 cas, vraiment. Le cas par défaut pour un dé à 6 faces et le cas des côtés personnalisés.

Il existe d'autres façons de remplacer temporairement la randint()fonction dans l'espace de noms global de Die, mais le mockmodule rend cela plus simple. Le @mock.patchdécorateur s'applique ici à toutes les méthodes de test dans le cas de test; chaque méthode de test reçoit un argument supplémentaire, la random.randint()fonction simulée, afin que nous puissions tester par rapport à la maquette pour voir si elle a bien été appelée correctement. L' return_valueargument spécifie ce qui est retourné par la maquette lorsqu'elle est appelée, afin que nous puissions vérifier que la die.roll()méthode nous a bien renvoyé le résultat «aléatoire».

J'ai utilisé une autre meilleure pratique Pitton unittesting ici: importer la classe sous test dans le cadre du test. La _make_oneméthode effectue le travail d'importation et d'instanciation dans un test , de sorte que le module de test sera toujours chargé même si vous avez fait une erreur de syntaxe ou une autre erreur qui empêchera le module d'origine d'importer.

De cette façon, si vous avez fait une erreur dans le code du module lui-même, les tests seront toujours exécutés; ils échoueront simplement, vous informant de l'erreur dans votre code.

Pour être clair, les tests ci-dessus sont extrêmement simplistes. Le but ici n'est pas de tester ce qui random.randint()a été appelé avec les bons arguments, par exemple. Au lieu de cela, l'objectif est de tester que l'unité produit les bons résultats compte tenu de certaines entrées, où ces entrées incluent les résultats d'autres unités non testées. En se moquant de la random.randint()méthode, vous pouvez prendre le contrôle d'une autre entrée de votre code.

Dans les tests du monde réel , le code réel de votre unité sous test va être plus complexe; la relation avec les entrées passées à l'API et comment les autres unités sont ensuite invoquées peut être encore intéressante, et la moquerie vous donnera accès à des résultats intermédiaires, et vous permettra de définir les valeurs de retour pour ces appels.

Par exemple, dans le code qui authentifie les utilisateurs par rapport à un service OAuth2 tiers (une interaction en plusieurs étapes), vous souhaitez tester que votre code transmet les bonnes données à ce service tiers et vous permet de simuler différentes réponses d'erreur qui Le service tiers reviendrait, vous permettant de simuler différents scénarios sans avoir à créer vous-même un serveur OAuth2 complet. Ici, il est important de tester que les informations d'une première réponse ont été gérées correctement et ont été transmises à un appel de deuxième étape, vous devez donc voir que le service simulé est appelé correctement.

Martijn Pieters
la source
1
Vous avez pas mal de plus de 2 cas de test ... les résultats vérifient la valeur par défaut: inférieure (1), supérieure (6), inférieure à (0), supérieure à (7) et les résultats pour les nombres spécifiés par l'utilisateur comme max_int etc., l'entrée n'est pas non plus validée, ce qui pourrait devoir être testé à un moment donné ...
James Snell
2
Non, ce sont des tests randint(), pas le code Die.roll().
Martijn Pieters
Il existe en fait un moyen de garantir que non seulement randint est appelé correctement, mais que son résultat est également utilisé correctement: moquez-le pour renvoyer un sentinel.dieexemple (l'objet sentinelle vient unittest.mockaussi), puis vérifiez qu'il s'agit bien de ce qui a été renvoyé par votre méthode roll. Cela ne permet en fait qu'une seule façon de mettre en œuvre la méthode testée.
aragaer
@aragaer: bien sûr, si vous voulez vérifier que la valeur est retournée inchangée, ce sentinel.dieserait un excellent moyen de vous en assurer.
Martijn Pieters
Je ne comprends pas pourquoi vous voudriez vous assurer que mocked_randint est appelé_ avec certaines valeurs. Je comprends vouloir se moquer de Randint pour retourner des valeurs prévisibles, mais le souci n'est-il pas simplement qu'il renvoie des valeurs prévisibles et non avec quelles valeurs il est appelé? Il me semble que la vérification des valeurs appelées lie inutilement le test à de fins détails d'implémentation. Aussi pourquoi nous soucions-nous que le dé retourne la valeur exacte de randint? Ne nous soucions-nous pas vraiment qu'il renvoie une valeur> 1 et inférieure à égale au max?
bdrx
16

La réponse de Martijn est de savoir comment vous le feriez si vous vouliez vraiment exécuter un test qui démontre que vous appelez random.randint. Cependant, au risque qu'on me dise "cela ne répond pas à la question", je pense que cela ne devrait pas du tout être testé à l'unité. Se moquer de Randint n'est plus un test de boîte noire - vous montrez spécifiquement que certaines choses se passent dans la mise en œuvre . Le test de la boîte noire n'est même pas une option - il n'y a aucun test que vous pouvez exécuter qui prouvera que le résultat ne sera jamais inférieur à 1 ou supérieur à 6.

Pouvez-vous vous moquer randint? Oui, vous pouvez. Mais que prouvez-vous? Que vous l'appeliez avec les arguments 1 et côtés. Qu'est- ce que cela signifie? Vous êtes de retour à la case départ - à la fin de la journée, vous devrez prouver - formellement ou officieusement - que l'appel random.randint(1, sides)met correctement en œuvre un lancer de dés.

Je suis tout pour les tests unitaires. Ce sont de fantastiques tests de santé mentale et révèlent la présence de bugs. Cependant, ils ne peuvent jamais prouver leur absence, et il y a des choses qui ne peuvent pas être affirmées par le biais de tests (par exemple qu'une fonction particulière ne lève jamais d'exception ou se termine toujours.) Dans ce cas particulier, je pense qu'il y a très peu de choses que vous supportez Gain. Pour les comportements déterministes, les tests unitaires ont du sens car vous savez réellement quelle sera la réponse que vous attendez.

Doval
la source
Les tests unitaires ne sont pas vraiment des tests de boîte noire. C'est à cela que servent les tests d'intégration, pour s'assurer que les différentes parties interagissent comme prévu. C'est une question d'opinion, bien sûr (la plupart des philosophies de test le sont), voir "Les tests unitaires" relèvent-ils des tests en boîte blanche ou en boîte noire? et Test unitaire de la boîte noire pour certaines perspectives (débordement de pile).
Martijn Pieters
@MartijnPieters Je ne suis pas d'accord pour dire que "c'est à cela que servent les tests d'intégration". Les tests d'intégration permettent de vérifier que tous les composants du système interagissent correctement. Ils ne sont pas le lieu de tester qu'un composant donné donne la sortie correcte pour une entrée donnée. En ce qui concerne les tests unitaires de boîte noire vs boîte blanche, les tests unitaires de boîte blanche finiront par rompre avec les changements d'implémentation, et toutes les hypothèses que vous aurez faites dans l'implémentation seront probablement reportées dans le test. La validation qui random.randintest appelée avec 1, sidesn'a aucune valeur si ce n'est pas la bonne chose à faire.
Doval
Oui, c'est une limitation d'un test unitaire en boîte blanche. Cependant, il est inutile de tester qui random.randint()retournera correctement des valeurs dans la plage [1, côtés] (inclus), c'est aux développeurs de Python de s'assurer que l' randomunité fonctionne correctement.
Martijn Pieters
Et comme vous le dites vous-même, les tests unitaires ne peuvent garantir que votre code est exempt de bogues; si votre code utilise mal d'autres unités (par exemple, vous vous attendiez random.randint()à vous comporter comme tel random.randrange()et donc à l'appeler avec random.randint(1, sides + 1), alors vous êtes de toute façon coulé.
Martijn Pieters
2
@MartijnPieters Je suis d'accord avec vous là-dessus, mais ce n'est pas à cela que je m'oppose. Je m'oppose à tester que random.randint est appelé avec des arguments (1, côtés) . Vous avez supposé dans l'implémentation que c'était la bonne chose à faire, et maintenant vous répétez cette hypothèse dans le test. Si cette hypothèse est erronée, le test aura réussi mais votre implémentation est toujours incorrecte. C'est une preuve à demi-assortie qui est une véritable douleur à écrire et à maintenir.
Doval
6

Correction d'une graine aléatoire. Pour les dés à 1, 2, 5 et 12 faces, confirmez que quelques milliers de jets donnent des résultats incluant 1 et N, et non compris 0 ou N + 1. Si, par hasard, vous obtenez un ensemble de résultats aléatoires qui ne couvrir la plage attendue, passer à une autre graine.

Les outils de moquerie sont cool, mais ce n'est pas parce qu'ils vous permettent de faire quelque chose que cela doit être fait. YAGNI s'applique autant aux appareils de test qu'aux fonctionnalités.

Si vous pouvez facilement tester avec des dépendances non simulées, vous devriez presque toujours le faire; de cette façon, vos tests se concentreront sur la réduction du nombre de défauts, et pas seulement sur l'augmentation du nombre de tests. Les moqueries excessives risquent de créer des chiffres de couverture trompeurs, ce qui peut à son tour entraîner le report des tests réels à une phase ultérieure que vous n'aurez peut-être jamais le temps de contourner ...

Soru
la source
3

Qu'est-ce qu'un Diesi vous y pensez? - rien de plus qu'une enveloppe autour random. Il encapsule random.randintet réétiquette en termes de vocabulaire de votre application: Die.Roll.

Je ne trouve pas pertinent d'insérer une autre couche d'abstraction entre Dieet randomcar Dieelle-même est déjà cette couche d'indirection entre votre application et la plateforme.

Si vous voulez dés en conserve des résultats, tout simplement fausse Die, ne vous moquez pasrandom .

En général, je ne teste pas mes objets wrapper qui communiquent avec des systèmes externes, j'écris des tests d'intégration pour eux. Vous pouvez en écrire quelques-uns, Diemais comme vous l'avez souligné, en raison de la nature aléatoire de l'objet sous-jacent, ils ne seront pas significatifs. De plus, aucune configuration ou communication réseau n'est impliquée ici, donc pas grand-chose à tester, sauf un appel de plate-forme.

=> Considérant qu'il Diene s'agit que de quelques lignes de code triviales et n'ajoutant que peu ou pas de logique par rapport à randomlui-même, je passerais le test dans cet exemple spécifique.

guillaume31
la source
2

L'ensemencement du générateur de nombres aléatoires et la vérification des résultats attendus ne sont PAS, à ma connaissance, un test valide. Il fait des hypothèses sur la façon dont vos dés fonctionnent en interne, ce qui est vilain. Les développeurs de python pourraient changer le générateur de nombres aléatoires, ou le dé (REMARQUE: "dés" est pluriel, "mourir" est singulier. À moins que votre classe n'implémente plusieurs jets de dé en un seul appel, il devrait probablement être appelé "mourir") pourrait utilisez un générateur de nombres aléatoires différent.

De même, se moquer de la fonction aléatoire suppose que l'implémentation de classe fonctionne exactement comme prévu. Pourquoi n'est-ce pas le cas? Quelqu'un pourrait prendre le contrôle du générateur de nombres aléatoires python par défaut, et pour éviter cela, une future version de votre dé pourrait récupérer plusieurs nombres aléatoires, ou des nombres aléatoires plus grands, pour mélanger plus de données aléatoires. Un schéma similaire a été utilisé par les fabricants du système d'exploitation FreeBSD, quand ils soupçonnaient la NSA de falsifier les générateurs de nombres aléatoires matériels intégrés dans les CPU.

Si c'était moi, je lancerais, disons, 6000 rouleaux, les compterais, et m'assurerais que chaque nombre de 1 à 6 est roulé entre 500 et 1500 fois. Je vérifierais également qu'aucun numéro en dehors de cette plage n'est retourné. Je pourrais également vérifier que, pour une deuxième série de 6000 rouleaux, lors de la commande du [1..6] par ordre de fréquence, le résultat est différent (cela échouera une fois sur 720 runs, si les nombres sont aléatoires!). Si vous voulez être minutieux, vous pouvez trouver la fréquence des nombres suivant un 1, un 2, etc.; mais assurez-vous que la taille de votre échantillon est suffisamment grande et que la variance est suffisante. Les humains s'attendent à ce que les nombres aléatoires aient moins de modèles qu'ils ne le font réellement.

Répétez l'opération pour un dé à 12 faces et à 2 faces (6 est le plus utilisé, donc le plus attendu pour quiconque écrit ce code).

Enfin, je testerais pour voir ce qui se passe avec un dé à 1 face, un dé à 0 face, un dé à -1 face, un dé à 2,3 faces, un dé à [1,2,3,4,5,6] faces et un dé "bla". Bien sûr, tout cela devrait échouer; échouent-ils de manière utile? Celles-ci devraient probablement échouer à la création, pas au roulement.

Ou, peut-être, vous voulez aussi gérer cela différemment - peut-être que la création d'un dé avec [1,2,3,4,5,6] devrait être acceptable - et peut-être aussi "bla"; cela pourrait être un dé à 4 faces, et chaque face portant une lettre. Le jeu "Boggle" vient à l'esprit, tout comme une boule magique à huit.

Et enfin, vous voudrez peut-être envisager ceci: http://lh6.ggpht.com/-fAGXwbJbYRM/UJA_31ACOLI/AAAAAAAAAPg/2FxOWzo96KE/s1600-h/random%25255B3%25255D.jpg

AMADANON Inc.
la source
2

Au risque de nager à contre-courant, j'ai résolu ce problème exact il y a plusieurs années en utilisant une méthode pas encore mentionnée.

Ma stratégie était simplement de se moquer du RNG avec un qui produit un flux prévisible de valeurs couvrant tout l'espace. Si (disons) côté = 6 et que le RNG produit des valeurs de 0 à 5 en séquence, je peux prédire comment ma classe devrait se comporter et effectuer des tests unitaires en conséquence.

La justification est que cela teste la logique dans cette seule classe, en supposant que le RNG produira finalement chacune de ces valeurs et sans tester le RNG lui-même.

C'est simple, déterministe, reproductible et il attrape des bugs. J'utiliserais à nouveau la même stratégie.


La question ne précise pas quels devraient être les tests, mais quelles données pourraient être utilisées pour les tests, compte tenu de la présence d'un RNG. Ma suggestion est simplement de tester de manière exhaustive en se moquant du RNG. La question de savoir ce qui vaut la peine d'être testé dépend des informations non fournies dans la question.

david.pfx
la source
Disons que vous vous moquez du RNG pour être prévisible. Eh bien, que testez-vous alors? La question demande "Les tests unitaires suivants seraient-ils valides / utiles?" Se moquer pour retourner 0-5 n'est pas un test mais plutôt une configuration de test. Comment feriez-vous un "test unitaire en conséquence"? Je n'arrive pas à comprendre comment cela "attrape les bugs". J'ai du mal à comprendre ce dont j'ai besoin pour un test «unitaire».
bdrx
@bdrx: C'était il y a quelque temps: je répondrais différemment maintenant. Mais voir modifier.
david.pfx
1

Les tests que vous proposez dans votre question ne détectent pas de compteur arithmétique modulaire comme implémentation. Et ils ne détectent pas les erreurs d'implémentation courantes dans le code lié à la distribution de probabilité comme return 1 + (random.randint(1,maxint) % sides). Ou une modification du générateur qui se traduit par des motifs à deux dimensions.

Si vous voulez réellement vérifier que vous générez des nombres à distribution aléatoire uniformément répartis, vous devez vérifier une très grande variété de propriétés. Pour faire un travail raisonnablement bon, vous pouvez exécuter http://www.phy.duke.edu/~rgb/General/dieharder.php sur vos numéros générés. Ou écrivez une suite de tests unitaires tout aussi complexe.

Ce n'est pas la faute des tests unitaires ou TDD, l'aléatoire est juste une propriété très difficile à vérifier. Et un sujet populaire pour des exemples.

Patrick
la source
-1

Le test le plus simple d'un jet de dé consiste simplement à le répéter plusieurs centaines de milliers de fois et à valider que chaque résultat possible a été touché environ (1 / nombre de côtés) fois. Dans le cas d'un dé à 6 faces, vous devriez voir chaque valeur possible toucher environ 16,6% du temps. Si certains sont en panne de plus d'un pour cent, vous avez un problème.

En procédant de cette façon, vous évitez de refactoriser le mécanisme sous-jacent de générer un nombre aléatoire facilement et surtout sans changer le test.

ChristopherBrown
la source
1
ce test passerait pour une implémentation totalement non aléatoire qui boucle simplement à travers les côtés un par un dans un ordre prédéfini
gnat
1
Si un codeur a l'intention d'implémenter quelque chose de mauvaise foi (ne pas utiliser d'agent de randomisation sur un dé) et d'essayer simplement de trouver quelque chose pour `` faire passer les voyants rouges au vert '', vous avez plus de problèmes que les tests unitaires ne peuvent vraiment résoudre.
ChristopherBrown