Est-ce que TDD rend la programmation défensive redondante?


104

Aujourd'hui, j'ai eu une discussion intéressante avec un collègue.

Je suis un programmeur défensif. Je crois que la règle " une classe doit s'assurer que ses objets ont un état valide lorsqu'elles interagissent avec l'extérieur de la classe " doit toujours être respectée. La raison de cette règle est que la classe ne sait pas qui sont ses utilisateurs et qu'elle devrait échouer de manière prévisible lorsqu'elle interagit de manière illégale. A mon avis, cette règle s'applique à toutes les classes.

Dans la situation spécifique où j'ai eu une discussion aujourd'hui, j'ai écrit un code qui valide que les arguments de mon constructeur sont corrects (par exemple, un paramètre entier doit être> 0) et si la condition préalable n'est pas remplie, une exception est levée. D'autre part, mon collègue estime qu'une telle vérification est redondante, car les tests unitaires devraient détecter toute utilisation incorrecte de la classe. De plus, il pense que les validations de programmation défensive doivent également être testées à l'unité. La programmation défensive ajoute donc beaucoup de travail et n'est donc pas optimale pour le TDD.

Est-il vrai que TDD est capable de remplacer la programmation défensive? La validation des paramètres (et je ne parle pas de saisie utilisateur) est-elle inutile? Ou les deux techniques se complètent-elles?


120
Vous remettez votre bibliothèque entièrement testée par unité sans contrôle de constructeur à un client, qui rompt le contrat de classe. A quoi servent ces tests unitaires maintenant?
Robert Harvey

42
OMI c'est l'inverse. La programmation défensive, les conditions préalables et professionnelles appropriées et un système de types riche rendent les tests redondants.
Gardenhead

37
Puis-je poster une réponse qui dit simplement "Bon chagrin?" La programmation défensive protège le système au moment de l'exécution. Les tests vérifient toutes les conditions d’exécution potentielles auxquelles le testeur peut penser, y compris les arguments non valides passés aux constructeurs et à d’autres méthodes. Les tests, s’ils sont terminés, confirmeront que le comportement de l’exécution sera comme prévu, y compris les exceptions appropriées levées ou autre comportement intentionnel se produisant lorsque des arguments non valides sont passés. Mais les tests ne font pas une sacrée chose pour protéger le système au moment de l'exécution.
Craig

16
"Les tests unitaires devraient détecter toute utilisation incorrecte de la classe" - euh, comment? Les tests unitaires vous montreront le comportement donné avec des arguments corrects et avec des arguments incorrects; ils ne peuvent pas vous montrer tous les arguments qui seront donnés.
OJFord

34
Je ne pense pas avoir vu un meilleur exemple de la façon dont une pensée dogmatique sur le développement de logiciels peut conduire à des conclusions néfastes.
Sdenham

Réponses:


196

C'est ridicule. TDD oblige le code à réussir les tests et oblige tout le code à en effectuer quelques tests. Cela n'empêche pas vos consommateurs d'appeler le code de manière incorrecte, pas plus qu'il n'empêche par magie les programmeurs de manquer des cas de test.

Aucune méthodologie ne peut obliger les utilisateurs à utiliser le code correctement.

Il y a un léger argument à faire valoir que si vous maîtrisiez parfaitement TDD, vous auriez capturé votre contrôle> 0 dans un scénario de test, avant de le mettre en œuvre, et vous avez résolu ce problème - probablement en ajoutant le chèque. Mais si vous utilisiez TDD, votre exigence (> 0 dans le constructeur) apparaîtrait tout d' abord comme un test qui échoue. Ainsi, vous donnant le test après avoir ajouté votre chèque.

