Les nombres magiques sont-ils acceptables dans les tests unitaires si les nombres ne veulent rien dire?


58

Dans mes tests unitaires, je jette souvent des valeurs arbitraires sur mon code pour voir ce qu'il fait. Par exemple, si je sais que cela foo(1, 2, 3)est censé renvoyer 17, je pourrais écrire ceci:

assertEqual(foo(1, 2, 3), 17)

Ces chiffres sont purement arbitraires et n’ont pas une signification plus large (ils ne sont pas, par exemple, des conditions aux limites, bien que je les vérifie également). J'aurais du mal à trouver de bons noms pour ces chiffres, et écrire quelque chose comme cela const int TWO = 2;est évidemment inutile. Est-ce correct d'écrire les tests de cette manière, ou devrais-je factoriser les nombres en constantes?

Dans Tous les nombres magiques créés sont-ils les mêmes? , nous avons appris que les nombres magiques sont acceptables si la signification est évidente dans le contexte, mais dans ce cas, les nombres n’ont aucune signification.


9
Si vous mettez des valeurs et que vous vous attendez à pouvoir les lire en retour, je dirais que les chiffres magiques conviennent. Donc, si, par exemple, 1, 2, 3sont des indices de tableau 3D dans lesquels vous avez précédemment stocké la valeur 17, alors je pense que ce test serait dandy (tant que vous avez également des tests négatifs). Mais si elle est le résultat d'un calcul, vous devez vous assurer que toute personne lisant ce test comprendra pourquoi foo(1, 2, 3)devrait être 17, et les nombres magiques ne sera probablement pas atteindre cet objectif.
Joe White

24
const int TWO = 2;est encore pire que de simplement utiliser 2. C'est se conformer à la formulation de la règle avec l'intention de violer son esprit.
Agent_L

4
Qu'est-ce qu'un nombre qui "ne veut rien dire"? Pourquoi cela serait-il dans votre code s'il ne voulait rien dire?
Tim Grant

6
Sûr. Laissez un commentaire avant une série de tests, par exemple, "une petite sélection d'exemples déterminés manuellement". Ceci, par rapport à vos autres tests qui testent clairement les limites et les exceptions, sera clair.
davidbak

5
Votre exemple est trompeur - lorsque le nom de votre fonction serait vraiment foo, cela ne signifierait rien, et donc les paramètres. Mais en réalité, je suis assez sûr que la fonction n'a pas ce nom, et les paramètres n'ont pas les noms bar1, bar2et bar3. Créez un exemple plus réaliste dans lequel les noms ont une signification. Par conséquent , il est beaucoup plus logique de déterminer si les valeurs des données de test nécessitent également un nom.
Doc Brown

Réponses:


80

Quand avez-vous vraiment des chiffres qui n'ont aucune signification?

Habituellement, lorsque les nombres ont une signification, vous devez les affecter à des variables locales de la méthode de test pour rendre le code plus lisible et plus explicite. Les noms des variables doivent au moins refléter ce que signifie la variable, pas nécessairement sa valeur.

Exemple:

const int startBalance = 10000;
const float interestRate = 0.05f;
const int years = 5;

const int expectedEndBalance = 12840;

assertEqual(calculateCompoundInterest(startBalance, interestRate, years),
            expectedEndBalance);

Notez que la première variable n'est pas nommée HUNDRED_DOLLARS_ZERO_CENT, mais startBalancepour indiquer quelle est la signification de la variable mais pas que sa valeur est spéciale.


3
@ Kevin - dans quelle langue testez-vous? Certains frameworks de test vous permettent de configurer des fournisseurs de données qui renvoient un tableau de tableaux de valeurs à tester
HorusKol

10
Bien que je sois d’accord avec l’idée, sachez que cette pratique peut également introduire de nouvelles erreurs, comme si vous extrayiez accidentellement une valeur comme 0.05fun int. :)
Jeff Bowman

5
+1 - bonnes choses. Ce n'est pas parce que vous vous fiche de la valeur d'une valeur que cela signifie que ce n'est pas encore un chiffre magique ...
Robbie Dee

2
@PieterB: autant que je sache, c'est la faute de C et C ++, qui a formalisé la notion de constvariable.
Steve Jessop

