Écrire du code testable vs éviter la généralité spéculative

11

Je lisais des articles de blog ce matin et je suis tombé sur celui-ci :

Si la seule classe qui implémente l'interface client est CustomerImpl, vous n'avez pas vraiment de polymorphisme et de substituabilité car il n'y a rien en pratique à substituer au moment de l'exécution. C'est une fausse généralité.

Cela a du sens pour moi, car l'implémentation d'une interface ajoute de la complexité et, s'il n'y a qu'une seule implémentation, on pourrait dire qu'elle ajoute une complexité inutile. L'écriture d'un code plus abstrait qu'il ne devrait l'être est souvent considérée comme une odeur de code appelée «généralité spéculative» (également mentionnée dans la publication).

Mais, si je suis TDD, je ne peux pas (facilement) créer des doubles de test sans cette généralité spéculative, que ce soit sous la forme d'une implémentation d'interface ou de notre autre option polymorphe, rendant la classe héritable et ses méthodes virtuelles.

Alors, comment pouvons-nous concilier ce compromis? Vaut-il la peine d'être spéculativement général pour faciliter les tests / TDD? Si vous utilisez des doubles de test, ceux-ci comptent-ils comme deuxièmes implémentations et ne rendent donc pas la généralité spéculative? Devriez-vous envisager un cadre de simulation plus lourd qui permet de se moquer de collaborateurs concrets (par exemple, Moles contre Moq dans le monde C #)? Ou, devriez-vous tester avec les classes concrètes et écrire ce qui pourrait être considéré comme des tests "d'intégration" jusqu'à ce que votre conception nécessite naturellement un polymorphisme?

Je suis curieux de lire les opinions des autres sur ce sujet - merci d'avance pour vos opinions.

Erik Dietrich
la source
Personnellement, je pense qu'il ne faut pas se moquer des entités. Je me moque uniquement des services, et ceux-ci ont besoin d'une interface dans tous les cas, car le code de domaine de code n'a généralement aucune référence au code où les services sont implémentés.
CodesInChaos
7
Nous, utilisateurs de langues typées dynamiquement, rions de votre frottement face aux chaînes que vos langues typiquement typées vous ont mises. C'est une chose qui facilite les tests unitaires dans les langages typés dynamiquement, je n'ai pas besoin d'avoir développé une interface pour sous-titrer un objet à des fins de test.
Winston Ewert
Les interfaces ne sont pas seulement utilisées pour effectuer la généralité. Ils sont utilisés à de nombreuses fins, le découplage de votre code étant l'un des plus importants. Ce qui rend le test de votre code beaucoup plus facile.
Marjan Venema
@WinstonEwert C'est un avantage intéressant de la frappe dynamique que je n'avais pas envisagé auparavant comme quelqu'un qui, comme vous le faites remarquer, ne fonctionne généralement pas dans des langues typées dynamiquement.
Erik Dietrich
@CodeInChaos Je n'avais pas considéré la distinction aux fins de cette question, bien que ce soit une distinction raisonnable à faire. Dans ce cas, nous pourrions penser à des classes de service / framework avec une seule implémentation (actuelle). Disons que j'ai une base de données à laquelle j'accède avec les DAO. Ne dois-je pas interfacer les DAO tant que je n'ai pas de modèle de persistance secondaire? (Cela semble être ce que l'auteur de l'article de blog sous-entend)
Erik Dietrich

Réponses:

14

Je suis allé lire le billet de blog et je suis d'accord avec beaucoup de ce que l'auteur a dit. Cependant, si vous écrivez votre code à l'aide d'interfaces à des fins de test unitaire, je dirais que l'implémentation simulée de l'interface est votre deuxième implémentation. Je dirais que cela n'ajoute vraiment pas beaucoup de complexité à votre code, surtout si le compromis de ne pas le faire a pour conséquence que vos classes sont étroitement couplées et difficiles à tester.