Il est également raisonnable de tester certaines conditions défensives (vous avez ajouté une logique, pourquoi ne voudriez-vous pas tester quelque chose d'aussi facilement testable?). Je ne sais pas pourquoi vous semblez être en désaccord avec cela.

Ou les deux techniques se complètent-elles?

TDD développera les tests. La mise en œuvre de la validation des paramètres les fera passer.


7
Je ne suis pas en désaccord avec la conviction que la validation préalable doit être testée, mais je ne partage pas l'opinion de mon collègue selon laquelle le travail supplémentaire engendré par la nécessité de tester la validation préalable est un argument pour ne pas créer la validation préalable dans le premier cas. endroit. J'ai édité mon post pour clarifier.
user2180613

20
@ user2180613 Créez un test qui teste qu'une défaillance de la condition préalable est gérée correctement: ajouter maintenant la vérification n'est pas un travail "extra", c'est un travail requis par TDD pour rendre le test vert. Si l'opinion de votre collègue est que vous devez faire le test, l'observer en échec et ensuite et seulement alors mettre en œuvre le contrôle de précondition, il pourrait alors avoir un argument du point de vue du puriste TDD. S'il dit juste d'ignorer complètement le chèque, alors il est idiot. Dans TDD, rien n'indique que vous ne pouvez pas être proactif en écrivant des tests pour les modes de défaillance potentiels.
RM le

4
@RM Vous n'écrivez pas de test pour tester le contrôle de condition préalable. Vous écrivez un test pour tester le comportement correct attendu du code appelé. Les contrôles de précondition sont, du point de vue du test, un détail d'implémentation opaque qui garantit le comportement correct. Si vous pensez à un meilleur moyen de garantir le bon état dans le code appelé, faites-le ainsi au lieu d'utiliser un contrôle de précondition traditionnel. Le test déterminera si vous avez réussi ou non, et ne saura toujours pas ou ne se souciera pas de savoir comment vous l' avez fait.
Craig

@ user2180613 Voilà une justification impressionnante: D si votre objectif en écrivant un logiciel est de réduire le nombre de tests dont vous avez besoin pour créer et exécuter, n'écrivez aucun logiciel - aucun test!
Gusdor

3
Cette dernière phrase de cette réponse clique dessus.
Robert Grant

32

La programmation défensive et les tests unitaires sont deux manières différentes de détecter les erreurs et ont chacun des forces différentes. L'utilisation d'un seul moyen de détecter les erreurs rend vos mécanismes de détection d'erreur fragiles. L'utilisation des deux permet de récupérer les erreurs qui auraient pu être omises par l'un ou l'autre, même dans du code autre qu'une API publique; Par exemple, il est possible que quelqu'un ait oublié d'ajouter un test unitaire pour les données non valides transmises à l'API publique. Tout vérifier aux endroits appropriés signifie plus de chances d’attraper l’erreur.

Dans la sécurité de l'information, cela s'appelle Défense en profondeur. Le fait de disposer de plusieurs niveaux de défense garantit que si l’un échoue, il en reste d’autres pour le rattraper.

Votre collègue a raison sur un point: vous devriez tester vos validations, mais ce n'est pas du "travail inutile". C'est la même chose que de tester tout autre code, vous voulez vous assurer que toutes les utilisations, même celles qui ne sont pas valides, ont un résultat attendu.


Est-il exact de dire que la validation des paramètres est une forme de validation préalable et que les tests unitaires sont des validations postconditionnelles, raison pour laquelle ils se complètent?
user2180613

1
"C’est la même chose que de tester tout autre code, vous voulez vous assurer que tous les usages, même ceux qui ne sont pas valides, ont un résultat attendu." Cette. Aucun code ne devrait jamais simplement passer lorsque son entrée transmise n'était pas conçue pour être gérée. Cela viole le principe "fail fast" et peut faire du débogage un cauchemar.
jpmc26

@ user2180613 - pas vraiment, mais plus que des tests unitaires vérifient les conditions d'échec attendues par le développeur, tandis que les techniques de programmation défensive vérifient les conditions que le développeur ne prévoit pas. Les tests unitaires peuvent être utilisés pour valider les conditions préalables (à l'aide d'un objet fictif injecté à l'appelant qui vérifie la condition préalable).
Periata Breatta

1
@ jpmc26 Oui, l'échec est le "résultat attendu" du test. Vous testez pour montrer qu'il échoue, plutôt que de présenter en silence un comportement non défini (inattendu).
Kryan

6
TDD détecte les erreurs dans votre propre code, la programmation défensive enregistre les erreurs dans le code des autres utilisateurs. TDD peut donc aider à vous assurer que vous êtes suffisamment sur la défensive :)
je suis de retour le

30

TDD ne remplace absolument pas la programmation défensive. Au lieu de cela, vous pouvez utiliser TDD pour vous assurer que toutes les défenses sont en place et fonctionnent comme prévu.

Dans TDD, vous n'êtes pas censé écrire du code sans écrire d'abord un test - suivez le cycle rouge-vert-refactorisation avec religion. Cela signifie que si vous souhaitez ajouter une validation, commencez par écrire un test nécessitant cette validation. Appelez la méthode en question avec des nombres négatifs et avec zéro et attendez-elle à une exception.

De plus, n'oubliez pas l'étape du «refactor». Bien que TDD soit piloté par les tests , cela ne signifie pas uniquement des tests . Vous devez toujours appliquer une conception appropriée et écrire du code sensible. L'écriture de code défensif est un code sensible, car il rend les attentes plus explicites et votre code globalement plus robuste - repérer tôt les erreurs possibles facilite leur débogage.

Mais ne sommes-nous pas supposés utiliser des tests pour localiser les erreurs? Les assertions et les tests sont complémentaires. Une bonne stratégie de test combinera différentes approches pour s'assurer que le logiciel est robuste. Seuls les tests unitaires ou les tests d'intégration ou les assertions du code ne sont pas satisfaisants. Vous avez besoin d'une bonne combinaison pour obtenir un degré de confiance suffisant de votre logiciel avec un effort acceptable.

Ensuite, il y a un très grand malentendu conceptuel de votre collègue: les tests unitaires ne peuvent jamais tester les utilisations de votre classe, mais seulement que la classe elle - même fonctionne comme prévu de manière isolée. Vous utiliseriez des tests d’intégration pour vérifier que l’interaction entre divers composants fonctionnait, mais l’explosion combinatoire de scénarios de test possibles rend impossible tout test. Les tests d'intégration doivent donc se limiter à quelques cas importants. Des tests plus détaillés couvrant également les cas extrêmes et les cas d'erreur conviennent mieux aux tests unitaires.


16

Les tests sont là pour soutenir et assurer la programmation défensive