2
Avez-vous nommé vos variables de la même façon que les paramètres nommés de calculateCompoundInterest? Si tel est le cas, la frappe supplémentaire est une preuve de travail que vous avez lue dans la documentation de la fonction que vous testez, ou au moins avez copié les noms que votre IDE vous a donnés. Je ne sais pas dans quelle mesure cela informe le lecteur de l'intention du code, mais si vous transmettez au moins les paramètres dans le mauvais ordre, ils peuvent au moins indiquer le but recherché.
Steve Jessop

20

Si vous utilisez des nombres arbitraires uniquement pour voir ce qu'ils font, alors vous recherchez réellement des données de test générées aléatoirement, ou des tests basés sur des propriétés.

Par exemple, Hypothesis est une excellente bibliothèque Python pour ce type de test, basée sur QuickCheck .

Imaginez un test unitaire normal comme suit:

  1. Mettre en place des données.
  2. Effectuer des opérations sur les données.
  3. Affirmer quelque chose sur le résultat.

L'hypothèse vous permet d'écrire des tests qui ressemblent à ceci:

  1. Pour toutes les données correspondant à certaines spécifications.
  2. Effectuer des opérations sur les données.
  3. Affirmer quelque chose sur le résultat.

L'idée est de ne pas vous contraindre à vos propres valeurs, mais de choisir des valeurs aléatoires qui peuvent être utilisées pour vérifier que vos fonctions correspondent à leurs spécifications. Il est important de noter que ces systèmes se souviendront généralement de toute entrée qui échouera, puis s’assureront que ces entrées seront toujours testées à l’avenir.

Le point 3 peut être déroutant pour certaines personnes, alors clarifions. Cela ne signifie pas que vous affirmez la réponse exacte - c'est évidemment impossible à faire pour une entrée arbitraire. Au lieu de cela, vous affirmez quelque chose à propos d'une propriété du résultat. Par exemple, vous pouvez affirmer qu'après l'ajout d'un élément à une liste, celui-ci devient non vide ou qu'un arbre de recherche binaire à auto-équilibrage est réellement équilibré (en utilisant les critères de cette structure de données particulière).

Dans l’ensemble, choisir des nombres arbitraires vous-même est probablement très mauvais. Cela n’ajoute pas vraiment beaucoup de valeur et est source de confusion pour quiconque le lit. Il est bon de générer automatiquement un ensemble de données de test aléatoires et de les utiliser efficacement. Trouver une hypothèse ou une bibliothèque semblable à QuickCheck pour la langue de votre choix est probablement un meilleur moyen d'atteindre vos objectifs tout en restant compréhensible pour les autres.


11
Les tests aléatoires peuvent trouver des bogues difficiles à reproduire, mais les tests au hasard ne permettent pas de détecter des bogues reproductibles. Assurez-vous de capturer tous les échecs de test avec un scénario de test reproductible spécifique.
JBRWilkinson

5
Et comment savez-vous que votre test unitaire n'est pas perturbé lorsque vous "affirmez quelque chose à propos du résultat" (dans ce cas, recalculez ce qu'est l' fooinformatique) ...? Si vous êtes sûr à 100% que votre code donne la bonne réponse, il vous suffira alors de l'insérer dans le programme et de ne pas le tester. Si vous ne l'êtes pas, alors vous devez tester le test et je pense que tout le monde voit où cela se passe.

2
Oui, si vous transmettez des entrées aléatoires à une fonction, vous devez connaître le résultat pour pouvoir affirmer que celle-ci fonctionne correctement. Avec des valeurs de test fixes / choisies, vous pouvez bien sûr le résoudre manuellement, etc. Cependant, toute méthode automatisée permettant de déterminer si le résultat est correct est soumise exactement aux mêmes problèmes que la fonction que vous testez. Soit vous utilisez votre implémentation (ce que vous ne pouvez pas faire parce que vous testez si cela fonctionne), soit vous écrivez une nouvelle implémentation qui risque tout autant d'être boguée (ou plus, sinon, vous utiliseriez le plus probable d'être correcte) )
Chris

7
@ NajibIdrissi - pas nécessairement. Vous pouvez, par exemple, vérifier que l'application de l'inverse de l'opération que vous testez au résultat renvoie la valeur initiale avec laquelle vous avez commencé. Vous pouvez également tester les invariants attendus (par exemple, pour tous les calculs d'intérêts en djours, le calcul en djours + 1 mois doit correspondre à un taux de pourcentage mensuel connu supérieur), etc.
Jules

