web-dev-qa-db-fra.com

Faut-il concevoir notre code depuis le début pour permettre les tests unitaires?

Il y a actuellement un débat dans notre équipe pour savoir si la modification de la conception du code pour permettre les tests unitaires est une odeur de code, ou dans quelle mesure cela peut être fait sans être une odeur de code. Cela est dû au fait que nous commençons à peine à mettre en place des pratiques qui sont présentes dans à peu près toutes les autres sociétés de développement de logiciels.

Plus précisément, nous aurons un service d'API Web qui sera très mince. Sa principale responsabilité consistera à organiser les demandes/réponses Web et à appeler une API sous-jacente contenant la logique métier.

Un exemple est que nous prévoyons de créer une usine qui renverra un type de méthode d'authentification. Nous n'avons pas besoin qu'elle hérite d'une interface, car nous ne prévoyons pas qu'elle sera autre chose que le type concret qu'elle sera. Cependant, pour tester unitaire le service API Web, nous devrons nous moquer de cette usine.

Cela signifie essentiellement que nous concevons la classe de contrôleur d'API Web pour accepter DI (via son constructeur ou setter), ce qui signifie que nous concevons une partie du contrôleur juste pour permettre DI et implémentons une interface dont nous n'avons pas besoin autrement, ou que nous utilisons un framework tiers comme Ninject pour éviter d'avoir à concevoir le contrôleur de cette façon, mais nous devrons toujours créer une interface.

Certains membres de l'équipe semblent réticents à concevoir du code uniquement à des fins de test. Il me semble qu'il doit y avoir un compromis si vous espérez faire des tests unitaires, mais je ne sais pas comment apaiser leurs inquiétudes.

Pour être clair, il s'agit d'un tout nouveau projet, il ne s'agit donc pas vraiment de modifier le code pour permettre les tests unitaires; il s'agit de concevoir le code que nous allons écrire pour qu'il soit testable à l'unité.

92
Lee

La réticence à modifier le code à des fins de test montre qu'un développeur n'a pas compris le rôle des tests et, par implication, leur propre rôle dans l'organisation.

L'activité logicielle s'articule autour de la fourniture d'une base de code qui crée de la valeur commerciale. Nous avons constaté, par une longue et amère expérience, que nous ne pouvons pas créer de telles bases de code de taille non triviale sans test. Par conséquent, les suites de tests font partie intégrante de l'entreprise.

De nombreux codeurs rendent hommage à ce principe mais ne l'acceptent jamais inconsciemment. Il est facile de comprendre pourquoi c'est le cas; la conscience que notre propre capacité mentale n'est pas infinie, et est en fait étonnamment limitée face à l'énorme complexité d'une base de code moderne, est malvenue et facilement supprimée ou rationalisée. Le fait que le code de test ne soit pas livré au client permet de croire facilement qu'il s'agit d'un citoyen de seconde classe et non essentiel par rapport au code commercial "essentiel". Et l'idée d'ajouter du code de test au code d'entreprise semble doublement offensante pour beaucoup.

Le problème de la justification de cette pratique est lié au fait que l'image complète de la création de valeur dans une entreprise de logiciels n'est souvent comprise que par les hauts responsables de la hiérarchie de l'entreprise, mais ces personnes n'ont pas la compréhension technique détaillée de le workflow de codage nécessaire pour comprendre pourquoi les tests ne peuvent pas être supprimés. Par conséquent, ils sont trop souvent apaisés par les pratiquants qui leur assurent que les tests peuvent être une bonne idée en général, mais "nous sommes des programmeurs Elite qui n'ont pas besoin de béquilles comme ça", ou que "nous ne le faisons pas" t ont le temps pour cela en ce moment ", etc. etc. Le fait que la réussite commerciale soit un jeu de chiffres et qu'éviter la dette technique, assurer la qualité, etc. ne montre sa valeur qu'à long terme signifie qu'ils sont souvent assez sincères dans cette croyance .

