Comment écrire de bons tests unitaires dans la programmation fonctionnelle
j'utilise des fonctions au lieu de classes, et je trouve que je ne peux pas dire quand une autre fonction sur laquelle elle s'appuie est une dépendance qui devrait être individuellement testée par unité ou un détail d'implémentation interne qui ne devrait pas l'être. Comment pouvez-vous dire lequel c'est?
un petit contexte: j'écris un interpréteur de Lisp très simple qui a un eval()
fonction. Il va avoir beaucoup de responsabilités, trop de responsabilités en fait, comme évaluer les symboles différemment que les listes (tout le reste évalue à lui-même). Lors de l'évaluation des symboles, il a son propre workflow complexe (environnement-lookup), et lors de l'évaluation des listes, il est encore plus compliqué, car la liste peut être une macro, une fonction, ou une forme spéciale, chacun d'entre eux ont leur propre workflow complexe et un ensemble de responsabilités.
je ne peux pas dire si mon eval_symbol()
et eval_list()
les fonctions doivent être considérées comme des détails internes de mise en oeuvre de eval()
qui devrait être testé par eval()
's propres tests unitaires, ou de véritables dépendances à part entière qui devraient être testées à l'unité indépendamment de eval()
's des tests unitaires.
5 réponses
une motivation importante pour le concept de "test unitaire" est de contrôler l'explosion combinatoire des cas d'essai requis. Regardons les exemples de eval
,eval_symbol
et eval_list
.
Dans le cas de eval_symbol
, nous allons tester les éventualités où le symbole de la liaison est:
manquant (c'est à dire le symbole non relié)
dans l'environnement mondial
est directement à l'intérieur de la environnement actuel
hérité d'un environnement contenant
l'occultation d'une autre liaison
... et ainsi de suite
Dans le cas de eval_list
, nous voulons tester (entre autres choses) ce qui se passe lorsque la position de la fonction list contient un symbole avec:
pas de fonction ou une macro de liaison
une fonction reliure
une macro de liaison
eval_list
appeler eval_symbol
chaque fois qu'il faut lier un symbole (en supposant un LISP-1, c'est-à-dire). Disons qu'il y a S cas d'essai pour eval_symbol
et L cas de test liés au symbole pour eval_list
. Si nous testons chacune de ces fonctions séparément, nous pourrions sortir avec à peu près S + L cas d'essai liés au symbole. Cependant, si nous souhaitons traiter eval_list
comme une boîte noire et de le tester exhaustivement sans aucune connaissance qu'il utilise eval_symbol
en interne, nous sommes confrontés à S x L cas d'essai liés au symbole (par exemple, liaison à une fonction globale, liaison à une macro globale, liaison à une fonction locale, liaison à une macro locale, liaison à une fonction héritée, liaison à une macro héritée, etc.). C'est beaucoup plus de cas. eval
est encore pire: comme une boîte noire le nombre de combinaisons peut devenir incroyablement grand -- d'où le terme combinatoire explosion.
ainsi, nous sommes confrontés à un choix de pureté théorique par rapport à l'aspect pratique réel. Il ne fait aucun doute qu'un ensemble complet de cas d'essai qui n'exerce que l '"API publique" (dans ce cas, eval
) donne la plus grande confiance qu'il n'y a pas de bugs. Après tout, en utilisant toutes les combinaisons possibles, nous pouvons déceler des bogues d'intégration subtils. Cependant, le nombre de ces combinaisons peut être trop grand pour empêcher de tels essais. Ne pas mentionnez que le programmeur va probablement faire des erreurs (ou devenir fou) en examinant un grand nombre de cas de test qui ne diffèrent que de manière subtile. En testant à l'Unité les plus petits composants internes, on peut réduire considérablement le nombre de cas de test requis tout en conservant un niveau élevé de confiance dans les résultats -- une solution pratique.
donc, je pense que la ligne directrice pour identifier la granularité des tests unitaires est la suivante: si le nombre de cas d'essai est inconfortablement grand, commencer à la recherche d'unités plus petites à tester.
dans le cas présent, je suis absolument en faveur des tests eval
,eval-list
et eval-symbol
en tant qu'unités séparées précisément à cause de l'explosion combinatoire. Lors de la rédaction des tests pour eval-list
, vous pouvez compter sur eval-symbol
être solide comme le roc, et de limiter votre attention sur la fonctionnalité eval-list
ajoute à part entière. Il y a probablement d'autres unités vérifiables dans les eval-list
, comme eval-function
,eval-macro
,eval-lambda
,eval-arglist
et ainsi de suite.
mon conseil est assez simple: "commencez quelque part!"
- si vous voyez le nom d'un def (ou deffun) qui semble fragile, Eh bien, vous voulez probablement le tester, n'est-ce pas?
- si vous avez de la difficulté à comprendre comment votre code client peut se connecter avec une autre unité de code, Eh bien, vous voulez probablement écrire quelques tests quelque part qui vous permettent de créer des exemples de la façon d'utiliser correctement cette fonction.
- si certaines fonctions semble sensible aux valeurs de données, bien, vous pourriez vouloir écrire quelques tests qui vérifient non seulement qu'il peut traiter toutes les entrées raisonnables correctement, mais aussi spécifiquement exercer des conditions limites et des entrées de données impaires ou inhabituelles.
- Tout ce qui semble sujet aux bogues devrait avoir des tests.
- Tout ce qui ne semble pas clair devrait avoir des tests.
- Tout ce qui semble compliqué devrait avoir des tests.
- Tout ce qui semble important devrait avoir des tests.
plus Tard, vous vous pouvez augmenter votre couverture à 100%. Mais vous constaterez que vous obtiendrez probablement 80% de vos résultats réels à partir des premiers 20% de votre codage de test unitaire (inversé "loi du petit nombre Critique").
donc, pour passer en revue le point principal de mon humble approche, " commencez quelque part!"
en ce qui concerne la dernière partie de votre question, je vous recommande de penser à une éventuelle récursion ou à une éventuelle réutilisation supplémentaire par les fonctions "client" que vous ou les développeurs subséquents pourriez créer dans le futur qui s'appellerait aussi eval_symbol() ou eval_list().
en ce qui concerne la récursion, le style de programmation fonctionnelle l'utilise beaucoup et il peut être difficile de faire les choses correctement, surtout pour ceux d'entre nous qui viennent de la programmation procédurale ou orientée objet, où la récursion semble rarement rencontrée. La meilleure façon d'obtenir une recursion correcte est de cibler précisément toutes les fonctionnalités de recursive avec des tests unitaires pour s'assurer que tous les cas possibles d'utilisation recursive sont valider.
en ce qui concerne la réutilisation, si vos fonctions sont susceptibles d'être invoquées par autre chose qu'une seule utilisation par votre fonction eval (), elles devraient probablement être traitées comme de véritables dépendances qui méritent des tests unitaires indépendants.
Comme un dernier indice, le terme "unité" a une définition technique dans le domaine des tests unitaires " le plus petit logiciel de code qui puisse être testé isolément.". C'est une très vieille définition fondamentale qui peut clarifiez rapidement votre situation.
ceci est quelque peu orthogonal au contenu de votre question, mais répond directement à la question posée dans le titre.
la programmation fonctionnelle idiomatique implique la plupart du temps des morceaux de code sans effet secondaire, ce qui rend les tests unitaires plus faciles en général. Définir un test unitaire implique généralement d'affirmer une propriété logique sur la fonction à l'essai, plutôt que de construire de grandes quantités d'échafaudages fragiles juste pour établir un test approprié environnement.
à titre d'exemple, disons que nous testons extendEnv
et lookupEnv
fonctionne en tant qu'interprète. Un bon test unitaire pour ces fonctions vérifierait que si nous prolongeons deux fois un environnement avec la même variable liée à des valeurs différentes, seule la valeur la plus récente est retournée par lookupEnv
.
dans Haskell, un test pour cette propriété pourrait ressembler à:
test =
let env = extendEnv "x" 5 (extendEnv "x" 6 emptyEnv)
in lookupEnv env "x" == Just 5
ce test nous donne une certaine assurance, et ne nécessite aucune installation ou démontage autres que la création de la env
valeur que nous sommes intéressés à tester. Cependant, les valeurs de l'essai sont très spécifiques. Cela ne teste qu'un environnement particulier, donc un bug subtil pourrait facilement passer. Nous préférons faire une déclaration plus générale: pour toutes les variables x
et v
et w
, un environnement env
prolongé deux fois avec x
lié v
après x
lié w
,lookupEnv env x == Just w
.
En général, nous avons besoin d'une preuve formelle (peut-être mécanisé avec un assistant de preuve comme Coq, Agda, ou Isabelle) afin de montrer qu'une propriété comme celle-ci tient. Cependant, nous pouvons nous rapprocher beaucoup plus que de spécifier des valeurs de test en utilisant QuickCheck, une bibliothèque disponible pour la plupart des langages fonctionnels qui génère de grandes quantités d'entrées de test arbitraires pour les propriétés que nous définissons comme des fonctions booléennes:
prop_test x v w env' =
let env = extendEnv x v (extendEnv x w env')
in lookupEnv env x == Just w
à l'invite, Nous pouvons avoir QuickCheck générer des entrées arbitraires à cette fonction, et voir si elle reste vrai pour tous:
*Main> quickCheck prop_test
+++ OK, passed 100 tests.
*Main> quickCheckWith (stdArgs { maxSuccess = 1000 }) prop_test
+++ OK, passed 1000 tests.
QuickCheck utilise de la magie très agréable (et extensible) pour produire ces valeurs arbitraires, mais c'est la programmation fonctionnelle qui rend ces valeurs utiles. En faisant des effets secondaires l'exception (désolé) plutôt que la règle, les tests unitaires deviennent moins une tâche de spécifier manuellement les cas de test, et plus une question d'affirmer des propriétés généralisées sur le comportement de vos fonctions.
ce processus va surprendre vous fréquemment. Le raisonnement à ce niveau donne à votre esprit des chances supplémentaires de remarquer des défauts dans votre conception, ce qui rend plus probable que vous attraperez des erreurs avant même d'exécuter votre code.
Je ne suis pas vraiment au courant d'une règle empirique particulière pour cela. Mais il semble que vous devriez vous poser deux questions:
- Pouvez-vous définir le but de la
eval_symbol
eteval_list
sans avoir besoin de dire "partie de la mise en œuvre deeval
? - Si vous voyez un test échoue pour
eval
, il serait utile de voir si des tests poureval_symbol
eteval_list
également échouer?
Si la réponse à l'une de ces oui, j'aimerais les tester séparément.
il y a quelques mois, j'ai écrit un simple interpréteur "presque Lisp" en Python pour une tâche. Je l'ai conçu en utilisant le modèle de conception de L'interprète, unit a testé le code d'évaluation. Puis j'ai ajouté le code d'impression et d'analyse et j'ai transformé les fixtures de test de la représentation de syntaxe abstraite (objets) en chaînes de syntaxe concrètes. Une partie de la tâche était de programmer des fonctions simples de traitement de liste récursive, donc je les ai ajoutés comme tests fonctionnels.
Pour répondre à votre question en général, les règles sont les mêmes que pour OO. Vous devriez couvrir toutes vos fonctions publiques. Dans OO les méthodes publiques font partie d'une classe ou d'une interface, dans la programmation fonctionnelle vous avez le plus souvent le contrôle de visibilité basé sur des modules (similaire aux interfaces). Idéalement, vous auriez une couverture complète pour toutes les fonctions, mais si cela n'est pas possible, envisagez L'approche TDD - commencez par écrire des tests pour ce que vous savez que vous avez besoin et les mettre en œuvre. Les fonctions auxiliaires seront le résultat du remaniement et comme vous avez écrit des tests pour tout ce qui est important avant, si les tests fonctionnent après le remaniement, vous êtes fait et pouvez écrire un autre test (iterate).
Bonne chance!