La programmation défensive protège l'intégrité du système au moment de l'exécution.

Les tests sont des outils de diagnostic (principalement statiques). Au moment de l'exécution, vos tests ne sont nulle part en vue. Ils sont comme les échafaudages utilisés pour ériger un haut mur de briques ou un dôme en pierre. Vous ne laissez pas de pièces importantes en dehors de la structure, car vous avez un échafaudage qui la retient pendant la construction. Vous avez un échafaudage qui le tient en place pendant la construction pour faciliter la mise en place de toutes les pièces importantes.

EDIT: une analogie

Qu'en est-il d'une analogie avec les commentaires dans le code?

Les commentaires ont leur raison d'être, mais peuvent être redondants, voire nuisibles. Par exemple, si vous mettez la connaissance intrinsèque sur le code dans les commentaires , puis modifiez le code, les commentaires deviennent au mieux inutiles et au pire nuisibles.

Donc, supposons que vous mettiez beaucoup de connaissances intrinsèques de votre base de code dans les tests, comme MethodA ne peut pas prendre une valeur nulle et l'argument de MethodB doit être > 0. Ensuite, le code change. Null est acceptable pour A maintenant, et B peut prendre des valeurs aussi petites que -10. Les tests existants sont maintenant fonctionnellement faux, mais continueront à réussir.

Oui, vous devriez mettre à jour les tests en même temps que le code. Vous devez également mettre à jour (ou supprimer) les commentaires en même temps que le code. Mais nous savons tous que ces choses ne se produisent pas toujours et que des erreurs sont commises.

Les tests vérifient le comportement du système. Ce comportement réel est intrinsèque au système lui-même et non aux tests.

Qu'est ce qui pourrait aller mal?

L’objectif des tests est de penser à tout ce qui pourrait mal tourner, d’écrire un test qui vérifie le bon comportement, puis de créer le code d’exécution de sorte qu’il passe tous les tests.

Ce qui signifie que la programmation défensive est le point .

TDD pilote la programmation défensive, si les tests sont complets.

Plus de tests, conduisant à une programmation plus défensive

Lorsque des bogues sont inévitablement trouvés, davantage de tests sont écrits pour modéliser les conditions qui manifestent le bogue. Ensuite, le code est corrigé, avec le code nécessaire à la réussite de ces tests, et les nouveaux tests restent dans la suite de tests.

Un bon ensemble de tests va passer les bons et les mauvais arguments à une fonction / méthode et attendre des résultats cohérents. Cela signifie à son tour que le composant testé utilisera des contrôles de précondition (programmation défensive) pour confirmer les arguments qui lui sont transmis.

Généralement parlant ...

Par exemple, si un argument null dans une procédure particulière est invalide, alors au moins un test passera un null et attendra une exception / erreur "argument non valide".

Au moins un autre test va bien sûr passer un argument valide - ou parcourir un grand tableau et transmettre de nombreux arguments valides - et confirmer que l'état résultant est approprié.

Si un test ne réussit pas cet argument null et est mis en attente avec l'exception attendue (et que cette exception a été levée parce que le code a vérifié l'état de manière défensive), la valeur null peut être affectée à une propriété d'une classe ou être enterrée. dans une collection de quelque sorte où il ne devrait pas être.

Cela peut entraîner un comportement inattendu dans une partie du système totalement différente à laquelle l'instance de classe est transmise, dans des paramètres régionaux éloignés après la livraison du logiciel . Et c'est le genre de chose que nous essayons d'éviter, n'est-ce pas?

Cela pourrait même être pire. L'instance de classe avec l'état non valide pourrait être sérialisée et stockée, uniquement pour provoquer un échec lorsqu'elle sera reconstituée pour être utilisée ultérieurement. Bon sang, je ne sais pas, c'est peut-être un système de contrôle mécanique qui ne peut pas redémarrer après un arrêt, car il ne peut pas désérialiser son propre état de configuration persistant. Ou bien l'instance de classe peut être sérialisée et transmise à un système totalement différent créé par une autre entité et ce système peut tomber en panne.

Surtout si les programmeurs de cet autre système ne codaient pas de manière défensive.


2
C'est drôle, le vote par opposition est arrivé si vite qu'il est absolument possible de lire au-delà du premier paragraphe.
Craig

1
:-) Je viens de voter sans lire au-delà du premier paragraphe, alors j'espère que ça va compenser ...
SusanW

1
Semblait le moins que je pouvais faire :-) ( En fait, je ne lisais le reste juste pour se assurer doit pas être bâclée -.! En particulier sur un sujet comme celui - ci)
SusanW

1
Je pensais que tu avais probablement. :)
Craig

des contrôles défensifs peuvent être effectués au moment de la compilation avec des outils tels que les contrats de code.
Matthew Whited

9

Au lieu de TDD, parlons de "test de logiciel" en général, et de "programmation défensive" en général, parlons de ma manière préférée de faire de la programmation défensive, qui consiste à utiliser des assertions.