12
@Chris - Dans de nombreux cas, il est plus facile de vérifier les résultats que de les générer. Bien que ce ne soit pas vrai dans toutes les circonstances, il y en a beaucoup. Exemple: l'ajout d'une entrée à un arbre binaire équilibré devrait aboutir à un nouvel arbre également équilibré ... facile à tester, assez difficile à mettre en œuvre dans la pratique.
Jules

11

Le nom de votre test unitaire devrait fournir la majeure partie du contexte. Pas à partir des valeurs des constantes. Le nom / la documentation pour un test doit donner le contexte approprié et une explication des nombres magiques présents dans le test.

Si cela ne suffit pas, un peu de documentation devrait pouvoir la fournir (via le nom de la variable ou une chaîne de documentation). Gardez à l'esprit que la fonction elle-même a des paramètres qui, espérons-le, ont des noms significatifs. Copier ceux-ci dans votre test pour nommer les arguments est plutôt inutile.

Enfin, si vos faiblesses sont suffisamment compliquées pour que cela soit difficile / pratique, vous avez probablement trop de fonctions compliquées et vous pouvez vous demander pourquoi.

Plus vous écrivez des tests, plus votre code sera mauvais. Si vous sentez le besoin de nommer vos valeurs de test pour rendre le test clair, cela suggère fortement que votre méthode actuelle nécessite une meilleure dénomination et / ou documentation. Si vous trouvez qu'il est nécessaire de nommer les constantes dans les tests, je chercherai pourquoi vous en avez besoin - probablement, le problème n'est pas le test lui-même mais sa mise en œuvre.


Cette réponse semble concerner la difficulté de déduire le but d'un test alors que la vraie question concerne les nombres magiques dans les paramètres de la méthode ...
Robbie Dee

@RobbieDee Le nom / la documentation d'un test devrait donner le contexte et l'explication appropriés des nombres magiques présents dans le test. Sinon, ajoutez de la documentation ou renommez le test pour qu'il soit plus clair.
Enderland

Il serait toujours préférable de donner les noms des nombres magiques. Si le nombre de paramètres devait changer, la documentation risque de devenir obsolète.
Robbie Dee

1
@RobbieDee garde à l'esprit que la fonction elle-même a des paramètres qui, espérons-le, ont des noms significatifs. Copier ceux-ci dans votre test pour nommer les arguments est plutôt inutile.
Enderland

"Espérons" hein? Pourquoi ne pas simplement coder la chose correctement et supprimer ce qui est apparemment un chiffre magique comme Philipp l'a déjà indiqué ...
Robbie Dee

9

Cela dépend fortement de la fonction que vous testez. Je connais beaucoup de cas où les nombres individuels n'ont pas de signification particulière en soi, mais le cas de test dans son ensemble est construit de manière réfléchie et a donc une signification spécifique. C'est ce que l'on devrait documenter d'une manière ou d'une autre. Par exemple, si foovraiment est une méthode testForTrianglequi décide si les trois nombres peuvent être des longueurs valides des arêtes d’un triangle, vos tests peuvent ressembler à ceci:

// standard triangle with area >0
assertEqual(testForTriangle(2, 3, 4), true);

// degenerated triangle, length of two edges match the length of the third
assertEqual(testForTriangle(1, 2, 3), true);  

// no triangle
assertEqual(testForTriangle(1, 2, 4), false); 

// two sides equal
assertEqual(testForTriangle(2, 2, 3), true);

// all three sides equal
assertEqual(testForTriangle(4, 4, 4), true);

// degenerated triangle / point
assertEqual(testForTriangle(0, 0, 0), true);  

etc. Vous pouvez améliorer ceci et transformer les commentaires en un paramètre de message assertEqualqui sera affiché en cas d'échec du test. Vous pouvez ensuite améliorer cela davantage et le transformer en un test piloté par les données (si votre framework de test le supporte). Néanmoins, vous vous faites une faveur si vous inscrivez dans le code la raison pour laquelle vous avez choisi ces chiffres et lequel des différents comportements que vous testez avec chaque cas.

Bien sûr, pour d'autres fonctions, les valeurs individuelles des paramètres peuvent avoir plus d'importance. Par conséquent, utiliser un nom de fonction dépourvu de sens, comme foopour demander comment traiter la signification des paramètres, n'est probablement pas la meilleure idée.


Solution sensible.
user1725145

6

Pourquoi voulons-nous utiliser des constantes nommées plutôt que des nombres?

  1. DRY - Si j'ai besoin de la valeur à 3 endroits, je veux seulement la définir une fois, afin de pouvoir la changer à un endroit si elle change.
  2. Donner un sens aux nombres.