Daniel Mann
la source
3
Absolument raison. Le test de code fait partie de l'application car vous en avez besoin pour obtenir la conception, la mise en œuvre, la maintenance, etc. Le fait que vous ne l'expédiiez pas au client est sans importance - s'il existe une deuxième implémentation dans votre suite de tests, alors la généralité est là et vous devez l'adapter.
Kilian Foth
1
C'est la réponse que je trouve la plus convaincante (et @KilianFoth ajoutant que si le code est livré ou non, une deuxième implémentation existe toujours). Je vais m'arrêter d'accepter un peu la réponse pour voir si quelqu'un d'autre intervient.
Erik Dietrich
J'ajouterais également, lorsque vous dépendez des interfaces dans les tests, que la généralité n'est plus spéculative
Pete
"Ne pas le faire" (en utilisant des interfaces) n'entraîne pas automatiquement un couplage étroit de vos classes. Ce n'est tout simplement pas le cas. Par exemple, dans le .NET Framework, il existe une Streamclasse, mais il n'y a pas de couplage étroit.
Luke Puplett
3

Tester le code en général n'est pas facile. Si c'était le cas, nous l'aurions fait il y a longtemps et nous ne nous en serions pas beaucoup occupés au cours des 10 à 15 dernières années. L'une des plus grandes difficultés a toujours été de déterminer comment tester du code écrit de manière cohérente, bien factorisée et testable sans rompre l'encapsulation. Le directeur de BDD suggère que nous nous concentrions presque entièrement sur le comportement et, à certains égards, semble suggérer que vous n'avez pas vraiment besoin de vous soucier des détails intérieurs dans une large mesure, mais cela peut souvent rendre les choses assez difficiles à tester s'il y en a. de nombreuses méthodes privées qui "trucent" de manière très cachée, car cela peut augmenter la complexité globale de votre test pour traiter tous les résultats possibles à un niveau plus public.

La moquerie peut aider dans une certaine mesure, mais là encore, elle est plutôt orientée vers l'extérieur. L'injection de dépendances peut également fonctionner très bien, encore une fois avec des simulations ou des tests doubles, mais cela peut également nécessiter que vous exposiez des éléments soit via une interface, soit directement, que vous auriez sinon préféré préférer rester masqués - cela est particulièrement vrai si vous souhaitez avoir un bon niveau de sécurité paranoïaque sur certaines classes de votre système.

Pour moi, le jury n'est toujours pas sur la question de savoir si vous devez concevoir vos cours pour qu'ils soient plus facilement testables. Cela peut créer des problèmes si vous devez fournir de nouveaux tests tout en conservant le code hérité. J'accepte que vous devriez pouvoir tester absolument tout dans un système, mais je n'aime pas l'idée d'exposer - même indirectement - les internes privés d'une classe, juste pour que je puisse écrire un test pour eux.

Pour moi, la solution a toujours été d'adopter une approche assez pragmatique et de combiner un certain nombre de techniques adaptées à chaque situation spécifique. J'utilise beaucoup de doublons de test hérités pour exposer les propriétés internes et les comportements de mes tests. Je me moque de tout ce qui peut être attaché à mes classes, et là où cela ne compromettra pas la sécurité de mes classes, je fournirai un moyen de remplacer ou d'injecter des comportements à des fins de test. J'envisagerai même de proposer une interface plus événementielle si cela permet d'améliorer la capacité de tester le code

Là où je trouve un code "non testable" , je cherche à voir si je peux refactoriser pour rendre les choses plus testables. Lorsque vous avez beaucoup de code privé faisant des trucs cachés dans les coulisses, vous trouverez souvent de nouvelles classes en attente d'être éclatées. Ces classes peuvent être utilisées en interne, mais peuvent souvent être testées de manière indépendante avec moins de comportements privés, et par la suite souvent moins de couches d'accès et de complexité. Une chose que je prends soin d'éviter, cependant, est d'écrire du code de production avec un code de test intégré. Il peut être tentant de créer des " cosses de test " qui aboutissent à inclure des horreurs telles que if testing then ..., ce qui indique un problème de test pas complètement déconstruit et incomplètement résolu.