Donc, puisque nous testons des logiciels, nous devrions cesser de placer des déclarations d’assertion dans le code de production, non? Laissez-moi compter les façons dont cela est faux:

  1. Les assertions sont facultatives. Si vous ne les aimez pas, exécutez simplement votre système avec les assertions désactivées.

  2. Les assertions vérifient ce que les tests ne peuvent pas (et ne devraient pas faire). Parce que les tests sont supposés avoir une vue en boîte noire de votre système, alors que les assertions ont une vue en boîte blanche. (Bien sûr, puisqu'ils y vivent.)

  3. Les assertions sont un excellent outil de documentation. Aucun commentaire n'a jamais été et ne sera jamais aussi clair qu'un code affirmant la même chose. De plus, la documentation tend à devenir obsolète à mesure que le code évolue, et le compilateur ne peut en aucune manière la mettre en application.

  4. Les assertions peuvent intercepter des erreurs dans le code de test. Avez-vous déjà rencontré une situation dans laquelle un test échoue et vous ne savez pas qui a tort - le code de production ou le test?

  5. Les assertions peuvent être plus pertinentes que les tests. Les tests vérifient ce qui est prescrit par les exigences fonctionnelles, mais le code doit souvent faire certaines hypothèses beaucoup plus techniques que cela. Les personnes qui rédigent des documents d’exigences fonctionnelles pensent rarement à une division par zéro.

  6. Les assertions identifient les erreurs auxquelles on ne fait que tester de manière générale. Ainsi, votre test définit des conditions préalables étendues, invoque un long morceau de code, rassemble les résultats et constate qu’ils ne sont pas conformes à vos attentes. Si vous avez suffisamment de problèmes, vous finirez par trouver exactement où les choses se sont mal passées, mais les affirmations le trouveront d’abord.

  7. Les assertions réduisent la complexité du programme. Chaque ligne de code que vous écrivez augmente la complexité du programme. Les assertions et le mot clé final( readonly) sont les deux seules constructions que je connaisse qui réduisent réellement la complexité du programme. C'est inestimable.

  8. Les assertions aident le compilateur à mieux comprendre votre code. Veuillez essayer ceci à la maison: void foo( Object x ) { assert x != null; if( x == null ) { } }votre compilateur devrait émettre un avertissement vous indiquant que la condition x == nullest toujours fausse. Cela peut être très utile.

Ce qui précède est un résumé d'un billet de mon blog du 2014-09-21 "Assertions and Testing"


Je pense que je suis surtout en désaccord avec cette réponse. (5) En TDD, la suite de tests est la spécification. Vous êtes censé écrire le code le plus simple pour faire passer les tests, rien de plus. (4) Le flux de travaux rouge-vert garantit que le test échoue quand il le devrait et qu'il réussit lorsque la fonctionnalité prévue est présente. Les affirmations n'aident pas beaucoup ici. (3,7) La documentation est la documentation, pas les assertions. Mais en rendant explicites les hypothèses, le code devient plus auto-documenté. Je penserais à eux comme des commentaires exécutables. (2) Les tests en boîte blanche peuvent faire partie d'une stratégie de test valide.
amon

5
"En TDD, la suite de tests est la spécification. Vous êtes censé écrire le code le plus simple pour faire passer les tests, rien de plus.": Je ne pense pas que ce soit toujours une bonne idée: comme indiqué dans la réponse, il y a hypothèse interne supplémentaire dans le code que l’on pourrait vouloir vérifier. Qu'en est-il des bugs internes qui s'annulent? Vos tests réussissent, mais quelques hypothèses dans votre code sont erronées, ce qui peut conduire à des bugs insidieux plus tard.
Giorgio

5

Je crois que la plupart des réponses manquent à une distinction essentielle: cela dépend de la façon dont votre code va être utilisé.

Le module en question sera-t-il utilisé par d'autres clients indépendamment de l'application que vous testez? Si vous fournissez une bibliothèque ou une API à l'usage de tiers, vous ne pouvez vous assurer qu'ils n'appellent votre code qu'avec une entrée valide. Vous devez valider toutes les entrées.

Mais si le module en question n’est utilisé que par le code que vous contrôlez, alors votre ami aura peut-être un sens. Vous pouvez utiliser des tests unitaires pour vérifier que le module en question est appelé uniquement avec une entrée valide. Les vérifications de précondition peuvent toujours être considérées comme une bonne pratique, mais c'est un compromis: si vous écrasez le code qui vérifie si une condition ne peut jamais survenir, elle masque simplement l'intention du code.

Je ne suis pas d'accord sur le fait que les contrôles préalables nécessitent davantage de tests unitaires. Si vous décidez que vous n'avez pas besoin de tester certaines formes d'entrées non valides, le fait que la fonction contienne ou non des vérifications préalables n'a aucune importance. N'oubliez pas que les tests doivent vérifier le comportement, pas les détails de la mise en œuvre.


4
Si la procédure appelée ne vérifie pas la validité des entrées (ce qui correspond au débat initial), vos tests unitaires ne peuvent pas garantir que le module en question est appelé uniquement avec une entrée valide. En particulier, il peut être appelé avec une entrée non valide mais il arrive juste de renvoyer un résultat correct de toute façon dans les cas testés - il existe différents types de comportement non défini, de débordement, etc. pouvant renvoyer le résultat attendu dans un environnement de test avec optimisations désactivées, mais échouer dans la production.
Peteris

@Peteris: Pensez-vous à un comportement indéfini comme en C? Invoquer des comportements non définis ayant des résultats différents dans des environnements différents est évidemment un bogue, mais il ne peut pas non plus être évité par des vérifications de précondition. Par exemple, comment vérifiez-vous qu'un argument de pointeur pointe sur une mémoire valide?
JacquesB

3
Cela ne fonctionnera que dans le plus petit des magasins. Une fois que votre équipe aura dépassé, disons, six personnes, vous aurez de toute façon besoin des contrôles de validation.
Robert Harvey

1
@ RobertHarvey: Dans ce cas, le système doit être partitionné en sous-systèmes avec des interfaces bien définies et la validation des entrées doit être effectuée à l'interface.
JacquesB

cette. Cela dépend du code. Ce code doit-il être utilisé par l'équipe? Est-ce que l'équipe a accès au code source? Si son code purement interne, la recherche d'arguments peut n'être qu'un fardeau, par exemple, vous recherchez une exception puis émettez une exception, et l'appelant examine ensuite le code; cette classe peut émettre une exception, etc., et attendez .. dans ce cas que L'objet ne recevra jamais 0 car ils sont filtrés 2 niveaux auparavant. Si c’est un code de bibliothèque à utiliser par des tiers, c’est une autre histoire. Non, tout le code est écrit pour être utilisé par le monde entier.
Aleksander Fular le

3

Cet argument me laisse perplexe, car lorsque j'ai commencé à utiliser TDD, mes tests unitaires de la forme "objet répond <certaine manière> lorsque <entrée non valide>" ont été multipliés par 2 ou 3. Je me demande comment votre collègue réussit à réussir ce type de tests unitaires sans que ses fonctions fassent la validation.

Le cas contraire, selon lequel les tests unitaires montrent que vous ne produisez jamais de mauvais résultats qui seront transmis aux arguments des autres fonctions, est beaucoup plus difficile à prouver. Comme dans le premier cas, cela dépend beaucoup de la couverture complète des cas extrêmes, mais vous devez également vous assurer que toutes les entrées de vos fonctions doivent provenir des sorties des autres fonctions dont vous avez testé les sorties et non pas, par exemple, des entrées utilisateur. modules tiers.

En d'autres termes, ce que fait TDD ne vous empêche pas d' avoir besoin d'un code de validation mais vous évite de l' oublier .


2

Je pense que j'interprète les propos de votre collègue différemment de la plupart des réponses restantes.

Il me semble que l'argument est le suivant:

  • Tous nos codes sont testés à l'unité.
  • Tout le code qui utilise votre composant est notre code ou, si ce n’est pas le cas, est testé par une autre personne (ce n’est pas explicitement indiqué, mais c’est ce que je comprends "les tests unitaires doivent détecter toute utilisation incorrecte de la classe").
  • Par conséquent, pour chaque appelant de votre fonction, il existe un test unitaire qui se moque de votre composant, et le test échoue si l'appelant transmet une valeur non valide à cette maquette.
  • Par conséquent, peu importe ce que votre fonction fait lorsqu'une valeur non valide est transmise, car nos tests indiquent que cela ne peut pas arriver.

Pour moi, cet argument a une certaine logique, mais fait trop confiance aux tests unitaires pour couvrir toutes les situations possibles. Le fait est qu'une couverture à 100% en lignes / branches / chemins n'exerce pas nécessairement toutes les valeurs que l'appelant peut transmettre, alors qu'une couverture à 100% de tous les états possibles de l'appelant (c'est-à-dire toutes les valeurs possibles de ses entrées). et variables) est impossible à calculer.