Pour faire court: rendre le code testable est une partie essentielle du processus de développement, pas différent que dans d'autres domaines (de nombreuses puces sont conçues avec une proportion substantielle d'éléments niquement à des fins de test), mais c'est très facile d'oublier les très bonnes raisons pour cela. Ne tombez pas dans ce piège.

203
Kilian Foth

Ce n'est pas aussi simple qu'on pourrait le penser. Décomposons-le.

  • L'écriture de tests unitaires est certainement une bonne chose.

MAIS!

  • Toute modification de votre code peut introduire un bogue. Changer le code sans raison commerciale n'est donc pas une bonne idée.

  • Votre webapi "très mince" ne semble pas être le meilleur cas pour les tests unitaires.

  • Changer le code et les tests en même temps est une mauvaise chose.

Je suggérerais l'approche suivante:

  1. Ecrire tests d'intégration . Cela ne devrait nécessiter aucune modification de code. Il vous donnera vos cas de test de base et vous permettra de vérifier que toute autre modification de code que vous apportez n'introduit aucun bogue.

  2. Assurez-vous que le nouveau code est testable et comporte des tests unitaires et d'intégration.

  3. Assurez-vous que votre chaîne CI exécute des tests après les générations et les déploiements.

Une fois ces éléments configurés, commencez alors à réfléchir à la refactorisation des projets hérités pour la testabilité.

J'espère que tout le monde aura tiré les leçons du processus et aura une bonne idée de l'endroit où les tests sont les plus nécessaires, de la façon dont vous souhaitez les structurer et de la valeur que cela apporte à l'entreprise.

EDIT : Depuis que j'ai écrit cette réponse, l'OP a clarifié la question pour montrer qu'ils parlent de nouveau code, pas de modifications du code existant. J'ai peut-être naïvement pensé que "les tests unitaires sont-ils bons?" l'argument a été réglé il y a quelques années.

Il est difficile d'imaginer quelles modifications de code seraient requises par les tests unitaires, mais ce ne serait pas une bonne pratique générale que vous souhaiteriez dans tous les cas. Il serait probablement sage d'examiner les objections réelles, peut-être que c'est le style de test unitaire qui fait l'objet d'une objection.

75
Ewan

Concevoir du code pour être intrinsèquement testable n'est pas une odeur de code; au contraire, c'est le signe d'un bon design. Il existe plusieurs modèles de conception bien connus et largement utilisés basés sur cela (par exemple, Model-View-Presenter) qui offrent des tests faciles (plus faciles) comme un gros avantage.

Donc, si vous avez besoin d'écrire une interface pour votre classe concrète afin de la tester plus facilement, c'est une bonne chose. Si vous avez déjà la classe concrète, la plupart des IDE peuvent en extraire une interface, minimisant ainsi l'effort requis. C'est un peu plus de travail de garder les deux synchronisés, mais une interface ne devrait pas changer beaucoup de toute façon, et les avantages des tests peuvent l'emporter sur cet effort supplémentaire.

D'autre part, comme @MatthieuM. mentionné dans un commentaire, si vous ajoutez des points d'entrée spécifiques dans votre code qui ne devraient jamais être utilisés en production, uniquement à des fins de test, cela pourrait être un problème.

18
mmathis

Il est très simple à mon humble avis de comprendre que pour créer des tests unitaires, le code à tester doit avoir au moins certaines propriétés. Par exemple, si le code ne se compose pas de nités individuelles qui peuvent être testées isolément, le mot "test unitaire" n'a même pas de sens. Si le code n'a pas ces propriétés, il doit d'abord être modifié, c'est assez évident.

Dit que, en théorie, on peut essayer d'écrire une unité de code testable en premier, en appliquant tous les principes SOLID, puis essayer d'écrire un test pour cela ensuite, sans modifier davantage le code d'origine. Malheureusement, écrire du code qui est vraiment testable à l'unité n'est pas toujours simple, il est donc probable qu'il y aura des changements nécessaires que l'on ne détectera qu'en essayant de créer les tests. Cela est vrai pour le code même lorsqu'il a été écrit avec l'idée des tests unitaires à l'esprit, et c'est certainement plus vrai pour le code qui a été écrit où la "testabilité unitaire" n'était pas à l'ordre du jour au début.