Si vous écrivez plusieurs tests unitaires, chacun avec un assortiment de 3 nombres (startBalance, intérêt, années), je mettrais simplement les valeurs dans le test unitaire en tant que variables locales. La plus petite portée à laquelle ils appartiennent.

testBigInterest()
  var startBalance = 10;
  var interestInPercent = 100
  var years = 2
  assert( calcCreditSum( startBalance, interestInPercent, years ) == 40 )

testSmallInterest()
  var startBalance = 50;
  var interestInPercent = .5
  var years = 1
  assert( calcCreditSum( startBalance, interestInPercent, years ) == 50.25 )

Si vous utilisez un langage qui autorise les paramètres nommés, c'est bien sûr superflous. Là, je voudrais juste emballer les valeurs brutes dans l'appel de méthode. Je ne peux pas imaginer de refactoring rendant cette déclaration plus concise:

testBigInterest()
  assert( calcCreditSum( startBalance:       10
                        ,interestInPercent: 100
                        ,years:               2 ) = 40 )

Ou utilisez un framework de test qui vous permettra de définir les scénarios de test dans un tableau ou un format de carte:

testcases = { {
                Name: "BigInterest"
               ,StartBalance:       10
               ,InterestInPercent: 100
               ,Years:               2
              }
             ,{ 
                Name: "SmallInterest"
               ,StartBalance:       50
               ,InterestInPercent:  .5
               ,Years:               1
              }
            }

3

... mais dans ce cas, les chiffres n'ont en réalité aucune signification

Les nombres sont utilisés pour appeler une méthode, donc la prémisse ci-dessus est inexacte. Vous ne vous souciez peut-être pas des chiffres, mais cela n’est pas pertinent. Oui, vous pouvez en déduire à quoi servent les nombres dans certaines magies de l'IDE, mais il serait bien mieux de donner les noms de valeurs, même s'ils correspondent uniquement aux paramètres.


1
Ce n'est cependant pas nécessairement vrai - comme dans l'exemple du test unitaire le plus récent que j'ai écrit ( assertEqual "Returned value" (makeKindInt 42) (runTest "lvalue_operators")). Dans cet exemple, il 42s’agit simplement d’une valeur d’espace réservé générée par le code du script de test nommé lvalue_operators, puis vérifié lorsqu’il est renvoyé par le script. Cela n'a aucun sens, sinon que la même valeur se produit à deux endroits différents. Quel serait le nom approprié ici qui donne un sens utile?
Jules

3

Si vous souhaitez tester une fonction pure sur un ensemble d'entrées qui ne sont pas des conditions aux limites, vous souhaiterez presque certainement la tester sur tout un ensemble d'ensembles d'entrées qui ne sont pas (et sont) des conditions aux limites. Et pour moi, cela signifie qu’il devrait y avoir une table de valeurs avec laquelle appeler la fonction et une boucle:

struct test_foo_values {
    int bar;
    int baz;
    int blurf;
    int expected;
};
const struct test_foo_values test_foo_with[] = {
   { 1, 2, 3, 17 },
   { 2, 4, 9, 34 },
   // ... many more here ...
};

for (size_t i = 0; i < ARRAY_SIZE(test_foo_with); i++) {
    const struct test_foo_values *c = test_foo_with[i];
    assertEqual(foo(c->bar, c->baz, c->blurf), c->expected);
}

Des outils tels que ceux suggérés dans la réponse de Dannnno peuvent vous aider à construire la table de valeurs à tester. bar, bazet blurfdevrait être remplacé par des noms significatifs, comme indiqué dans la réponse de Philipp .

(Principe général discutable ici: les nombres ne sont pas toujours des "nombres magiques" qui ont besoin de noms; ils peuvent plutôt être des données . S'il est logique de placer vos nombres dans un tableau, peut-être un tableau d'enregistrements, ils sont probablement des données. Inversement, si vous pensez avoir des données sur vos mains, envisagez de les placer dans un tableau et d’en acquérir davantage.)


1

Les tests sont différents du code de production et, du moins dans les tests unitaires écrits dans Spock, qui sont courts et précis, je n’ai aucun problème à utiliser des constantes magiques.