Par conséquent , je tendance à préférer à l' unité-test , les appelants pour faire en sorte que (pour autant que les essais vont) ils ne passent jamais dans de mauvaises valeurs, et en plus d'exiger que votre composant ne d'une manière reconnaissable quand une mauvaise valeur est passée dans ( au moins dans la mesure où il est possible de reconnaître les mauvaises valeurs dans la langue de votre choix). Cela facilitera le débogage en cas de problèmes lors des tests d'intégration et aidera également les utilisateurs de votre classe qui ne sont pas rigoureux à isoler leur unité de code de cette dépendance.

Sachez cependant que si vous documentez et testez le comportement de votre fonction lorsqu'une valeur <= 0 est transmise, les valeurs négatives ne sont plus invalides (du moins, pas plus que n'importe quel argument throw, car aussi est documenté pour jeter une exception!). Les appelants ont le droit de se fier à ce comportement défensif. Si la langue le permet, il se peut que ce soit de toute façon le meilleur scénario. La fonction ne comporte pas "d'entrées non valides", mais les appelants qui s'attendent à ne pas provoquer la fonction en lançant une exception doivent être suffisamment testés pour garantir leur suppression. t transmettre les valeurs qui causent cela.

Bien que je pense que votre collègue a un peu moins tort que la plupart des réponses, je parviens à la même conclusion, à savoir que les deux techniques se complètent. Programmez de manière défensive, documentez vos vérifications défensives et testez-les. Le travail n'est "inutile" que si les utilisateurs de votre code ne peuvent pas bénéficier de messages d'erreur utiles lorsqu'ils commettent des erreurs. En théorie, s'ils testent minutieusement tous leurs codes avant de les intégrer au vôtre, et que leurs tests ne font jamais d'erreur, ils ne verront jamais les messages d'erreur. En pratique, même s'ils effectuent une injection de dépendance totale et de TDD, ils peuvent quand même explorer pendant le développement ou les tests peuvent être échoués. Le résultat est qu'ils appellent votre code avant que leur code soit parfait!


