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 foo(1, 2, 3)
est censé renvoyer 17, je pourrais écrire ceci:
assertEqual(foo(1, 2, 3), 17)
Ces nombres sont purement arbitraires et n'ont pas de sens plus large (ce ne sont pas, par exemple, les conditions aux limites, bien que je les teste également). J'aurais du mal à trouver de bons noms pour ces chiffres et à écrire quelque chose comme const int TWO = 2;
est évidemment inutile. Est-il correct d'écrire des tests comme celui-ci, ou dois-je factoriser les nombres en constantes?
Dans Tous les nombres magiques sont-ils créés de la même manière? , nous avons appris que les nombres magiques sont OK si la signification est évidente du contexte, mais dans ce cas, les nombres n'ont en fait aucune signification.
Quand avez-vous vraiment des chiffres qui n'ont aucune signification?
Habituellement, lorsque les nombres ont une signification, vous devez les affecter aux 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 startBalance
pour indiquer quelle est la signification de la variable mais pas que sa valeur est en quelque sorte spéciale.
Si vous utilisez des nombres arbitraires juste pour voir ce qu'ils font, alors ce que vous cherchez vraiment, ce sont probablement des données de test générées de manière aléatoire ou des tests basés sur les propriétés.
Par exemple, Hypothesis est une bibliothèque cool Python pour ce type de test, et est basée sur QuickCheck .
Considérez un test unitaire normal comme quelque chose comme ceci:
- Configurez des données.
- Effectuez certaines opérations sur les données.
- Affirmez quelque chose sur le résultat.
L'hypothèse vous permet d'écrire des tests qui ressemblent plutôt à ceci:
- Pour toutes les données correspondant à certaines spécifications.
- Effectuez certaines opérations sur les données.
- Affirmez 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 souviennent généralement de toute entrée qui échoue, puis s'assurent que ces entrées sont 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 sur une propriété du résultat. Par exemple, vous pouvez affirmer qu'après avoir ajouté quelque chose à une liste, il devient non vide ou qu'un arbre de recherche binaire auto-équilibré est en fait é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 assez mauvais - cela n'ajoute pas vraiment un tas de valeur et est déroutant pour quiconque le lit. Générer automatiquement un tas de données de test aléatoires et les utiliser efficacement est une bonne chose. Trouver une hypothèse ou une bibliothèque de type QuickCheck pour la langue de votre choix est probablement un meilleur moyen d'atteindre vos objectifs tout en restant compréhensible pour les autres.
Le nom de votre test unitaire devrait fournir la majeure partie du contexte. Pas à partir des valeurs des constantes. Le nom/la documentation d'un test doit donner le contexte et l'explication appropriés des nombres magiques présents dans le test.
Si cela ne suffit pas, un peu de documentation devrait être en mesure de le fournir (que ce soit par le nom de la variable ou une docstring). Gardez à l'esprit que la fonction elle-même a des paramètres qui, espérons-le, ont des noms significatifs. Les copier dans votre test pour nommer les arguments est plutôt inutile.
Et enfin, si vos tests sont suffisamment compliqués pour que ce soit difficile/pas pratique, vous avez probablement des fonctions trop compliquées et pourriez vous demander pourquoi.
Plus vous écrivez des tests de manière bâclée, pire sera votre code réel. Si vous ressentez 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 la nécessité de nommer des constantes dans les tests, je chercherais à savoir pourquoi vous en avez besoin - le problème n'est probablement pas le test lui-même mais l'implémentation
Cela dépend fortement de la fonction que vous testez. Je connais de nombreux cas où les nombres individuels n'ont pas de signification particulière en eux-mêmes, 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 foo
est vraiment une méthode testForTriangle
qui décide si les trois nombres peuvent être des longueurs valides des bords d'un triangle, vos tests pourraient 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 cela et transformer les commentaires en un paramètre de message de assertEqual
qui sera affiché lorsque le test échoue. Vous pouvez ensuite l'améliorer davantage et le refactoriser en un test basé sur les données (si votre framework de test le prend en charge). Néanmoins, vous vous rendez service si vous mettez une note dans le code pourquoi vous avez choisi ces chiffres et lequel des divers comportements que vous testez avec le cas individuel.
Bien sûr, pour d'autres fonctions, les valeurs individuelles des paramètres peuvent être plus importantes, donc l'utilisation d'un nom de fonction sans signification comme foo
pour demander comment traiter la signification des paramètres n'est probablement pas la meilleure idée.
Pourquoi voulons-nous utiliser des constantes nommées au lieu de nombres?
Si vous écrivez plusieurs tests unitaires, chacun avec un assortiment de 3 nombres (startBalance, intérêt, années) - je voudrais simplement emballer les valeurs dans le test unitaire en tant que variables locales. La plus petite portée où 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 des paramètres nommés, c'est bien sûr superflu. Là, je voudrais simplement 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 cas 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
}
}
... mais dans ce cas, les chiffres n'ont en fait aucune signification
Les chiffres sont utilisés pour appeler une méthode, donc la prémisse ci-dessus est sûrement incorrecte. Vous ne pouvez pas soins quels sont les chiffres, mais c'est à côté du point. Oui, vous pouvez déduire à quoi les nombres sont utilisés par certains IDE sorcellerie mais il serait bien mieux si vous donniez simplement les noms de valeurs - même s'ils correspondent simplement aux paramètres.
Si vous voulez tester un fonction pure sur un ensemble d'entrées qui ne sont pas des conditions aux limites, alors vous voulez certainement le tester sur un tas entier d'ensembles des 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 lesquelles 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 comme ceux suggérés dans réponse de Dannnno peuvent vous aider à construire la table des valeurs à tester. bar
, baz
et blurf
doivent être remplacés par des noms significatifs comme indiqué dans Réponse de Philipp .
(Principe général discutable ici: les nombres ne sont pas toujours des "nombres magiques" qui ont besoin de noms; à la place, les nombres peuvent être des données . Si cela avait du sens de mettre vos numéros dans un tableau, peut-être un tableau d'enregistrements, alors ce sont probablement des données. Inversement, si vous pensez que vous pourriez avoir des données entre les mains, pensez à les mettre dans un tableau et à en acquérir davantage.)
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 "cas de test arbitraire".
Bien sûr, certains nombres magiques peuvent également être arbitraires, comme pour les valeurs de "poignée" uniques (qui doivent être remplacées par des constantes nommées, bien sûr), mais peuvent également être des constantes précalculées comme "la vitesse d'un moineau européen à vide en furlongs par quinzaine", où la valeur numérique est branchée sans commentaires ni contexte utile.
Tout d'abord, convenons que le "test unitaire" est souvent utilisé pour couvrir tous les tests automatisés qu'un programmeur écrit, et qu'il est inutile de débattre sur le nom de chaque test…
J'ai travaillé sur un système où le logiciel a pris beaucoup d'entrées et élaboré une "solution" qui devait respecter certaines contraintes, tout en optimisant d'autres nombres. Il n'y avait pas de bonnes réponses, donc le logiciel devait juste 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é exécuté plusieurs fois, en choisissant le meilleur résultat. Un générateur de nombres aléatoires peut être amorcé, de sorte qu'il donne toujours les mêmes nombres dans le même ordre, donc si le test définit une amorce, nous savons que le résultat serait le même à chaque exécution.
Nous avons eu beaucoup de tests qui ont fait ce qui précède, et vérifié que les résultats étaient les mêmes, cela nous a dit que nous n'avait pas changé ce que cette partie du système a fait par erreur lors de la refactorisation, etc. dites-nous quoi que ce soit de correct cette partie du système.
Ces tests étaient coûteux à entretenir, car toute modification du code d'optimisation romprait les tests, mais ils ont également trouvé des bogues dans le code beaucoup plus grand qui prétraitait les données et post-traitait les résultats.
Comme nous nous sommes moqués de la base de données, vous pouvez appeler ces tests des "tests unitaires", mais l '"unité" était plutôt grande.
Souvent, lorsque vous travaillez sur un système sans test, vous faites quelque chose comme ci-dessus, afin de pouvoir confirmer que votre refactoring ne modifie pas la sortie; j'espère que de meilleurs tests sont écrits pour le nouveau code!
Les tests sont différents du code de production et, au moins dans les tests unitaires écrits en Spock, qui sont courts et précis, je n'ai aucun problème à utiliser des constantes magiques.
Si un test fait 5 lignes et suit le schéma de base donné/quand/alors, extraire ces valeurs en constantes ne ferait que rendre le code plus long et plus difficile à lire. Si la logique est "Lorsque j'ajoute un utilisateur nommé Smith, je vois l'utilisateur Smith renvoyé dans la liste d'utilisateurs", il est inutile d'extraire "Smith" dans une constante.
Cela s'applique bien sûr si vous pouvez facilement faire correspondre les valeurs utilisées dans le bloc "donné" (configuration) à celles trouvées dans les blocs "quand" et "puis". 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.
Je ne me risquerai pas à dire un oui/non définitif, mais voici quelques questions que vous devriez vous poser pour décider si c'est OK ou non.
Si les chiffres ne veulent rien dire, pourquoi sont-ils là en premier lieu? 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 au lieu d'assertions de valeur? Considérons quelque chose comme la méthode verify()
de Mockito qui vérifie si certains appels de méthode ont été effectués pour simuler des objets au lieu d'affirmer une valeur.
Si les nombres do signifient quelque chose, alors ils doivent être attribués à des variables qui sont nommées de manière appropriée.
L'écriture du nombre 2
Sous la forme TWO
peut être utile dans certains contextes, et pas tant dans d'autres contextes.
assertEquals(TWO, half_of(FOUR))
a du sens pour quelqu'un qui lit le code. Il est immédiatement clair quoi vous testez.assertEquals(numCustomersInBank(BANK_1), TWO)
, cela n'a pas de sens que. PourquoiBANK_1
Contient-il deux clients? Quoi testons-nous?