Vous pourriez trouver utile de lire le livre xUnit Test Patterns de Gerard Meszaros , qui couvre tout ce genre de choses avec beaucoup plus de détails que je ne peux en parler ici. Je ne fais probablement pas tout ce qu'il suggère, mais cela aide à clarifier certaines des situations de test les plus difficiles à gérer. À la fin de la journée, vous voulez être en mesure de satisfaire vos exigences de test tout en appliquant vos conceptions préférées, et il est utile d'avoir une meilleure compréhension de toutes les options afin de mieux décider où vous devrez peut-être faire des compromis.

S.Robins
la source
1

Le langage que vous utilisez a-t-il un moyen de "se moquer" d'un objet à tester? Si c'est le cas, ces interfaces ennuyeuses peuvent disparaître.

Sur une note différente, il peut y avoir des raisons d'avoir une SimpleInterface et un seul ComplexThing qui l'implémente. Il peut y avoir des éléments du ComplexThing que vous ne souhaitez pas que l'utilisateur de SimpleInterface puisse accéder. Ce n'est pas toujours à cause d'un codeur OO-ish trop exubérant.

Je vais m'éloigner maintenant et laisser tout le monde sauter sur le fait que le code qui fait cela "sent mauvais" pour eux.


la source
Oui, je travaille dans des langages avec des frameworks moqueurs qui supportent les objets concrets moqueurs. Ces outils nécessitent différents degrés de saut à travers des cerceaux pour le faire.
Erik Dietrich
0

Je répondrai en deux parties:

  1. Vous n'avez pas besoin d'interfaces si vous êtes uniquement intéressé par les tests. J'utilise des frameworks moqueurs à cet effet (en Java: Mockito ou easymock). Je pense que le code que vous concevez ne doit pas être modifié à des fins de test. Écrire du code testable équivaut à écrire du code modulaire, j'ai donc tendance à écrire du code modulaire (testable) et à tester uniquement les interfaces publiques de code.

  2. J'ai travaillé dans un grand projet Java et je suis profondément convaincu que l'utilisation d'interfaces en lecture seule (uniquement les getters) est la voie à suivre (veuillez noter que je suis un grand fan de l'immuabilité). La classe d'implémentation peut avoir des setters, mais c'est un détail d'implémentation qui ne doit pas être exposé aux couches externes. Dans une autre perspective, je préfère la composition à l'héritage (modularité, tu te souviens?), Donc les interfaces aident aussi ici. Je suis prêt à payer le prix d'une généralité spéculative plutôt que de me tirer une balle dans le pied.

Gil Brandao
la source
0

J'ai vu de nombreux avantages depuis que j'ai commencé à programmer davantage sur une interface au-delà du polymorphisme.

  • Cela m'oblige à réfléchir plus avant sur l'interface de la classe (ce sont les méthodes publiques) et comment elle va interagir avec les interfaces des autres classes.
  • Cela m'aide à écrire des classes plus petites qui sont plus cohérentes et suivent le principe de la responsabilité unique.
  • Il est plus facile de tester mon code
  • Classes moins statiques / état global car la classe doit être au niveau de l'instance
  • Plus facile à intégrer et à assembler des pièces avant que le programme entier ne soit prêt
  • Injection de dépendances, séparant la construction d'objets de la logique métier

Beaucoup de gens conviendront que des classes plus nombreuses et plus petites valent mieux que des classes moins nombreuses et plus grandes. Vous n'avez pas à vous concentrer sur autant en même temps et chaque classe a un objectif bien défini. D'autres peuvent dire que vous ajoutez à la complexité en ayant plus de classes.

Il est bon d'utiliser des outils pour améliorer la productivité, mais je pense que s'appuyer uniquement sur des frameworks Mock et autres au lieu de construire la testibilité et la modularité directement dans le code se traduira par un code de qualité inférieure à long terme.

Dans l'ensemble, je crois que cela m'a aidé à écrire du code de meilleure qualité et les avantages l'emportent de loin sur toutes les conséquences.

Despertar
la source