Si un test a une longueur de 5 lignes et suit le schéma de base donné / When / then, extraire ces valeurs en constantes ne ferait que rendre le code plus long et plus difficile à lire. Si la logique est "Quand j'ajoute un utilisateur nommé Smith, je vois que l'utilisateur Smith est retourné dans la liste des utilisateurs", l'extraction de "Smith" dans une constante est inutile.

Ceci s'applique bien entendu si vous pouvez facilement faire correspondre les valeurs utilisées dans le bloc "donné" (setup) avec celles trouvées dans les blocs "when" et "then". Si votre configuration de test est séparée (en code) de l'endroit où les données sont utilisées, il peut être préférable d'utiliser des constantes. Mais comme les tests sont mieux autonomes, la configuration est généralement proche du lieu d'utilisation et le premier cas s'applique, ce qui signifie que les constantes magiques sont tout à fait acceptables dans ce cas.


1

Tout d'abord, convenons que le «test unitaire» est souvent utilisé pour couvrir tous les tests automatisés écrits par un programmeur, et qu'il est inutile de débattre de la manière dont chaque test devrait être appelé….

J'ai travaillé sur un système où le logiciel prenait beaucoup de données et élaborait une «solution» qui devait respecter certaines contraintes tout en optimisant d'autres nombres. Il n'y avait pas de bonne réponse, le logiciel devait donc donner une réponse raisonnable.

Il l'a fait en utilisant beaucoup de nombres aléatoires pour obtenir un point de départ, puis en utilisant un «alpiniste» pour améliorer le résultat. Cela a été couru plusieurs fois, en choisissant le meilleur résultat. Un générateur de nombres aléatoires peut être initialisé, de sorte qu'il donne toujours les mêmes nombres dans le même ordre. Par conséquent, si le test établit une valeur initiale, nous savons que le résultat sera le même à chaque exécution.

Nous avons eu beaucoup de tests qui ont fait ce qui précède, et nous avons vérifié que les résultats étaient les mêmes. Cela nous a dit que nous n'avions pas changé ce que cette partie du système avait fait par erreur lors de la refactorisation, etc. ce que cette partie du système a fait.

La maintenance de ces tests était coûteuse, car toute modification apportée au code d'optimisation aurait pour effet de rompre les tests, mais ils ont également révélé des erreurs dans le code beaucoup plus volumineux qui pré-traitait les données et post-traitait les résultats.

Lorsque nous avons «moqué» la base de données, vous pouvez appeler ces tests «tests unitaires», mais «l'unité» était plutôt grande.

Souvent, lorsque vous travaillez sur un système sans test, vous effectuez une opération similaire à celle décrite ci-dessus, de sorte que vous puissiez confirmer que votre refactoring ne modifie pas la sortie. j'espère que de meilleurs tests sont écrits pour le nouveau code!


1

Je pense que dans ce cas, les nombres devraient être appelés des nombres arbitraires, plutôt que des nombres magiques, et commenter simplement la ligne comme "un cas test arbitraire".

Bien sûr, certains chiffres magiques peuvent aussi être arbitraires, comme pour les valeurs "handle" uniques (qui doivent être remplacées par des constantes nommées, bien sûr), mais peuvent également être des constantes précalculées du type "vitesse d'un moineau européen non chargé en tranches de quinzaine", où la valeur numérique est branchée sans commentaire ni contexte utile.


0

Je n’irai pas jusqu’à dire un oui / non définitif, mais voici quelques questions que vous devriez vous poser lorsque vous décidez si cela vous convient ou non.

  1. Si les chiffres ne veulent rien dire, pourquoi sont-ils là au départ? Peuvent-ils être remplacés par autre chose? Pouvez-vous effectuer une vérification basée sur des appels de méthode et un flux plutôt que sur des assertions de valeur? Considérons quelque chose comme la verify()méthode de Mockito qui vérifie si certains appels de méthode ont été faits pour simuler des objets au lieu d'affirmer une valeur.

  2. Si les chiffres font quelque chose de méchant, alors ils devraient être affectés à des variables qui sont nommés de façon appropriée.

  3. L' écriture du nombre 2que TWOpourrait être utile dans certains contextes, et non pas tant dans d' autres contextes.

    • Par exemple: assertEquals(TWO, half_of(FOUR))cela a du sens pour quelqu'un qui lit le code. Ce que vous testez est immédiatement clair .
    • Cependant , si votre test est assertEquals(numCustomersInBank(BANK_1), TWO), cela ne fait pas que beaucoup de sens. Pourquoi ne BANK_1contient deux clients? Que testons-nous?
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.