Lorsque je fais des tests unitaires de la manière "appropriée", c'est-à-dire en bloquant chaque appel public et en renvoyant des valeurs prédéfinies ou des simulations, j'ai l'impression de ne rien tester. Je regarde littéralement mon code et je crée des exemples basés sur le flux de logique à travers mes méthodes publiques. Et chaque fois que l'implémentation change, je dois aller changer ces tests, encore une fois, sans vraiment sentir que j'accomplis quelque chose d'utile (que ce soit à moyen ou à long terme). Je fais également des tests d'intégration (y compris des chemins non-heureux) et je ne me soucie pas vraiment de l'augmentation des temps de test. Avec ceux-ci, j'ai l'impression de tester des régressions, car ils en ont attrapé plusieurs, alors que tout ce que font les tests unitaires est de me montrer que l'implémentation de ma méthode publique a changé, ce que je sais déjà.
Les tests unitaires sont un vaste sujet, et j'ai l'impression que je ne comprends pas quelque chose ici. Quel est l'avantage décisif des tests unitaires par rapport aux tests d'intégration (hors temps système)?
Lorsque je fais des tests unitaires de la manière "appropriée", c'est-à-dire en bloquant chaque appel public et en renvoyant des valeurs prédéfinies ou des simulations, j'ai l'impression de ne rien tester. Je regarde littéralement mon code et je crée des exemples basés sur le flux de logique à travers mes méthodes publiques.
Cela ressemble à la méthode que vous testez a besoin de plusieurs autres instances de classe (dont vous devez vous moquer), et appelle plusieurs méthodes de son propre chef.
Ce type de code est en effet difficile à tester unitaire, pour les raisons que vous expliquez.
Ce que j'ai trouvé utile, c'est de diviser ces classes en:
Ensuite, les classes de 1. sont faciles à tester, car elles acceptent simplement des valeurs et renvoient un résultat. Dans des cas plus complexes, ces classes peuvent avoir besoin d'effectuer des appels par elles-mêmes, mais elles n'appelleront que les classes à partir de 2. (et n'appelleront pas directement par exemple une fonction de base de données), et les classes à partir de 2. sont faciles à simuler (car elles ne exposez les parties du système enveloppé dont vous avez besoin).
Les classes de 2. et 3. ne peuvent généralement pas être testées de manière significative (car elles ne font rien d'utile par elles-mêmes, elles ne sont que du "collage" de code). OTOH, ces classes ont tendance à être relativement simples (et peu nombreuses), elles doivent donc être correctement couvertes par des tests d'intégration.
n exemple
Une classe
Supposons que vous ayez une classe qui récupère un prix dans une base de données, applique des remises, puis met à jour la base de données.
Si vous avez tout cela dans une seule classe, vous devrez appeler les fonctions DB, qui sont difficiles à moquer. En pseudocode:
1 select price from database
2 perform price calculation, possibly fetching parameters from database
3 update price in database
Les trois étapes nécessiteront un accès DB, donc beaucoup de moqueries (complexes), qui risquent de se casser si le code ou la structure DB change.
Fractionner
Vous vous divisez en trois classes: PriceCalculation, PriceRepository, App.
PriceCalculation effectue uniquement le calcul réel et obtient les valeurs dont il a besoin. L'application relie tout ensemble:
App:
fetch price data from PriceRepository
call PriceCalculation with input values
call PriceRepository to update prices
De cette façon:
Enfin, il peut s'avérer que PriceCalculation doit effectuer ses propres appels de base de données. Par exemple, car seul PriceCalculation connaît les données dont il a besoin, il ne peut donc pas être récupéré à l'avance par App. Ensuite, vous pouvez lui passer une instance de PriceRepository (ou une autre classe de référentiel), adaptée aux besoins de PriceCalculation. Il faudra alors se moquer de cette classe, mais ce sera simple, car l'interface de PriceRepository est simple, par ex. PriceRepository.getPrice(articleNo, contractType)
. Plus important encore, l'interface de PriceRepository isole PriceCalculation de la base de données, il est donc peu probable que les modifications apportées au schéma de base de données ou à l'organisation des données changent son interface et, par conséquent, rompent les simulations.
Quel est l'avantage décisif des tests unitaires par rapport aux tests d'intégration?
C'est une fausse dichotomie.
Les tests unitaires et les tests d'intégration ont deux objectifs similaires mais différents. Le but des tests unitaires est de s'assurer que vos méthodes fonctionnent. En termes pratiques, les tests unitaires s'assurent que le code remplit le contrat défini par les tests unitaires. Cela est évident dans la façon dont les tests unitaires sont conçus : ils indiquent spécifiquement ce que le code est censé faire, et affirment que le code fait cela.
Les tests d'intégration sont différents. Les tests d'intégration exercent l'interaction entre les composants logiciels. Vous pouvez avoir des composants logiciels qui réussissent tous leurs tests et échouent toujours aux tests d'intégration car ils n'interagissent pas correctement.
Cependant, s'il existe un avantage décisif pour les tests unitaires, c'est celui-ci: les tests unitaires sont beaucoup plus faciles à configurer et nécessitent beaucoup moins de temps et d'efforts que les tests d'intégration. Lorsqu'ils sont utilisés correctement, les tests unitaires encouragent le développement d'un code "testable", ce qui signifie que le résultat final sera plus fiable, plus facile à comprendre et plus facile à maintenir. Le code testable a certaines caractéristiques, comme une API cohérente, un comportement répétable et il renvoie des résultats faciles à affirmer.
Les tests d'intégration sont plus difficiles et plus coûteux, car vous avez souvent besoin d'une simulation élaborée, d'une configuration complexe et d'assertions difficiles. Au plus haut niveau d'intégration système, imaginez essayer de simuler une interaction humaine dans une interface utilisateur. Des systèmes logiciels entiers sont consacrés à ce type d'automatisation. Et c'est l'automatisation que nous recherchons; les tests humains ne sont pas reproductibles et ne sont pas évolutifs comme le font les tests automatisés.
Enfin, les tests d'intégration ne garantissent pas la couverture du code. Combien de combinaisons de boucles de code, de conditions et de branches testez-vous avec vos tests d'intégration? Le savez-vous vraiment? Il existe des outils que vous pouvez utiliser avec les tests unitaires et les méthodes sous test qui vous indiqueront la couverture de code dont vous disposez et la complexité cyclomatique de votre code. Mais ils ne fonctionnent vraiment bien qu'au niveau de la méthode, où vivent les tests unitaires.
Si vos tests changent chaque fois que vous refactorisez, c'est un problème différent. Les tests unitaires sont censés consister à documenter ce que fait votre logiciel, à prouver qu'il le fait, puis à prouver qu'il le fait à nouveau lorsque vous refactorisez le sous-jacent la mise en oeuvre. Si votre API change, ou si vous avez besoin que vos méthodes changent en fonction d'un changement dans la conception du système, c'est ce qui est censé se produire. Si cela se produit souvent, pensez à écrire vos tests d'abord, avant d'écrire du code. Cela vous forcera à réfléchir à l'architecture globale et vous permettra de écrire du code avec l'API déjà établie.
Si vous passez beaucoup de temps à écrire des tests unitaires pour du code trivial comme
public string SomeProperty { get; set; }
alors vous devriez réexaminer votre approche. Le test unitaire est censé tester le comportement , et il n'y a aucun comportement dans la ligne de code ci-dessus. Vous avez cependant créé une dépendance dans votre code quelque part, car cette propriété va presque certainement être référencée ailleurs dans votre code. Au lieu de cela, envisagez d'écrire des méthodes qui acceptent la propriété nécessaire comme paramètre:
public string SomeMethod(string someProperty);
Maintenant, votre méthode n'a plus de dépendances sur quelque chose en dehors d'elle-même, et elle est maintenant plus testable, car elle est complètement autonome. Certes, vous ne pourrez pas toujours le faire, mais cela déplace votre code dans le sens d'être plus testable, et cette fois, vous écrivez un test unitaire pour le comportement réel.
Les tests unitaires avec des simulations visent à s'assurer que l'implémentation de la classe est correcte. Vous vous moquez des interfaces publiques des dépendances du code que vous testez. De cette façon, vous contrôlez tout ce qui est externe à la classe et vous êtes sûr qu'un test qui échoue est dû à quelque chose interne à la classe et non à l'un des autres objets.
Vous testez également le comportement de la classe testée et non l'implémentation. Si vous refactorisez le code (création de nouvelles méthodes internes, etc.), les tests unitaires ne devraient pas échouer. Mais si vous modifiez ce que fait la méthode publique, les tests doivent absolument échouer car vous avez modifié le comportement.
Il semble également que vous écriviez les tests après avoir écrit le code, essayez plutôt d'écrire d'abord les tests. Essayez de décrire le comportement que doit avoir la classe, puis écrivez la quantité de code minimum pour réussir les tests.
Les tests unitaires et les tests d'intégration sont utiles pour garantir la qualité de votre code. Les tests unitaires examinent chaque composant isolément. Et les tests d'intégration garantissent que tous les composants interagissent correctement. Je veux avoir les deux types dans ma suite de tests.
Les tests unitaires m'ont aidé dans mon développement car je peux me concentrer sur une seule pièce de l'application à la fois. Se moquer des composants que je n'ai pas encore fait. Ils sont également parfaits pour la régression, car ils documentent tous les bugs dans la logique que j'ai trouvée (même dans les tests unitaires).
MISE À JOUR
La création d'un test qui garantit uniquement que les méthodes sont appelées a de la valeur dans la mesure où vous vous assurez que les méthodes sont effectivement appelées. En particulier, si vous écrivez d'abord vos tests, vous disposez d'une liste de contrôle des méthodes qui doivent se produire. Étant donné que ce code est à peu près procédural, vous n'avez pas grand-chose à vérifier à part le fait que les méthodes soient appelées. Vous protégez le code pour le changement à l'avenir. Lorsque vous devez appeler une méthode avant l'autre. Ou qu'une méthode est toujours appelée même si la méthode initiale lève une exception.
Le test de cette méthode peut ne jamais changer ou peut changer uniquement lorsque vous modifiez les méthodes. Pourquoi est-ce une mauvaise chose? Cela aide à renforcer l'utilisation des tests. Si vous devez corriger un test après avoir changé le code, vous aurez l'habitude de changer les tests avec le code.
Je rencontrais une question similaire - jusqu'à ce que je découvre la puissance des tests de composants. En bref, ce sont les mêmes que les tests unitaires sauf que vous ne vous moquez pas par défaut mais utilisez des objets réels (idéalement via l'injection de dépendances).
De cette façon, vous pouvez rapidement créer des tests robustes avec une bonne couverture de code. Pas besoin de mettre à jour vos mocks tout le temps. Il peut être un peu moins précis que les tests unitaires avec des simulations à 100%, mais le temps et l'argent que vous économisez compensent cela. La seule chose dont vous avez vraiment besoin pour utiliser des simulateurs ou des appareils est des backends de stockage ou des services externes.
En fait, une moquerie excessive est un anti-modèle: Anti-Patterns TDD et Les moqueries sont mauvaises .