Tests unitaires - l'avantage des tests unitaires avec des changements de contrat?
Récemment, j'ai eu une discussion intéressante avec un collègue sur les tests unitaires. Nous discutions lorsque le maintien des tests unitaires est devenu moins productif, lorsque vos contrats changent.
Peut-être que n'importe qui peut m'éclairer comment aborder ce problème. Laissez-moi élaborer:
Disons donc qu'il y a une classe qui fait des calculs astucieux. Le contrat dit qu'il devrait calculer un nombre, ou il renvoie -1 quand il échoue pour une raison quelconque.
J'ai des tests de contrat qui testent que. Et dans tous mes autres tests, je stub ce truc de calculatrice astucieuse.
Alors maintenant, je change le contrat, chaque fois qu'il ne peut pas calculer, il lancera une exception CannotCalculateException.
Mes tests de contrat échoueront, et je les corrigerai en conséquence. Mais, tous mes objets moqués/tronqués utiliseront toujours les anciennes règles du contrat. Ces tests réussiront, alors qu'ils ne devraient pas!
La question qui se pose, est qu'avec cette foi dans les tests unitaires, combien de foi peut être placée dans un tel changement... Les tests unitaires réussissent, mais des bogues se produisent lors du test de l'application. Les tests utilisant cette calculatrice devront être corrigés, ce qui coûte du temps et peut même être tronqué/moqué beaucoup de fois...
Comment pensez-vous de cette affaire? Je n'ai jamais pensé thourougly. À mon avis, ces changements aux tests unitaires seraient acceptables. Si je n'utilise pas de tests unitaires, je verrais aussi de tels bugs apparaître dans la phase de test (par les testeurs). Pourtant, je ne suis pas assez confiant pour souligner ce qui va coût de plus de temps (ou moins).
Des idées?
9 réponses
Le premier problème que vous soulevez est le problème dit du "test fragile". Vous apportez une modification à votre application, et des centaines de tests se cassent à cause de ce changement. Lorsque cela se produit, vous avez un conception problème. Vos tests ont été conçus pour être fragiles. Ils n'ont pas été suffisamment découplés du code de production. La solution est (comme dans tous les problèmes logiciels comme celui-ci) de trouver une abstraction qui découpleles tests du code de production de telle sorte que le la volatilité du code de production est cachée des tests.
Certaines choses simples qui causent ce genre de fragilité sont:
- Test des chaînes affichées. Ces chaînes sont volatiles parce que leur grammaire ou leur orthographe peuvent changer au gré d'un analyste.
- tester les valeurs discrètes (par exemple 3) qui doivent être codées derrière une abstraction (par exemple FULL_TIME).
- appel de la même API à partir de nombreux tests. Vous devez envelopper l'appel API dans une fonction de test que lorsque l'API change, vous pouvez faire le changement en un seul endroit.
La conception des tests est une question importante qui est souvent négligée par les débutants en TDD. Cela se traduit souvent par des tests fragiles, ce qui conduit les novices à rejeter TDD comme "improductif".
La deuxième question que vous avez soulevée était les faux positifs. Vous avez utilisé tellement de mocks qu'aucun de vos tests ne teste réellement le système intégré. Bien que tester des unités indépendantes soit une bonne chose, il est également important de tester les unités partielles et intégrations entières du système. TDD est pas juste à propos des tests unitaires.
Les essais doivent être organisés comme suit:
- les tests unitaires fournissent une couverture de code proche de 100%. Ils testent des unités indépendantes. Ils sont écrits par des programmeurs utilisant le langage de programmation du système.
- les tests de composants couvrent environ 50% du système. Ils sont écrits par des analystes d'affaires et QA. Ils sont écrits dans une langue comme FitNesse, sélénium,concombre, etc. Ils permettent de tester l'ensemble composants, pas des unités individuelles. Ils testent principalement les cas de chemin heureux et certains cas de chemin malheureux très visibles.
- les tests D'intégration couvrent environ 20% du système. Ils testent de petits ensembles de composants par opposition à l'ensemble du système. Aussi écrit en FitNesse / sélénium / concombre etc. Rédigé par architectes.
- les tests du système couvrent environ 10% du système. Ils testent l'ensemble du système intégré ensemble. Encore une fois, ils sont écrits en FitNesse / sélénium / concombre etc. Écrit par architecte.
- tests manuels exploratoires. (Voir James Bach) ces tests sont manuels mais pas scriptés. Ils emploient l'ingéniosité et la créativité humaines.
Il est préférable d'avoir à corriger les tests unitaires qui échouent en raison de changements de code intentionnels que de ne pas avoir de tests pour attraper les bogues qui sont finalement introduits par ces changements.
Lorsque votre base de code a une bonne couverture de test unitaire, vous pouvez rencontrer de nombreux échecs de test unitaire qui ne sont pas dus à des bogues dans le code mais à des changements intentionnels sur les contrats ou le refactoring de code.
Cependant, cette couverture de test unitaire vous donnera également confiance pour refactoriser le code et implémenter toutes les modifications de contrat. Certains tests échoueront et devront être corrigés, mais d'autres tests échoueront finalement en raison de bugs que vous avez introduits avec ces modifications.
Les tests unitaires ne peuvent sûrement pas attraper tous les bugs, même dans le cas idéal de 100% de couverture de code / fonctionnalité. Je pense que c'est de ne pas être prévu.
Si le contrat testé change, je (le développeur) devrais utiliser mon cerveau pour mettre à jour tout le code (y compris le code de test!) conséquent. Si Je ne parviens pas à mettre à jour quelques mocks qui produisent donc encore l'ancien comportement, c'est de ma faute, pas des tests unitaires.
C'est similaire au cas où je corrige un bug et produit un test unitaire Pour, mais j'échoue réfléchir (et tester) tous les cas similaires, dont certains se révèlent plus tard être bogués.
Donc oui, les tests unitaires ont besoin de maintenance aussi bien que le code de production lui-même. Sans entretien, ils se décomposent et pourrissent.
J'ai des expériences similaires avec les tests unitaires-lorsque vous modifiez le contrat d'une classe, vous devez souvent changer des charges d'autres tests (ce qui passera dans de nombreux cas, ce qui le rend encore plus difficile). C'est pourquoi j'utilise toujours des tests de niveau supérieur:
- tests D'acceptation-tester quelques classes ou plus. Ces tests sont généralement alignés sur les magasins d'utilisateurs qui doivent être implémentés-vous testez donc que l'histoire de l'utilisateur "fonctionne". Ces n'avez pas besoin de vous connecter à un DB ou d'autres systèmes externes, mais peut.
- tests D'intégration-principalement pour vérifier la connectivité du système externe, etc.
- tests complets de bout en bout-tester l'ensemble du système
Veuillez noter que même si vous avez une couverture de test unitaire de 100%, vous n'êtes même pas assuré que votre application démarre! C'est pourquoi vous avez besoin de tests de niveau supérieur. Il y a tellement de couches différentes de tests parce que plus vous testez quelque chose, plus il est généralement moins cher (en termes de développement, de maintien infrastructure de test ainsi que le temps d'exécution).
Comme note de côté-en raison du problème que vous avez mentionné, l'utilisation de tests unitaires vous apprend à garder vos composants aussi découplés que possible et leurs contrats aussi petits que possible-ce qui est certainement une bonne pratique!
Quelqu'un a posé la même question dans le Groupe Google pour le livre "cultivons l'Objet de Logiciels Orientés - Guidés par des Tests". Le thread est les hypothèses de simulation/talon de test unitaire pourrissent .
Voici la réponse de J. B. Rainsberger (Il est l'auteur de "JUnit Recipes"de Manning).
L'une des règles pour le code de tests unitaires (et tout autre code utilisé pour les tests) est de le traiter de la même manière que le code de production - ni plus, ni moins - tout de même.
Ma compréhension de ceci est que (à part le garder pertinent, refactorisé, travailler etc. comme le code de production), il devrait être regardé de la même manière à partir de la prospective d'investissement/coût.
Probablement votre stratégie de test devrait inclure quelque chose pour résoudre le problème que vous avez décrit dans la post - quelque chose le long des lignes spécifiant quel code de test (y compris les talons/mocks) devrait être examiné (exécuté, inspecté, modifié, corrigé, etc.) lorsqu'un concepteur change une fonction/méthode dans le code de production. Par conséquent, le coût de tout changement de code de production doit inclure le coût de faire cela-sinon - le code de test deviendra "citoyen de troisième classe" et la confiance des concepteurs dans la suite de tests unitaires ainsi que sa pertinence diminuera... De toute évidence, le ROI est dans le moment de la découverte de bugs et fix.
Un principe sur lequel je compte ici est la suppression de la duplication. Je n'ai généralement pas beaucoup de faux ou de mocks différents mettant en œuvre ce contrat (j'utilise plus de faux que de mocks en partie pour cette raison). Lorsque je change le contrat, il est naturel d'inspecter chaque implémentation de ce contrat, code de production ou test. Cela me dérange quand je trouve que je fais ce genre de changement, mes abstractions auraient dû être mieux pensées peut-être etc, mais si les codes de test sont trop onéreux pour changer pour l'échelle du changement de contrat, alors je dois me demander si ceux-ci sont également dus à un refactoring.
Je le regarde de cette façon, lorsque votre contrat change, vous devriez le traiter comme un nouveau contrat. Par conséquent, vous devez créer un nouvel ensemble entier de test unitaire pour ce" nouveau " contrat. Le fait que vous ayez un ensemble existant de cas de test est en plus du point.
Je second l'avis de l'oncle Bob que le problème est dans la conception. Je voudrais en outre revenir en arrière et vérifier la conception de vos contrats.
En bref
, au Lieu de dire "return -1 pour x==0" ou "jeter CannotCalculateException pour x==y", underspecify niftyCalcuatorThingy(x,y)
avec la condition x!=y && x!=0
dans des situations appropriées (voir ci-dessous). Ainsi, vos stubs peuvent se comporter arbitrairement pour ces cas, vos tests unitaires doivent le refléter, et vous avez une modularité maximale, c'est - à-dire la liberté de changer arbitrairement le comportement de votre système testé pour tous les cas sous-spécifiés-sans avoir besoin de changer de contrat ou de test.
Sous-Spécification le cas échéant
Vous pouvez différencier votre instruction "-1 quand elle échoue pour une raison quelconque" selon les critères suivants: est le scénario
- un comportement exceptionnel que l'implémentation peut vérifier?
- dans le domaine/la responsabilité de la méthode?
- un exception que l'appelant (ou quelqu'un plus tôt dans la pile d'appels) peut récupérer/gérer d'une autre manière?
SI et seulement si 1) à 3) hold, spécifiez le scénario dans le contrat (par exemple que EmptyStackException
est lancé lors de l'appel de pop() sur une pile vide).
Sans 1), l'implémentation ne peut garantir un comportement spécifique dans le cas exceptionnel. Par exemple, l'Objet.equals () ne spécifie aucun comportement lorsque la condition de réflexivité, de symétrie, de transitivité et de cohérence est pas rencontré.
Sans 2), Singleresponsabilityprinciple n'est pas respecté, la modularité est brisée et les utilisateurs / lecteurs du code sont confus. Par exemple, Graph transform(Graph original)
ne devrait pas spécifier que MissingResourceException
pourrait être lancé car au fond, un clonage via la sérialisation est effectué.
Sans 3), l'appelant ne peut pas utiliser le comportement spécifié (certaine valeur de retour/exception). Par exemple, si la JVM lance un UnknownError.
Avantages et inconvénients
Si vous spécifiez les cas où 1), 2) ou 3) ne tient pas, vous obtenez quelques difficultés:
- un but principal d'un contrat (design by) est la modularité. Ceci est mieux réalisable si vous séparez vraiment les responsabilités: lorsque la condition préalable (la responsabilité de l'appelant) n'est pas remplie, ne pas spécifier le comportement de l'implémentation conduit à une modularité maximale - comme le montre votre exemple.
- vous n'avez aucune liberté de changer dans le futur, même pas à une fonctionnalité plus générale de la méthode qui jette exception dans moins de CAs
- les comportements exceptionnels peuvent devenir assez complexes, de sorte que les contrats les couvrant deviennent complexes, sujets aux erreurs et difficiles à comprendre. Par exemple: chaque situation est-elle couverte? Quel comportement est correct si plusieurs conditions préalables exceptionnelles tiennent?
L'inconvénient de la sous-spécification est que la robustesse (test), c'est-à-dire la capacité de l'implémentation à réagir de manière appropriée à des conditions anormales, est plus difficile.
Comme compromis, j'aime utiliser le schéma de contrat suivant si possible:
Si PRE n'est pas satisfait, l'implémentation actuelle lance le RTE A, B ou C.