Il existe une approche bien connue qui essaie de résoudre le problème en écrivant d'abord les tests unitaires - elle s'appelle Test Driven Development (TDD), et elle peut certainement aider à rendre le code plus testable unitaire dès le début.

Bien sûr, la réticence à changer le code par la suite pour le rendre testable se produit souvent dans une situation où le code a été testé manuellement en premier et/ou fonctionne correctement en production, donc le changer pourrait en fait introduire de nouveaux bogues, c'est vrai. La meilleure approche pour atténuer cela est de créer d'abord une suite de tests de régression (qui peut souvent être implémentée avec seulement des modifications très minimes de la base de code), ainsi que d'autres mesures d'accompagnement comme les révisions de code ou de nouvelles sessions de test manuelles. Cela devrait vous donner suffisamment de confiance pour vous assurer que la refonte de certains composants internes ne casse rien d'important.

13
Doc Brown

Je conteste l'affirmation (non étayée) que vous faites:

pour tester unitairement le service API Web, nous devrons nous moquer de cette usine

Ce n'est pas nécessairement vrai. Il existe de nombreuses façons d'écrire des tests, et il y a sont des façons d'écrire des tests unitaires qui n'impliquent pas de simulations. Plus important encore, il existe d'autres sortes de tests, tels que des tests fonctionnels ou d'intégration. Plusieurs fois, il est possible de trouver une "couture de test" dans une "interface" qui n'est pas un langage de programmation OOP interface).

Quelques questions pour vous aider à trouver une couture de test alternative, qui pourrait être plus naturelle:

  • Vais-je jamais vouloir écrire une fine API Web sur une différente API?
  • Puis-je réduire la duplication de code entre l'API Web et l'API sous-jacente? Peut-on être généré en fonction de l'autre?
  • Puis-je traiter l'ensemble de l'API Web et de l'API sous-jacente comme une seule unité "boîte noire" et faire des affirmations significatives sur le comportement de l'ensemble?
  • Si l'API Web devait être remplacée par une nouvelle implémentation à l'avenir, comment procéderions-nous?
  • Si l'API Web était remplacée par une nouvelle implémentation à l'avenir, les clients de l'API Web pourraient-ils s'en apercevoir? Si c'est le cas, comment?

Une autre affirmation non fondée que vous faites concerne DI:

soit nous concevons la classe de contrôleur API Web pour accepter DI (via son constructeur ou setter), ce qui signifie que nous concevons une partie du contrôleur juste pour permettre DI et implémentons une interface dont nous n'avons pas besoin autrement, ou nous utilisons un tiers comme Ninject pour éviter d'avoir à concevoir le contrôleur de cette façon, mais nous devrons toujours créer une interface.

L'injection de dépendances ne signifie pas nécessairement la création d'un nouveau interface. Par exemple, dans le cas d'un jeton d'authentification: pouvez-vous simplement créer un jeton d'authentification réel par programme? Ensuite, le test peut créer de tels jetons et les injecter. Le processus de validation d'un jeton dépend-il d'un secret cryptographique quelconque? J'espère que vous n'avez pas codé en dur un secret - je m'attends à ce que vous puissiez le lire à partir du stockage d'une manière ou d'une autre, et dans ce cas, vous pouvez simplement utiliser un secret différent (bien connu) dans vos cas de test.

Cela ne veut pas dire que vous ne devez jamais créer un nouveau interface. Mais ne vous focalisez pas sur le fait qu'il n'y a qu'une seule façon d'écrire un test, ou une seule façon de simuler un comportement. Si vous pensez en dehors des sentiers battus, vous pouvez généralement trouver une solution qui nécessitera un minimum de contorsions de votre code tout en vous donnant toujours l'effet souhaité.

11
Daniel Pryden

