En programmation et en TDD en particulier, il y a de bons principes auxquels il est utile de se conformer: SEC et test par des méthodes publiques. Ils ont fait leurs preuves à plusieurs reprises dans la pratique, mais dans les projets avec un grand code hérité, ils peuvent avoir un "côté obscur". Par exemple, vous pouvez écrire du code guidé par ces principes, puis vous retrouver à démonter des tests couvrant un tas de 20+ abstractions avec une configuration qui est incomparablement plus grande que la logique testée. Ce «côté obscur» fait peur aux gens et empêche l'utilisation du TDD dans les projets. Sous la coupe, je discute pourquoi les tests par des méthodes publiques sont mauvais et comment réduire les problèmes qui se posent en raison de ce principe.
Clause de non-responsabilitéImmédiatement, je veux dissiper l'impression possible. Certains peuvent même ne pas ressentir les inconvénients qui seront discutés, en raison, par exemple, de la taille de leurs projets. De plus, ces lacunes, à mon avis, font partie de la dette technique et ont les mêmes caractéristiques: le problème s'aggravera s'il n'est pas pris en compte. Par conséquent, il est nécessaire de décider en fonction de la situation.
L'idée sous-jacente au principe sonne bien: vous devez tester le comportement, pas la mise en œuvre. Cela signifie que vous n'avez qu'à tester l'interface de classe. En pratique, ce n'est pas toujours le cas. Pour présenter l'essence du problème, imaginez que vous disposez d'une méthode qui calcule le coût des travailleurs engagés dans le travail posté. Il s’agit d’une tâche non triviale en ce qui concerne le travail posté, ils ont des pourboires, des bonus, des week-ends, des vacances, des règles d'entreprise, etc., etc. Cette méthode effectue en interne de nombreuses opérations et utilise d'autres services qui lui donnent des informations sur les vacances, les pourboires, etc. lors de l'écriture d'un test unitaire pour celui-ci, il est nécessaire de créer une configuration pour tous les services utilisés, si le code testé se situe quelque part à la fin de la méthode. Dans le même temps, le code testé lui-même ne peut utiliser que partiellement ou pas du tout les services configurables. Et il y a déjà des tests unitaires écrits de cette façon.
Moins 1: Surconfiguration du test unitaire
Vous voulez maintenant ajouter une réaction à une nouvelle fonctionnalité qui a une logique non triviale et qui est également utilisée quelque part à la fin de la méthode. La nature de l'indicateur est telle qu'il fait partie de la logique de service et, en même temps, ne fait pas partie de l'interface de service. Dans le cas ci-dessus, ce code n'est pertinent que pour cette méthode publique et peut généralement être inscrit à l'intérieur de l'ancienne méthode.
Si le projet a adopté la règle pour tout tester uniquement via des méthodes publiques, le développeur peut très probablement simplement copier un test unitaire existant et le modifier un peu. Dans le nouveau test, il y aura toujours une configuration de tous les services pour exécuter la méthode. D'une part, nous avons respecté le principe, mais, d'autre part, nous avons obtenu un test unitaire avec surconfiguration. À l'avenir, si quelque chose se casse ou nécessite un changement de configuration, vous devrez faire le travail de singe pour ajuster les tests. Elle est fastidieuse, longue et n'apporte ni joie ni bénéfice apparent au client. Il semblerait que nous suivions le bon principe, mais nous sommes dans la même situation que nous voulions nous éloigner, refusant de tester des méthodes privées.
Moins 2: Couverture incomplète
En outre, un facteur humain tel que la paresse peut intervenir. Par exemple, une méthode privée avec une logique de drapeau non triviale peut ressembler à cet exemple.
private bool HasShifts(DateTime date, int tolerance, bool clockIn, Shift[] shifts, int[] locationIds) { bool isInLimit(DateTime date1, DateTime date2, int limit) => Math.Abs(date2.Subtract(date1).TotalMinutes) <= limit; var shiftsOfLocations = shifts.Where(x => locationIds.Contains(x.LocationId)); return clockIn ? shiftsOfLocations.Any(x => isInLimit(date, x.StartDate, tolerance)) : shiftsOfLocations.Any(x => isInLimit(date, x.EndDate, tolerance)); }
Cette méthode nécessite 10 contrôles pour couvrir tous les cas, dont 8 sont significatifs.
Décodage de 8 cas importants- shiftsOfLocations - 2 valeurs - que ce soit ou non
- clockIn - 2 valeurs - vrai ou faux
- tolérance - 2 significations différentes
Total: 2 x 2 x 2 = 8
Lors de l'écriture de tests unitaires pour tester cette logique, un développeur devra écrire au moins 8 grands tests unitaires. Je suis tombé sur des cas où la configuration de test unitaire a pris plus de 50 lignes de code, avec 4 lignes d'appel direct. C'est-à-dire seulement environ 10% du code porte une charge utile. Dans ce cas, la tentation est grande de réduire la quantité de travail en écrivant moins de tests unitaires. Par conséquent, sur 8, par exemple, il ne reste que deux tests unitaires, pour chaque valeur de clockIn. Cette situation conduit au fait que, encore une fois, il est fastidieux et long d'écrire tous les tests nécessaires, de créer la configuration (Ctrl + C, V fonctionne, où serait-il sans), ou la méthode ne reste que partiellement couverte. Chaque option a ses conséquences désagréables.
Solutions possibles
En plus du principe "comportement de test", il existe toujours l'OCP (principe ouvert / fermé). En l'appliquant correctement, vous pouvez oublier ce que sont les «tests fragiles», en testant le comportement interne du module. Si vous avez besoin d'un nouveau comportement de module, vous écrirez de nouveaux tests unitaires pour la nouvelle classe successeur dans laquelle le comportement dont vous avez besoin sera modifié. Vous n'aurez alors pas à passer du temps à revérifier et à mettre à jour les tests existants. Dans ce cas, cette méthode peut être déclarée comme interne ou interne protégée et testée en ajoutant InternalsVisibleTo à l'assembly. Dans ce cas, votre interface IClass ne souffrira pas, et les tests seront les plus laconiques, non sujets à des changements fréquents.
Une autre alternative serait de déclarer une classe auxiliaire supplémentaire dans laquelle notre méthode peut être tirée en la déclarant publique. Ensuite, le principe sera observé et le test sera concis. À mon avis, cette approche n'est pas toujours payante. Par exemple, certains peuvent décider de tirer même une méthode dans une classe, ce qui conduit à la création d'un groupe de classes avec une seule méthode. L'autre extrême peut être de vider ces méthodes dans une classe d'assistance, qui se transforme en classe d'assistance GOD. Mais cette option avec un assistant peut être la seule si l'assembly de travail est signé avec un nom fort, et vous ne pouvez pas signer l'assembly de test, pour une raison quelconque. InternalsVisibleTo fonctionnera lorsque les deux assemblys seront signés ou pas en même temps.
Résumé
Et à la fin, en raison d'une combinaison de ces problèmes, l'idée de TDD et de tests unitaires souffre, car personne n'a le désir d'écrire des tests volumétriques et de les soutenir. Je serai heureux de voir des exemples de la façon dont le strict respect de ce principe a conduit à des problèmes et à une motivation réduite pour écrire des tests de l'équipe de développement.