Tests unitaires .NET : dépensez à l'avance pour économiser plus tard
Publié: 2022-03-11Il y a souvent beaucoup de confusion et de doute concernant les tests unitaires lors des discussions avec les parties prenantes et les clients. Les tests unitaires sonnent parfois comme la soie dentaire pour un enfant : « Je me brosse déjà les dents, pourquoi dois-je le faire ? »
Suggérer des tests unitaires semble souvent être une dépense inutile pour les personnes qui considèrent que leurs méthodes de test et leurs tests d'acceptation des utilisateurs sont suffisamment solides.
Mais les tests unitaires sont un outil très puissant et sont plus simples que vous ne le pensez. Dans cet article, nous examinerons les tests unitaires et les outils disponibles dans DotNet tels que Microsoft.VisualStudio.TestTools et Moq .
Nous allons essayer de construire une bibliothèque de classes simple qui calculera le nième terme de la suite de Fibonacci. Pour ce faire, nous voudrons créer une classe pour calculer les séquences de Fibonacci qui dépend d'une classe mathématique personnalisée qui additionne les nombres. Ensuite, nous pouvons utiliser le .NET Testing Framework pour nous assurer que notre programme s'exécute comme prévu.
Qu'est-ce que les tests unitaires ?
Les tests unitaires décomposent le programme en le plus petit morceau de code, généralement au niveau de la fonction, et garantissent que la fonction renvoie la valeur attendue. En utilisant un cadre de test unitaire, les tests unitaires deviennent une entité distincte qui peut ensuite exécuter des tests automatisés sur le programme au fur et à mesure de sa construction.
[TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }
Un simple test unitaire utilisant la méthodologie Arrange, Act, Assert teste que notre bibliothèque mathématique peut correctement additionner 2 + 2.
Une fois les tests unitaires configurés, si une modification est apportée au code, pour tenir compte d'une condition supplémentaire qui n'était pas connue lors du développement initial du programme, par exemple, les tests unitaires indiqueront si tous les cas correspondent aux valeurs attendues. sortie par la fonction.
Les tests unitaires ne sont pas des tests d'intégration. Ce ne sont pas des tests de bout en bout. Bien que ces deux méthodologies soient puissantes, elles doivent fonctionner conjointement avec les tests unitaires, et non en remplacement.
Les avantages et le but des tests unitaires
L'avantage le plus difficile à comprendre des tests unitaires, mais le plus important, est la possibilité de retester le code modifié à la volée. La raison pour laquelle cela peut être si difficile à comprendre est que tant de développeurs se disent : "Je ne toucherai plus jamais à cette fonction" ou "Je la retesterai simplement quand j'aurai terminé". Et les parties prenantes pensent en termes de « si cet article est déjà écrit, pourquoi dois-je le retester ? »
En tant que personne qui a été des deux côtés du spectre du développement, j'ai dit ces deux choses. Le développeur à l'intérieur de moi sait pourquoi nous devons le retester.
Les changements que nous apportons au quotidien peuvent avoir des impacts énormes. Par exemple:
- Votre commutateur tient-il correctement compte d'une nouvelle valeur que vous avez ajoutée ?
- Savez-vous combien de fois vous avez utilisé cet interrupteur ?
- Avez-vous correctement pris en compte les comparaisons de chaînes insensibles à la casse ?
- Vérifiez-vous correctement les valeurs nulles ?
- Une exception de levée est-elle gérée comme prévu ?
Les tests unitaires prennent ces questions et les mémorisent dans le code et un processus pour s'assurer que ces questions reçoivent toujours une réponse. Les tests unitaires peuvent être exécutés avant une construction pour s'assurer que vous n'avez pas introduit de nouveaux bogues. Parce que les tests unitaires sont conçus pour être atomiques, ils sont exécutés très rapidement, généralement moins de 10 millisecondes par test. Même dans une très grande application, une suite de tests complète peut être effectuée en moins d'une heure. Votre processus UAT peut-il correspondre à cela ?
Fibonacci_GetNthTerm_Input2_AssertResult1
qui est la première exécution et inclut le temps de configuration, tous les tests unitaires s'exécutent en moins de 5 ms. Ma convention de dénomination ici est configurée pour rechercher facilement une classe ou une méthode dans une classe que je veux tester
En tant que développeur, cela peut sembler être plus de travail pour vous. Oui, vous avez l'esprit tranquille que le code que vous publiez est bon. Mais les tests unitaires vous offrent également la possibilité de voir où votre conception est faible. Écrivez-vous les mêmes tests unitaires pour deux morceaux de code ? Devraient-ils être sur un seul morceau de code à la place?
Faire en sorte que votre code soit lui-même testable à l'unité est un moyen pour vous d'améliorer votre conception. Et pour la plupart des développeurs qui n'ont jamais effectué de tests unitaires ou qui ne prennent pas autant de temps pour examiner la conception avant de coder, vous pouvez réaliser à quel point votre conception s'améliore en la préparant pour les tests unitaires.
Votre unité de code est-elle testable ?
Outre DRY, nous avons également d'autres considérations.
Vos méthodes ou fonctions essaient-elles d'en faire trop ?
Si vous devez écrire des tests unitaires trop complexes qui s'exécutent plus longtemps que prévu, votre méthode peut être trop compliquée et mieux adaptée à plusieurs méthodes.
Exploitez-vous correctement l'injection de dépendance ?
Si votre méthode testée nécessite une autre classe ou fonction, nous appelons cela une dépendance. Dans les tests unitaires, nous ne nous soucions pas de ce que fait la dépendance sous le capot ; pour les besoins de la méthode testée, il s'agit d'une boîte noire. La dépendance a son propre ensemble de tests unitaires qui détermineront si son comportement fonctionne correctement.
En tant que testeur, vous souhaitez simuler cette dépendance et lui indiquer les valeurs à renvoyer dans des cas spécifiques. Cela vous donnera un meilleur contrôle sur vos cas de test. Pour ce faire, vous devrez injecter une version factice (ou, comme nous le verrons plus tard, simulée) de cette dépendance.
Vos composants interagissent-ils les uns avec les autres comme vous vous y attendez ?
Une fois que vous avez défini vos dépendances et votre injection de dépendances, vous constaterez peut-être que vous avez introduit des dépendances cycliques dans votre code. Si la classe A dépend de la classe B, qui à son tour dépend de la classe A, vous devez reconsidérer votre conception.
La beauté de l'injection de dépendance
Prenons notre exemple de Fibonacci. Votre patron vous dit qu'il a une nouvelle classe plus efficace et plus précise que l'opérateur d'ajout actuel disponible en C#.
Bien que cet exemple particulier ne soit pas très probable dans le monde réel, nous voyons des exemples analogues dans d'autres composants, tels que l'authentification, le mappage d'objets et à peu près n'importe quel processus algorithmique. Pour les besoins de cet article, supposons simplement que la nouvelle fonction d'ajout de votre client est la plus récente et la meilleure depuis l'invention des ordinateurs.
En tant que tel, votre patron vous remet une bibliothèque de boîtes noires avec une seule classe Math
, et dans cette classe, une seule fonction Add
. Votre travail d'implémentation d'une calculatrice Fibonacci ressemblera probablement à ceci :
public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }
Ce n'est pas horrible. Vous instanciez une nouvelle classe Math
et l'utilisez pour ajouter les deux termes précédents pour obtenir le suivant. Vous exécutez cette méthode à travers votre batterie normale de tests, en calculant jusqu'à 100 termes, en calculant le 1000e terme, le 10 000e terme, et ainsi de suite jusqu'à ce que vous soyez convaincu que votre méthodologie fonctionne bien. Puis, dans le futur, un utilisateur se plaint que le 501e terme ne fonctionne pas comme prévu. Vous passez la soirée à parcourir votre code et à essayer de comprendre pourquoi ce cas d'angle ne fonctionne pas. Vous commencez à vous méfier du fait que le cours de Math
le plus récent et le meilleur n'est pas aussi bon que votre patron le pense. Mais c'est une boîte noire et vous ne pouvez pas vraiment le prouver, vous arrivez à une impasse en interne.
Le problème ici est que la dépendance Math
n'est pas injectée dans votre calculatrice Fibonacci. Par conséquent, dans vos tests, vous vous fiez toujours aux résultats Math
existants, non testés et inconnus pour tester Fibonacci. S'il y a un problème avec Math
, alors Fibonacci aura toujours tort (sans coder un cas particulier pour le 501e terme).
L'idée pour corriger ce problème est d'injecter la classe Math
dans votre calculatrice Fibonacci. Mais encore mieux, c'est de créer une interface pour la classe Math
qui définit les méthodes publiques (dans notre cas, Add
) et d'implémenter l'interface sur notre classe Math
.
public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }
Plutôt que d'injecter la classe Math
dans Fibonacci, nous pouvons injecter l'interface IMath
dans Fibonacci. L'avantage ici est que nous pourrions définir notre propre classe OurMath
que nous savons être précise et tester notre calculatrice par rapport à cela. Mieux encore, en utilisant Moq, nous pouvons simplement définir ce que Math.Add
renvoie. Nous pouvons définir un certain nombre de sommes ou nous pouvons simplement dire à Math.Add
de renvoyer x + y.