Vous avez de la chance car il s'agit d'un nouveau projet. J'ai trouvé que Test Driven Design fonctionne très bien pour écrire un bon code (c'est pourquoi nous le faisons en premier lieu).

En déterminant à l'avant comment appeler un morceau de code donné avec des données d'entrée réalistes, puis obtenir des données de sortie réalistes que vous pouvez vérifier comme prévu, vous faites la conception de l'API très tôt dans le processus et ont de bonnes chances d'obtenir une conception utile car vous n'êtes pas gêné par le code existant qui doit être réécrit pour s'adapter. Il est également plus facile à comprendre par vos pairs afin que vous puissiez à nouveau avoir de bonnes discussions au début du processus.

Notez que "utile" dans la phrase ci-dessus signifie non seulement que les méthodes résultantes sont faciles à invoquer, mais aussi que vous avez tendance à obtenir des interfaces propres qui sont faciles à configurer dans les tests d'intégration et à écrire des maquettes.

Considère-le. Surtout avec l'examen par les pairs. D'après mon expérience, l'investissement de temps et d'efforts sera très rapidement rendu.

9

Si vous devez modifier le code, c'est l'odeur du code.

Par expérience personnelle, si mon code est difficile à écrire pour les tests, c'est du mauvais code. Ce n'est pas un mauvais code car il ne fonctionne pas ou ne fonctionne pas comme prévu, c'est mauvais parce que je ne peux pas comprendre rapidement pourquoi il fonctionne. Si je rencontre un bug, je sais que ça va être un travail long et pénible de le corriger. Le code est également difficile/impossible à réutiliser.

Un bon code (propre) décompose les tâches en sections plus petites qui sont faciles à comprendre en un coup d'œil (ou au moins un bon aperçu). Il est facile de tester ces petites sections. Je peux également écrire des tests qui ne testent une partie de la base de code avec la même facilité que si je suis assez confiant sur les sous-sections (la réutilisation aide également ici car elle a déjà été testée).

Gardez le code facile à tester, facile à refactoriser et facile à réutiliser dès le début et vous ne vous tuerez pas chaque fois que vous devrez apporter des modifications.

Je tape ceci tout en reconstruisant complètement un projet qui aurait dû être un prototype jetable en code plus propre. Il est préférable de le corriger dès le début et de remanier le mauvais code dès que possible plutôt que de regarder un écran pendant des heures en ayant peur de toucher quoi que ce soit de peur de casser quelque chose qui fonctionne partiellement.

8
David

Je dirais que l'écriture de code qui ne peut pas être testé à l'unité est une odeur de code. En général, si votre code ne peut pas être testé à l'unité, il n'est pas modulaire, ce qui le rend difficile à comprendre, à maintenir ou à améliorer. Peut-être que si le code est du code collant qui n'a vraiment de sens qu'en termes de tests d'intégration, vous pouvez remplacer les tests d'intégration par des tests unitaires, mais même lorsque l'intégration échoue, vous devrez isoler le problème et les tests unitaires sont un excellent moyen de fais le.

Vous dites

Nous prévoyons de créer une usine qui renverra un type de méthode d'authentification. Nous n'avons pas besoin qu'elle hérite d'une interface, car nous ne prévoyons pas qu'elle sera autre chose que le type concret qu'elle sera. Cependant, pour tester unitaire le service API Web, nous devrons nous moquer de cette usine.

Je ne suis pas vraiment là-dessus. La raison d'avoir une usine qui crée quelque chose est de vous permettre de changer d'usine ou de changer facilement ce que l'usine crée, de sorte que les autres parties du code n'ont pas besoin de changer. Si votre méthode d'authentification ne changera jamais, alors l'usine est inutile. Cependant, si vous voulez avoir une méthode d'authentification différente en test qu'en production, avoir une usine qui renvoie une méthode d'authentification différente en test qu'en production est une excellente solution.

Vous n'avez pas besoin de DI ou de Mocks pour cela. Vous avez juste besoin que votre usine prenne en charge les différents types d'authentification et qu'elle soit configurable d'une manière ou d'une autre, comme à partir d'un fichier de configuration ou d'une variable d'environnement.