Cette entreprise consistant à mettre l’accent sur le fait de tester les appelants afin de s’assurer qu’ils ne transmettent pas de mauvaises valeurs semble se prêter à un code fragile comportant de nombreuses dépendances de basses et de basses, sans séparation nette des préoccupations. Je ne pense vraiment pas que j'aimerais le code qui résulterait de la pensée derrière cette approche.
Craig

@Craig: Si vous avez isolé un composant à tester en vous moquant de ses dépendances, pourquoi ne pas vérifier qu'il ne transmet que des valeurs correctes à ces dépendances? Et si vous ne pouvez pas isoler le composant, avez-vous vraiment des préoccupations séparées? Je ne suis pas en désaccord avec le codage défensif, mais si les vérifications défensives sont le moyen par lequel vous testez l'exactitude du code d'appel, c'est un gâchis. Donc, je pense que le collègue de l'interlocuteur a raison de dire que les chèques sont redondants, mais il est faux de considérer cela comme une raison de ne pas les écrire :-)
Steve Jessop

Le seul trou criant que je vois, c’est que je teste seulement que mes propres composants ne peuvent pas transmettre de valeurs non valides à ces dépendances, ce qui, je suis tout à fait d’accord, devrait être fait, mais combien de décisions faut-il prendre en fonction du nombre de chefs d’entreprise composante publique afin que les partenaires puissent l'appeler? Cela me rappelle en fait la conception de la base de données et toute l’histoire amoureuse actuelle avec les ORM, ce qui a amené tant de personnes (pour la plupart plus jeunes) à déclarer que les bases de données ne sont que du stockage réseau idiot et ne devraient pas se protéger avec des contraintes, des clés étrangères et des procédures stockées.
Craig

L'autre chose que je vois, c'est que dans ce scénario, bien sûr, vous testez uniquement les appels à des simulacres, pas aux dépendances réelles. C'est finalement le code dans ces dépendances qui peut ou ne peut pas fonctionner correctement avec une valeur passée particulière, pas le code dans l'appelant. La dépendance doit donc faire ce qui est juste et il doit exister une couverture suffisante de tests indépendants de la dépendance pour s’assurer que c’est le cas. Rappelez-vous, ces tests dont nous parlons sont appelés des tests "unitaires". Chaque dépendance est une unité. :)
Craig

1

Les interfaces publiques peuvent et seront mal utilisées

La réclamation de votre collègue "les tests unitaires doivent détecter toute utilisation incorrecte de la classe" est strictement fausse pour toute interface non privée. Si une fonction publique peut être appelée avec des arguments entiers, elle peut et sera appelée avec tous les arguments entiers, et le code doit se comporter de manière appropriée. Si une signature de fonction publique accepte, par exemple, le type Java Double, les valeurs null, NaN, MAX_VALUE, -Inf sont toutes possibles. Vos tests unitaires ne peuvent pas détecter les utilisations incorrectes de la classe, car ces tests ne peuvent pas tester le code qui utilisera cette classe, car ce code n'est pas encore écrit, il se peut qu'il ne soit pas écrit par vous et sortira définitivement du cadre de vos tests unitaires. .

D'autre part, cette approche peut être valable pour les propriétés privées (beaucoup plus nombreuses, espérons-le) - si une classe peut s'assurer que certains faits sont toujours vrais (par exemple, la propriété X ne peut jamais être nulle, la position entière ne dépasse pas la longueur maximale , lorsque la fonction A est appelée, toutes les structures de données prérequises sont bien formées), il peut alors être judicieux d’éviter de le vérifier encore et encore pour des raisons de performances et de faire appel à des tests unitaires.


Le titre et le premier paragraphe de ceci sont vrais, car ce ne sont pas les tests unitaires qui exerceront le code lors de l'exécution. Il est tout autre code d'exécution et la modification des conditions réelles et les intrants mauvais utilisateur et les tentatives de piratage interagissent avec le code.
Craig

1

La défense contre les utilisations abusives est une fonctionnalité , développée pour répondre à une nécessité. (Toutes les interfaces ne nécessitent pas de contrôles rigoureux contre les utilisations abusives; par exemple, les interfaces internes très étroitement utilisées.)

La fonctionnalité nécessite des tests: la défense contre les utilisations abusives fonctionne-t-elle réellement? Le but de tester cette fonctionnalité est d’essayer de montrer que ce n’est pas le cas: il faut remédier à une mauvaise utilisation du module qui n’est pas détectée par ses vérifications.

Si des vérifications spécifiques sont une fonctionnalité requise, il est en effet absurde d'affirmer que l'existence de certains tests les rend inutiles. S'il s'agit d'une caractéristique d'une fonction qui (par exemple) génère une exception lorsque le paramètre trois est négatif, cela n'est pas négociable; il doit faire ça.