private IMath _math; public Fibonacci(IMath math) { _math = math; }
Injecter l'interface IMath dans la classe Fibonacci
//setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);
Utilisation de Moq pour définir ce que Math.Add
renvoie.
Nous avons maintenant une méthode éprouvée (enfin, si cet opérateur + est faux en C #, nous avons des problèmes plus importants) pour ajouter deux nombres. En utilisant notre nouveau Mocked IMath
, nous pouvons coder un test unitaire pour notre 501e terme et voir si nous avons gaffé notre implémentation ou si la classe Math
personnalisée a besoin d'un peu plus de travail.
Ne laissez pas une méthode essayer d'en faire trop
Cet exemple souligne également l'idée d'une méthode qui en fait trop. Bien sûr, l'addition est une opération assez simple sans trop besoin d'abstraire ses fonctionnalités de notre méthode GetNthTerm
. Et si l'opération était un peu plus compliquée ? Au lieu d'un ajout, il s'agissait peut-être d'une validation de modèle, d'un appel à une usine pour obtenir un objet sur lequel opérer ou de la collecte de données supplémentaires nécessaires à partir d'un référentiel.
La plupart des développeurs essaieront de s'en tenir à l'idée qu'une méthode a un seul but. Dans les tests unitaires, nous essayons de nous en tenir au principe selon lequel les tests unitaires doivent être appliqués aux méthodes atomiques et en introduisant trop d'opérations dans une méthode, nous la rendons intestable. Nous pouvons souvent créer un problème où nous devons écrire autant de tests pour tester correctement notre fonction.
Chaque paramètre que nous ajoutons à une méthode augmente le nombre de tests que nous devons écrire de manière exponentielle en fonction de la complexité du paramètre. Si vous ajoutez un booléen à votre logique, vous devez doubler le nombre de tests à écrire car vous devez maintenant vérifier les cas vrai et faux avec vos tests actuels. Dans le cas de la validation de modèles, la complexité de nos tests unitaires peut augmenter très rapidement.
Nous sommes tous coupables d'ajouter un petit plus à une méthode. Mais ces méthodes plus vastes et plus complexes créent le besoin de trop de tests unitaires. Et il devient rapidement évident lorsque vous écrivez les tests unitaires que la méthode essaie d'en faire trop. Si vous avez l'impression d'essayer de tester trop de résultats possibles à partir de vos paramètres d'entrée, considérez le fait que votre méthode doit être divisée en une série de plus petites.
Ne vous répétez pas
Un de nos locataires de programmation préférés. Celui-ci devrait être assez simple. Si vous vous retrouvez à écrire les mêmes tests plus d'une fois, vous avez introduit du code plus d'une fois. Il peut vous être avantageux de refactoriser ce travail dans une classe commune accessible aux deux instances que vous essayez d'utiliser.
Quels outils de test unitaire sont disponibles ?
DotNet nous offre une plate-forme de test unitaire très puissante prête à l'emploi. En utilisant cela, vous pouvez mettre en œuvre ce que l'on appelle la méthodologie Arrange, Act, Assert. Vous organisez vos considérations initiales, agissez sur ces conditions avec votre méthode testée, puis affirmez que quelque chose s'est passé. Vous pouvez affirmer n'importe quoi, ce qui rend cet outil encore plus puissant. Vous pouvez affirmer qu'une méthode a été appelée un nombre spécifique de fois, que la méthode a renvoyé une valeur spécifique, qu'un type particulier d'exception a été levé ou toute autre chose à laquelle vous pouvez penser. Pour ceux qui recherchent un framework plus avancé, NUnit et son homologue Java JUnit sont des options viables.
[TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }
Tester que notre méthode de Fibonacci gère les nombres négatifs en levant une exception. Les tests unitaires peuvent vérifier que l'exception a été levée.
Pour gérer l'injection de dépendances, Ninject et Unity existent sur la plate-forme DotNet. Il y a très peu de différence entre les deux, et il s'agit de savoir si vous souhaitez gérer les configurations avec Fluent Syntax ou XML Configuration.
Pour simuler les dépendances, je recommande Moq. Moq peut être difficile à maîtriser, mais l'essentiel est de créer une version simulée de vos dépendances. Ensuite, vous indiquez à la dépendance ce qu'elle doit renvoyer dans des conditions spécifiques. Par exemple, si vous aviez une méthode nommée Square(int x)
qui élevait l'entier au carré, vous pourriez lui dire quand x = 2, retourner 4. Vous pourriez aussi lui dire de retourner x^2 pour n'importe quel entier. Ou vous pouvez lui dire de renvoyer 5 lorsque x = 2. Pourquoi exécuteriez-vous le dernier cas ? Dans le cas où la méthode sous le rôle du test est de valider la réponse de la dépendance, vous pouvez forcer le retour des réponses non valides pour vous assurer que vous attrapez correctement le bogue.
[TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }
Utilisation de Moq pour indiquer à l'interface IMath
comment gérer l' Add
sous test. Vous pouvez définir des cas explicites avec It.Is
ou une plage avec It.IsInRange
.
Cadres de test unitaire pour DotNet
Cadre de test unitaire Microsoft
Microsoft Unit Testing Framework est la solution de test unitaire prête à l'emploi de Microsoft et incluse avec Visual Studio. Parce qu'il est livré avec VS, il s'intègre bien avec lui. Lorsque vous démarrez un projet, Visual Studio vous demande si vous souhaitez créer une bibliothèque de tests unitaires à côté de votre application.
Microsoft Unit Testing Framework est également fourni avec un certain nombre d'outils pour vous aider à mieux analyser vos procédures de test. De plus, comme il appartient et est écrit par Microsoft, il y a un certain sentiment de stabilité dans son existence à l'avenir.
Mais lorsque vous travaillez avec des outils Microsoft, vous obtenez ce qu'ils vous donnent. Le Microsoft Unit Testing Framework peut être lourd à intégrer.
NUnité
Le plus gros avantage pour moi dans l'utilisation de NUnit est les tests paramétrés. Dans notre exemple Fibonacci ci-dessus, nous pouvons entrer un certain nombre de cas de test et nous assurer que ces résultats sont vrais. Et dans le cas de notre 501e problème, nous pouvons toujours ajouter un nouveau jeu de paramètres pour garantir que le test est toujours exécuté sans avoir besoin d'une nouvelle méthode de test.
L'inconvénient majeur de NUnit est son intégration dans Visual Studio. Il manque les cloches et les sifflets fournis avec la version Microsoft et signifie que vous devrez télécharger votre propre ensemble d'outils.
xUnit.Net
xUnit est très populaire en C# car il s'intègre parfaitement à l'écosystème .NET existant. Nuget a de nombreuses extensions de xUnit disponibles. Il s'intègre également parfaitement à Team Foundation Server, même si je ne sais pas combien de développeurs .NET utilisent encore TFS sur diverses implémentations Git.
En revanche, de nombreux utilisateurs se plaignent que la documentation de xUnit manque un peu. Pour les nouveaux utilisateurs des tests unitaires, cela peut causer un énorme mal de tête. De plus, l'extensibilité et l'adaptabilité de xUnit rendent également la courbe d'apprentissage un peu plus raide que NUnit ou le cadre de test unitaire de Microsoft.
Conception/Développement piloté par les tests
La conception / développement piloté par les tests (TDD) est un sujet un peu plus avancé qui mérite son propre article. Cependant, je voulais faire une introduction.
L'idée est de commencer par vos tests unitaires et de dire à vos tests unitaires ce qui est correct. Ensuite, vous pouvez écrire votre code autour de ces tests. En théorie, le concept semble simple, mais en pratique, il est très difficile d'entraîner votre cerveau à réfléchir à l'application. Mais l'approche a l'avantage intégré de ne pas être obligé d'écrire vos tests unitaires après coup. Cela conduit à moins de refactorisation, de réécriture et de confusion de classe.
TDD a été un peu un mot à la mode ces dernières années, mais l'adoption a été lente. Sa nature conceptuelle est déroutante pour les parties prenantes, ce qui rend difficile son approbation. Mais en tant que développeur, je vous encourage à écrire même une petite application en utilisant l'approche TDD pour vous habituer au processus.
Pourquoi vous ne pouvez pas avoir trop de tests unitaires
Les tests unitaires sont l'un des outils de test les plus puissants dont disposent les développeurs. Ce n'est en aucun cas suffisant pour un test complet de votre application, mais ses avantages en matière de tests de régression, de conception de code et de documentation des objectifs sont inégalés.
Il n'y a rien de tel que d'écrire trop de tests unitaires. Chaque cas marginal peut proposer de gros problèmes sur toute la ligne de votre logiciel. La mémorisation des bogues trouvés sous forme de tests unitaires peut garantir que ces bogues ne trouvent pas de moyens de revenir dans votre logiciel lors de modifications ultérieures du code. Bien que vous puissiez ajouter 10 à 20 % au budget initial de votre projet, vous pourriez économiser beaucoup plus que cela en termes de formation, de corrections de bogues et de documentation.
Vous pouvez trouver le dépôt Bitbucket utilisé dans cet article ici.