4
Old Pro

Dans toutes les disciplines d'ingénierie auxquelles je peux penser, il n'y a qu'une seule façon d'atteindre des niveaux de qualité décents ou supérieurs:

Tenir compte des inspections/essais dans la conception.

Cela vaut pour la construction, la conception de puces, le développement de logiciels et la fabrication. Maintenant, cela ne signifie pas que les tests sont le pilier sur lequel chaque conception doit être construite, pas du tout. Mais à chaque décision de conception, les concepteurs doivent être clairs sur les impacts sur les coûts de test et prendre une décision consciente sur le compromis.

Dans certains cas, les tests manuels ou automatisés (par exemple le sélénium) seront plus pratiques que les tests unitaires, tout en fournissant une couverture de test acceptable par eux-mêmes. Dans de rares cas, jeter quelque chose qui n'est presque pas testé peut également être acceptable. Mais ces décisions doivent être prises au cas par cas. Appeler une conception qui tient compte du test d'une "odeur de code" indique un grave manque d'expérience.

2
Peter

Cela signifie essentiellement que nous concevons la classe de contrôleur d'API Web pour accepter DI (via son constructeur ou setter), ce qui signifie que nous concevons une partie du contrôleur juste pour permettre DI et implémentons une interface dont nous n'avons pas besoin autrement, ou que nous utilisons un framework tiers comme Ninject pour éviter d'avoir à concevoir le contrôleur de cette façon, mais nous devrons toujours créer une interface.

Regardons la différence entre un testable:

public class MyController : Controller
{
    private readonly IMyDependency _thing;

    public MyController(IMyDependency thing)
    {
        _thing = thing;
    }
}

et contrôleur non testable:

public class MyController : Controller
{
}

L'ancienne option a littéralement 5 lignes de code supplémentaires, dont deux peuvent être générées automatiquement par Visual Studio. Une fois que vous avez configuré votre infrastructure d'injection de dépendances pour substituer un type concret à IMyDependency au moment de l'exécution - qui pour tout framework DI décent, est une autre ligne de code - tout fonctionne, sauf que vous pouvez maintenant vous moquer et ainsi tester votre contrôleur au contenu de votre cœur.

6 lignes de code supplémentaires pour permettre la testabilité ... et vos collègues soutiennent que c'est "trop ​​de travail"? Cet argument ne vole pas avec moi, et il ne devrait pas voler avec vous.

Et vous n'avez pas à créer et implémenter une interface pour les tests: Moq , par exemple, vous permet de simuler le comportement d'un type de béton à des fins de tests unitaires. Bien sûr, cela ne vous sera pas très utile si vous ne pouvez pas injecter ces types dans les classes que vous testez.

L'injection de dépendance est une de ces choses qui une fois que vous comprenez, vous vous demandez "comment ai-je pu travailler sans ça?". C'est simple, c'est efficace et ça a du sens. S'il vous plaît, ne laissez pas le manque de compréhension de vos collègues à de nouvelles choses vous empêcher de tester votre projet.

1
Ian Kemp

J'ai trouvé que les tests unitaires (et d'autres types de tests automatisés) ont tendance à réduire les odeurs de code, et je ne peux pas penser à un seul exemple où ils introduisent des odeurs de code. Les tests unitaires vous obligent généralement à écrire un meilleur code. Si vous ne pouvez pas utiliser une méthode facilement testée, pourquoi devrait-elle être plus simple dans votre code?

Des tests unitaires bien écrits vous montrent comment le code est destiné à être utilisé. Ils sont une forme de documentation exécutable. J'ai vu des tests unitaires trop longs et hideusement écrits qui ne pouvaient tout simplement pas être compris. N'écris pas ça! Si vous devez écrire de longs tests pour configurer vos classes, vos classes doivent être refactorisées.