Toutefois, j’imagine que votre collègue a tout à fait du sens du point de vue d’une situation dans laquelle il n’est pas nécessaire de procéder à un contrôle spécifique des intrants, avec des réponses spécifiques aux mauvais intrants: une situation dans laquelle il n’existe qu’une exigence générale bien comprise. robustesse.

Les contrôles lors de l’entrée dans certaines fonctions de niveau supérieur servent en partie à protéger un code interne faible ou mal testé contre des combinaisons inattendues de paramètres (de sorte que si le code est bien testé, les contrôles ne sont pas nécessaires: le code peut simplement " météo "les mauvais paramètres).

L’idée du collègue est véridique et ce qu’il entend probablement par là est la suivante: si nous construisons une fonction à partir d’éléments très robustes de niveau inférieur codés de manière défensive et testés individuellement contre tout abus, il est alors possible que la fonction de niveau supérieur soit robuste sans avoir ses propres autocontrôles approfondis.

Si son contrat est violé, il se traduira par une utilisation abusive des fonctions de niveau inférieur, peut-être en lançant des exceptions ou autre.

Le seul problème avec cela est que les exceptions de niveau inférieur ne sont pas spécifiques à l'interface de niveau supérieur. Que ce soit un problème ou non dépend des exigences. Si l'exigence est simplement "la fonction doit être robuste contre les utilisations abusives et émettre une sorte d'exception plutôt que de planter ou de continuer à calculer avec des données erronées", elle pourrait en fait être couverte par toute la robustesse des éléments de niveau inférieur sur lesquels elle repose. construit.

Si la fonction demande des rapports d'erreur très spécifiques et détaillés relatifs à ses paramètres, les contrôles de niveau inférieur ne répondent pas pleinement à ces exigences. Ils veillent uniquement à ce que la fonction explose (ne continue pas avec une mauvaise combinaison de paramètres, ce qui produira un résultat incohérent). Si le code client est écrit pour intercepter certaines erreurs et les gérer, il est possible que cela ne fonctionne pas correctement. Le code client peut lui-même obtenir, en entrée, les données sur lesquelles les paramètres sont basés, et il peut s’attendre à ce que la fonction les vérifie et traduise les valeurs incorrectes en erreurs spécifiques comme documenté (afin qu’elle puisse les gérer). erreurs correctement) plutôt que d’autres erreurs qui ne sont pas traitées et qui risquent d’arrêter l’image logicielle.

TL; DR: votre collègue n'est probablement pas un idiot; vous vous contentez de parler les uns des autres avec des points de vue différents sur la même chose, car les exigences ne sont pas complètement définies et chacun de vous a une idée différente de ce que sont les "exigences non écrites". Vous pensez que, lorsqu'il n'y a pas d'exigences spécifiques en matière de vérification des paramètres, vous devez néanmoins coder une vérification détaillée; le collègue pense, laissez simplement le code de niveau inférieur robuste exploser lorsque les paramètres sont erronés. Il est quelque peu improductif de discuter des exigences non écrites via le code: reconnaissez que vous n'êtes pas d'accord sur les exigences plutôt que sur le code. Votre façon de coder reflète ce que vous pensez être les exigences; la façon dont le collègue représente sa vision des exigences. Si vous le voyez ainsi, il est clair que ce qui est juste ou faux n’est pas t dans le code lui-même; le code est juste un proxy pour votre opinion de ce que la spécification devrait être.


Ceci est lié à une difficulté philosophique générale de gestion de ce que peuvent être des exigences non résolues. Si une fonction est laissée libre, mais non totale, de se comporter de manière arbitraire lorsqu’elle reçoit des entrées mal formées (par exemple, si un décodeur d’image répond aux exigences si on peut le garantir - à son loisir - soit produire une combinaison arbitraire de pixels, soit se terminer anormalement , mais pas si cela peut permettre à des entrées malicieusement conçues d'exécuter du code arbitraire), il est difficile de savoir quels tests sont appropriés pour s'assurer qu'aucune entrée ne produit un comportement inacceptable.
Supercat

1

Les tests définissent le contrat de votre classe.

Corollairement, l' absence de test définit un contrat qui inclut un comportement indéfini . Ainsi, lorsque vous passez nullà Foo::Frobnicate(Widget widget), et que des ravages incalculables au moment de l'exécution s'ensuivent, vous êtes toujours dans le contrat de votre classe.

Plus tard, vous décidez: "nous ne voulons pas la possibilité d’un comportement indéfini", ce qui est un choix judicieux. Cela signifie que vous devez avoir un comportement attendu pour pouvoir passer nullà Foo::Frobnicate(Widget widget).

Et vous documentez cette décision en incluant un

[Test]
void Foo_FrobnicatesANullWidget_ThrowsInvalidArgument() 
{
    Given(Foo foo);
    When(foo.Frobnicate(null));
    Then(Expect_Exception(InvalidArgument));
}

1

Une bonne série de tests permettra d’exercer l’ interface externe de votre classe et de s’assurer que de telles utilisations abusives génèrent la réponse correcte (une exception ou tout ce que vous définissez comme "correct"). En fait, le premier cas de test que j'écris pour une classe consiste à appeler son constructeur avec des arguments hors de portée.

Le type de programmation défensive qui tend à être éliminé par une approche entièrement testée est la validation inutile d' invariants internes qui ne peuvent être violés par du code externe.

Une idée utile que j’utilise parfois est de fournir une méthode qui teste les invariants de l’objet; votre méthode de démontage peut l'appeler pour valider que vos actions externes sur l'objet ne cassent jamais les invariants.


0

Les tests de TDD détecteront les erreurs lors du développement du code .

La vérification des limites que vous décrivez dans le cadre de la programmation défensive détectera les erreurs lors de l’utilisation du code .

Si les deux domaines sont identiques, c’est-à-dire que le code que vous écrivez n’est utilisé en interne que par ce projet spécifique, il est alors possible que TDD exclue la nécessité de la vérification des limites de la programmation défensive que vous décrivez, mais uniquement si ces types de vérification des limites sont spécifiquement effectués dans les tests TDD .


A titre d'exemple spécifique, supposons qu'une bibliothèque de codes financiers ait été développée à l'aide de TDD. L'un des tests peut affirmer qu'une valeur donnée ne peut jamais être négative. Cela garantit que les développeurs de la bibliothèque n'abusent pas des classes par inadvertance lorsqu'ils implémentent les fonctionnalités.

Mais une fois la bibliothèque publiée et utilisée dans mon propre programme, ces tests TDD ne m'empêchent pas d'attribuer une valeur négative (en supposant qu'elle soit exposée). La vérification des limites serait.

Mon argument est qu’une assertion TDD pourrait résoudre le problème de la valeur négative si le code n’est utilisé que de manière interne dans le cadre du développement d’une application plus grande (sous TDD), s’il s’agit d’une bibliothèque utilisée par d’autres programmeurs sans TDD. cadre et tests , vérification des limites.


1
Je n'ai pas voté à la baisse, mais je suis d'accord avec les votes négatifs sur la prémisse selon laquelle l'ajout de distinctions subtiles à ce type d'argument brouille l'eau.
Craig

@Craig Je serais intéressé par vos commentaires sur l'exemple spécifique que j'ai ajouté.
Blackhawk

J'aime la spécificité de l'exemple. La seule préoccupation qui me reste concerne l'ensemble de l'argument. Par exemple; un nouveau développeur de l’équipe vient ensuite et écrit un nouveau composant qui utilise ce module financier. Le nouveau type ne connaît pas toutes les subtilités du système, sans parler du fait que toutes sortes de connaissances expertes sur la manière dont le système est censé fonctionner sont intégrées aux tests plutôt qu'au code en cours de test.
Craig

Ainsi, le nouveau gars / fille ne crée pas certains tests essentiels ET vous finissez par avoir une redondance dans vos tests - des tests dans différentes parties du système vérifient les mêmes conditions et deviennent de plus en plus disparates avec le temps, au lieu de simplement mettre en place les assertions appropriées. précondition vérifie dans le code où l'action est.
Craig

1
Quelque chose comme ca. Sauf que bon nombre des arguments avancés ici ont consisté à faire en sorte que les tests du code appelant fassent toutes les vérifications. Mais si vous avez un certain degré de fan-in, vous finissez par faire les mêmes vérifications à partir de plusieurs endroits différents, et c'est un problème de maintenance en soi. Que se passe-t-il si la plage d'entrées valides d'une procédure change, mais que vous possédez les connaissances de domaine pour cette plage intégrées dans des tests qui exercent différents composants? Je suis toujours totalement en faveur de la programmation défensive et de l’utilisation du profilage pour déterminer si et quand vous avez des problèmes de performance à résoudre.
Craig

0

TDD et la programmation défensive vont de pair. L'utilisation des deux n'est pas redondante, mais en fait complémentaire. Lorsque vous avez une fonction, vous voulez vous assurer que celle-ci fonctionne comme décrit et écrire des tests pour elle. si vous ne couvrez pas ce qui se passe dans le cas d'une mauvaise entrée, d'un mauvais retour, d'un mauvais état, etc., vous n'écrivez pas vos tests de manière suffisamment robuste et votre code sera fragile même si tous vos tests réussissent.

En tant qu'ingénieur intégré, j'aime bien utiliser l'exemple de l'écriture d'une fonction pour simplement ajouter deux octets ensemble et renvoyer le résultat comme suit:

uint8_t AddTwoBytes(uint8_t a, uint8_t b, uint8_t *sum); 

Maintenant, si vous le faisiez simplement, *(sum) = a + bcela fonctionnerait, mais seulement avec quelques intrants. a = 1et b = 2ferait sum = 3; Cependant, parce que la taille de la somme est un octet, a = 100et b = 200serait sum = 44due à un dépassement de capacité. En C, vous renverriez une erreur dans ce cas pour indiquer que la fonction a échoué; Lancer une exception est la même chose dans votre code. Ne pas prendre en compte les échecs ou tester comment les gérer ne fonctionnera pas à long terme, car si ces conditions se produisent, elles ne seront pas gérées et pourraient causer un grand nombre de problèmes.


Cela ressemble à un bon exemple de question d’interview (pourquoi at-il une valeur de retour et un paramètre "out" - et que se passe-t-il quand sumest un pointeur nul?).
Toby Speight
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.