Les tests unitaires mettront en évidence où se trouvent certaines de vos odeurs de code. Je vous conseille de lire Michael C. Feathers 'Travailler efficacement avec Legacy Code. Même si votre projet est nouveau, s'il n'a pas déjà (ou beaucoup) de tests unitaires, vous aurez peut-être besoin de techniques non évidentes pour que votre code puisse être testé correctement.

1
CJ Dennis

En un mot:

Le code testable est (généralement) du code maintenable - ou plutôt, du code dur à tester est généralement dur à maintenir. Concevoir du code qui n'est pas testable s'apparente à concevoir une machine qui n'est pas réparable - dommage le pauvre shmuck qui sera assigné pour le réparer éventuellement (ce pourrait être vous).

Un exemple est que nous prévoyons de créer une usine qui renverra un type de méthode d'authentification. Nous n'avons pas besoin qu'elle hérite d'une interface, car nous ne prévoyons pas qu'elle sera autre chose que le type concret qu'elle sera.

Vous savez que vous aurez besoin de cinq types différents de types de méthodes d'authentification en trois ans, maintenant que vous l'avez dit, non? Les exigences changent, et bien que vous deviez éviter de trop concevoir votre conception, avoir une conception testable signifie que votre conception a (juste) assez de coutures pour être modifiée sans (trop beaucoup) de douleur - et que les tests du module vous fourniront des moyens automatisés pour voir que vos changements ne cassent rien.

1
CharonX

Concevoir autour de l'injection de dépendances n'est pas une odeur de code - c'est la meilleure pratique. L'utilisation de DI n'est pas seulement pour la testabilité. Construire vos composants autour de la DI facilite la modularité et la réutilisabilité, ce qui permet plus facilement de permuter les principaux composants (comme une couche d'interface de base de données). Bien qu'il ajoute un certain degré de complexité, bien fait, il permet une meilleure séparation des couches et une isolation des fonctionnalités, ce qui facilite la gestion et la navigation de la complexité. Cela permet de valider plus facilement le comportement de chaque composant, en réduisant les bogues, et peut également faciliter la traque des bogues.

1
Zenilogix

Lorsque j'écris des tests unitaires, je commence à penser à ce qui pourrait mal tourner dans mon code. Cela m'aide à améliorer la conception du code et à appliquer le principe de responsabilité unique (SRP). De plus, lorsque je reviens pour modifier le même code quelques mois plus tard, cela m'aide à confirmer que les fonctionnalités existantes ne sont pas rompues.

Il y a une tendance à utiliser autant que possible des fonctions pures (applications sans serveur). Les tests unitaires m'aident à isoler l'état et à écrire des fonctions pures.

Plus précisément, nous aurons un service d'API Web qui sera très mince. Sa principale responsabilité consistera à organiser les demandes/réponses Web et à appeler une API sous-jacente contenant la logique métier.

Écrivez d'abord des tests unitaires pour l'API sous-jacente et si vous disposez d'un temps de développement suffisant, vous devez également écrire des tests pour le service API Web léger.

TL; DR, les tests unitaires aident à améliorer la qualité du code et contribuent à rendre les modifications futures du code sans risque. Il améliore également la lisibilité du code. Utilisez des tests au lieu de commentaires pour faire valoir votre point de vue.

0
Ashutosh

L'essentiel, et quel devrait être votre argument avec le lot réticent, c'est qu'il n'y a pas de conflit. La grosse erreur semble avoir été que quelqu'un a inventé l'idée de "concevoir pour tester" aux personnes qui détestent les tests. Ils auraient dû juste fermer la bouche ou Word différemment, comme "prenons le temps de bien faire les choses".

L'idée que "vous devez implémenter une interface" pour rendre quelque chose testable est fausse. L'interface est déjà implémentée, elle n'est tout simplement pas encore déclarée dans la déclaration de classe. Il s'agit de reconnaître les méthodes publiques existantes, de copier leurs signatures dans une interface et de déclarer cette interface dans la déclaration de la classe. Aucune programmation, aucun changement à la logique existante.

Apparemment, certaines personnes ont une idée différente à ce sujet. Je vous suggère d'essayer de résoudre ce problème en premier.

0
Martin Maat