Tests unitaires, comment écrire du code testable et pourquoi c'est important
Publié: 2022-03-11Les tests unitaires sont un instrument essentiel dans la boîte à outils de tout développeur de logiciel sérieux. Cependant, il peut parfois être assez difficile d'écrire un bon test unitaire pour un morceau de code particulier. Ayant des difficultés à tester leur propre code ou celui de quelqu'un d'autre, les développeurs pensent souvent que leurs difficultés sont causées par un manque de connaissances fondamentales en matière de test ou de techniques de test unitaire secrètes.
Dans ce tutoriel sur les tests unitaires, j'ai l'intention de démontrer que les tests unitaires sont assez simples ; les vrais problèmes qui compliquent les tests unitaires et introduisent une complexité coûteuse sont le résultat d'un code mal conçu et non testable . Nous discuterons de ce qui rend le code difficile à tester, des anti-modèles et des mauvaises pratiques à éviter pour améliorer la testabilité, et des autres avantages que nous pouvons obtenir en écrivant du code testable. Nous verrons qu'écrire des tests unitaires et générer du code testable ne consiste pas seulement à rendre les tests moins gênants, mais à rendre le code lui-même plus robuste et plus facile à maintenir.
Qu'est-ce que les tests unitaires ?
Essentiellement, un test unitaire est une méthode qui instancie une petite partie de notre application et vérifie son comportement indépendamment des autres parties . Un test unitaire typique contient 3 phases : d'abord, il initialise un petit morceau d'une application qu'il souhaite tester (également connu sous le nom de système sous test, ou SUT), puis il applique un stimulus au système sous test (généralement en appelant un méthode dessus), et enfin, il observe le comportement résultant. Si le comportement observé est conforme aux attentes, le test unitaire réussit, sinon, il échoue, indiquant qu'il y a un problème quelque part dans le système testé. Ces trois phases de test unitaire sont également appelées Arrange, Act et Assert, ou simplement AAA.
Un test unitaire peut vérifier différents aspects comportementaux du système testé, mais il tombera très probablement dans l'une des deux catégories suivantes : basé sur l'état ou basé sur l'interaction . Vérifier que le système testé produit des résultats corrects, ou que son état résultant est correct, est appelé test unitaire basé sur l'état, tandis que vérifier qu'il invoque correctement certaines méthodes est appelé test unitaire basé sur l'interaction .
Comme métaphore pour les tests unitaires de logiciel appropriés, imaginez un scientifique fou qui veut construire une chimère surnaturelle, avec des cuisses de grenouille, des tentacules de poulpe, des ailes d'oiseau et une tête de chien. (Cette métaphore est assez proche de ce que les programmeurs font réellement au travail). Comment ce scientifique s'assurerait-il que chaque pièce (ou unité) qu'il a choisie fonctionne réellement ? Eh bien, il peut prendre, disons, une seule cuisse de grenouille, lui appliquer un stimulus électrique et vérifier la bonne contraction musculaire. Ce qu'il fait est essentiellement les mêmes étapes Arrange-Act-Assert du test unitaire; la seule différence est que, dans ce cas, l'unité fait référence à un objet physique, et non à un objet abstrait à partir duquel nous construisons nos programmes.
J'utiliserai C# pour tous les exemples de cet article, mais les concepts décrits s'appliquent à tous les langages de programmation orientés objet.
Un exemple de test unitaire simple pourrait ressembler à ceci :
[TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }
Test unitaire vs test d'intégration
Une autre chose importante à considérer est la différence entre les tests unitaires et les tests d'intégration.
Le but d'un test unitaire en génie logiciel est de vérifier le comportement d'un logiciel relativement petit, indépendamment des autres parties. Les tests unitaires ont une portée étroite et nous permettent de couvrir tous les cas, garantissant que chaque pièce fonctionne correctement.
D'autre part, les tests d'intégration démontrent que les différentes parties d'un système fonctionnent ensemble dans l'environnement réel . Ils valident des scénarios complexes (nous pouvons considérer les tests d'intégration comme un utilisateur effectuant une opération de haut niveau au sein de notre système) et nécessitent généralement la présence de ressources externes, telles que des bases de données ou des serveurs Web.
Revenons à notre métaphore du savant fou, et supposons qu'il ait réussi à combiner toutes les parties de la chimère. Il souhaite effectuer un test d'intégration de la créature résultante, en s'assurant qu'elle peut, disons, marcher sur différents types de terrain. Tout d'abord, le scientifique doit imiter un environnement sur lequel la créature peut marcher. Ensuite, il jette la créature dans cet environnement et la pique avec un bâton, observant si elle marche et se déplace comme prévu. Après avoir terminé un test, le savant fou nettoie toute la saleté, le sable et les roches qui sont maintenant éparpillés dans son joli laboratoire.
Notez la différence significative entre les tests unitaires et d'intégration : un test unitaire vérifie le comportement d'une petite partie de l'application, isolée de l'environnement et d'autres parties, et est assez facile à mettre en œuvre, tandis qu'un test d'intégration couvre les interactions entre différents composants, dans le environnement proche de la vie réelle et nécessite plus d'efforts, y compris des phases de configuration et de démontage supplémentaires.
Une combinaison raisonnable de tests unitaires et d'intégration garantit que chaque unité fonctionne correctement, indépendamment des autres, et que toutes ces unités fonctionnent bien lorsqu'elles sont intégrées, nous donnant un niveau élevé de confiance que l'ensemble du système fonctionne comme prévu.
Cependant, nous devons nous rappeler de toujours identifier le type de test que nous mettons en œuvre : un test unitaire ou un test d'intégration. La différence peut parfois être trompeuse. Si nous pensons que nous écrivons un test unitaire pour vérifier un cas subtil dans une classe de logique métier et que nous réalisons qu'il nécessite la présence de ressources externes telles que des services Web ou des bases de données, quelque chose ne va pas - essentiellement, nous utilisons un marteau pour casser une noix. Et cela signifie une mauvaise conception.
Qu'est-ce qui fait un bon test unitaire ?
Avant de plonger dans la partie principale de ce tutoriel et d'écrire des tests unitaires, discutons rapidement des propriétés d'un bon test unitaire. Les principes des tests unitaires exigent qu'un bon test soit :
Facile à écrire. Les développeurs écrivent généralement de nombreux tests unitaires pour couvrir différents cas et aspects du comportement de l'application, il devrait donc être facile de coder toutes ces routines de test sans effort énorme.
Lisible. L'intention d'un test unitaire doit être claire. Un bon test unitaire raconte une histoire sur certains aspects comportementaux de notre application, il devrait donc être facile de comprendre quel scénario est testé et, si le test échoue, de détecter facilement comment résoudre le problème. Avec un bon test unitaire, on peut corriger un bogue sans réellement déboguer le code !
Fiable. Les tests unitaires ne devraient échouer que s'il y a un bogue dans le système testé. Cela semble assez évident, mais les programmeurs rencontrent souvent un problème lorsque leurs tests échouent même lorsqu'aucun bogue n'a été introduit. Par exemple, les tests peuvent réussir lors de l'exécution un par un, mais échouer lors de l'exécution de l'ensemble de la suite de tests, ou passer sur notre machine de développement et échouer sur le serveur d'intégration continue. Ces situations sont révélatrices d'un défaut de conception. De bons tests unitaires doivent être reproductibles et indépendants de facteurs externes tels que l'environnement ou l'ordre de marche.
Vite. Les développeurs écrivent des tests unitaires afin de pouvoir les exécuter à plusieurs reprises et vérifier qu'aucun bogue n'a été introduit. Si les tests unitaires sont lents, les développeurs sont plus susceptibles de ne pas les exécuter sur leurs propres machines. Un test lent ne fera pas une différence significative ; ajoutez mille autres et nous sommes sûrement coincés à attendre un moment. Les tests unitaires lents peuvent également indiquer que le système testé ou le test lui-même interagit avec des systèmes externes, ce qui le rend dépendant de l'environnement.
Vraiment unité, pas intégration. Comme nous en avons déjà discuté, les tests unitaires et d'intégration ont des objectifs différents. Le test unitaire et le système testé ne doivent pas accéder aux ressources du réseau, aux bases de données, au système de fichiers, etc., pour éliminer l'influence de facteurs externes.
C'est tout — il n'y a pas de secrets pour écrire des tests unitaires . Cependant, certaines techniques nous permettent d'écrire du code testable .
Code testable et non testable
Certains codes sont écrits de telle manière qu'il est difficile, voire impossible, d'écrire un bon test unitaire pour celui-ci. Alors, qu'est-ce qui rend le code difficile à tester ? Passons en revue quelques anti-modèles, odeurs de code et mauvaises pratiques que nous devrions éviter lors de l'écriture de code testable.
Empoisonner la base de code avec des facteurs non déterministes
Commençons par un exemple simple. Imaginez que nous écrivions un programme pour un microcontrôleur de maison intelligente et que l'une des exigences soit d'allumer automatiquement la lumière dans le jardin si un mouvement y est détecté le soir ou la nuit. Nous avons commencé de bas en haut en implémentant une méthode qui renvoie une représentation sous forme de chaîne de l'heure approximative de la journée ("Night", "Morning", "Afternoon" ou "Evening") :
public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }
Essentiellement, cette méthode lit l'heure système actuelle et renvoie un résultat basé sur cette valeur. Alors, qu'est-ce qui ne va pas avec ce code ?
Si nous y réfléchissons du point de vue des tests unitaires, nous verrons qu'il n'est pas possible d'écrire un test unitaire basé sur l'état approprié pour cette méthode. DateTime.Now
est, essentiellement, une entrée masquée, qui changera probablement pendant l'exécution du programme ou entre les tests. Ainsi, les appels ultérieurs à celui-ci produiront des résultats différents.
Un tel comportement non déterministe rend impossible le test de la logique interne de la méthode GetTimeOfDay()
sans modifier réellement la date et l'heure du système. Voyons comment un tel test devrait être mis en œuvre :
[TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }
Des tests comme celui-ci violeraient de nombreuses règles discutées précédemment. Il serait coûteux d'écrire (en raison de la logique de configuration et de démontage non triviale), peu fiable (il peut échouer même s'il n'y a pas de bogue dans le système testé, en raison de problèmes d'autorisation système, par exemple), et non garanti pour cours vite. Et, enfin, ce test ne serait pas réellement un test unitaire - ce serait quelque chose entre un test unitaire et un test d'intégration, car il prétend tester un cas limite simple mais nécessite qu'un environnement soit configuré d'une manière particulière. Le résultat n'en vaut pas la chandelle, hein ?
Il s'avère que tous ces problèmes de testabilité sont causés par l'API GetTimeOfDay()
de faible qualité. Dans sa forme actuelle, cette méthode souffre de plusieurs problèmes :
Il est étroitement couplé à la source de données concrète. Il n'est pas possible de réutiliser cette méthode pour traiter la date et l'heure récupérées à partir d'autres sources, ou passées en argument ; la méthode fonctionne uniquement avec la date et l'heure de la machine particulière qui exécute le code. Le couplage étroit est la racine principale de la plupart des problèmes de testabilité.
Il viole le principe de responsabilité unique (SRP). La méthode a de multiples responsabilités ; il consomme l'information et la traite également. Un autre indicateur de violation SRP est lorsqu'une seule classe ou méthode a plus d'une raison de changer . De ce point de vue, la méthode
GetTimeOfDay()
peut être modifiée soit en raison d'ajustements logiques internes, soit parce que la source de date et d'heure doit être modifiée.Il ment sur les informations nécessaires pour faire son travail. Les développeurs doivent lire chaque ligne du code source réel pour comprendre quelles entrées cachées sont utilisées et d'où elles viennent. La signature de la méthode seule ne suffit pas pour comprendre le comportement de la méthode.
C'est difficile à prévoir et à maintenir. Le comportement d'une méthode qui dépend d'un état global mutable ne peut pas être prédit en lisant simplement le code source ; il faut tenir compte de sa valeur actuelle, ainsi que de toute la séquence d'événements qui auraient pu la modifier plus tôt. Dans une application du monde réel, essayer de démêler tout cela devient un véritable casse-tête.
Après avoir examiné l'API, corrigeons-la enfin ! Heureusement, c'est beaucoup plus facile que de discuter de tous ses défauts - nous avons juste besoin de briser les préoccupations étroitement liées.
Correction de l'API : introduction d'un argument de méthode
Le moyen le plus évident et le plus simple de corriger l'API consiste à introduire un argument de méthode :
public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }
Désormais, la méthode demande à l'appelant de fournir un argument DateTime
, au lieu de rechercher secrètement cette information par lui-même. Du point de vue des tests unitaires, c'est génial; la méthode est maintenant déterministe (c'est-à-dire que sa valeur de retour dépend entièrement de l'entrée), donc les tests basés sur l'état sont aussi simples que de passer une valeur DateTime
et de vérifier le résultat :
[TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }
Notez que ce refactor simple a également résolu tous les problèmes d'API discutés précédemment (couplage étroit, violation SRP, API peu claire et difficile à comprendre) en introduisant une ligne claire entre les données à traiter et la manière dont cela doit être fait.
Excellent — la méthode est testable, mais qu'en est-il de ses clients ? Il est désormais de la responsabilité de l' appelant de fournir la date et l'heure à la GetTimeOfDay(DateTime dateTime)
, ce qui signifie qu'ils pourraient devenir impossibles à tester si nous n'y prêtons pas suffisamment attention. Voyons comment nous pouvons gérer cela.
Correction de l'API client : injection de dépendances
Disons que nous continuons à travailler sur le système de maison intelligente et implémentons le client suivant de la GetTimeOfDay(DateTime dateTime)
- le code de microcontrôleur de maison intelligente susmentionné responsable de l'allumage ou de l'extinction de la lumière, en fonction de l'heure de la journée et de la détection de mouvement :
public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }
Aie! Nous avons le même type de problème d'entrée DateTime.Now
caché - la seule différence est qu'il est situé un peu plus haut d'un niveau d'abstraction. Pour résoudre ce problème, nous pouvons introduire un autre argument, en déléguant à nouveau la responsabilité de fournir une valeur DateTime
à l'appelant d'une nouvelle méthode avec la signature ActuateLights(bool motionDetected, DateTime dateTime)
. Mais, au lieu de déplacer une fois de plus le problème d'un niveau supérieur dans la pile des appels, employons une autre technique qui nous permettra de garder à la fois ActuateLights(bool motionDetected)
et ses clients testables : Inversion of Control, ou IoC.
L'inversion de contrôle est une technique simple, mais extrêmement utile, pour découpler le code, et pour les tests unitaires en particulier. (Après tout, garder les choses couplées de manière lâche est essentiel pour pouvoir les analyser indépendamment les unes des autres.) Le point clé de l'IoC est de séparer le code de prise de décision ( quand faire quelque chose) du code d'action ( que faire quand quelque chose se passe ). Cette technique augmente la flexibilité, rend notre code plus modulaire et réduit le couplage entre les composants.
L'inversion de contrôle peut être mise en œuvre de plusieurs manières ; Examinons un exemple particulier - l'injection de dépendance à l'aide d'un constructeur - et comment cela peut aider à créer une API SmartHomeController
testable.
Commençons par créer une interface IDateTimeProvider
, contenant une signature de méthode pour obtenir une date et une heure :
public interface IDateTimeProvider { DateTime GetDateTime(); }
Ensuite, faites en sorte que SmartHomeController
référence une implémentation IDateTimeProvider
et déléguez-lui la responsabilité d'obtenir la date et l'heure :
public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }
Maintenant, nous pouvons voir pourquoi l'inversion de contrôle est ainsi appelée : le contrôle du mécanisme à utiliser pour lire la date et l'heure a été inversé et appartient maintenant au client de SmartHomeController
et non à SmartHomeController
lui-même. Ainsi, l'exécution de la ActuateLights(bool motionDetected)
dépend entièrement de deux choses qui peuvent être facilement gérées de l'extérieur : l'argument motionDetected
et une implémentation concrète de IDateTimeProvider
, passé dans un constructeur SmartHomeController
.

Pourquoi est-ce important pour les tests unitaires ? Cela signifie que différentes implémentations IDateTimeProvider
peuvent être utilisées dans le code de production et le code de test unitaire. Dans l'environnement de production, une implémentation réelle sera injectée (par exemple, une implémentation qui lit l'heure système réelle). Dans le test unitaire, cependant, nous pouvons injecter une "fausse" implémentation qui renvoie une valeur DateTime
constante ou prédéfinie adaptée pour tester le scénario particulier.
Une fausse implémentation de IDateTimeProvider
pourrait ressembler à ceci :
public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }
Avec l'aide de cette classe, il est possible d'isoler SmartHomeController
des facteurs non déterministes et d'effectuer un test unitaire basé sur l'état. Vérifions que, si un mouvement a été détecté, l'heure de ce mouvement est enregistrée dans la propriété LastMotionTime
:
[TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }
Génial! Un test comme celui-ci n'était pas possible avant le refactoring. Maintenant que nous avons éliminé les facteurs non déterministes et vérifié le scénario basé sur l'état, pensez-vous que SmartHomeController
est entièrement testable ?
Empoisonner la base de code avec des effets secondaires
Malgré le fait que nous ayons résolu les problèmes causés par l'entrée cachée non déterministe et que nous ayons pu tester certaines fonctionnalités, le code (ou, du moins, une partie de celui-ci) est toujours intestable !
Passons en revue la partie suivante de la ActuateLights(bool motionDetected)
chargée d'allumer ou d'éteindre la lumière :
// If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }
Comme nous pouvons le voir, SmartHomeController
délègue la responsabilité d'allumer ou d'éteindre la lumière à un objet BackyardLightSwitcher
, qui implémente un modèle Singleton. Quel est le problème avec cette conception?
Pour tester entièrement la ActuateLights(bool motionDetected)
, nous devons effectuer des tests basés sur l'interaction en plus des tests basés sur l'état ; c'est-à-dire que nous devons nous assurer que les méthodes pour allumer ou éteindre la lumière sont appelées si, et seulement si, les conditions appropriées sont remplies. Malheureusement, la conception actuelle ne nous permet pas de faire cela : les TurnOn()
et TurnOff()
de BackyardLightSwitcher
déclenchent des changements d'état dans le système, ou, en d'autres termes, produisent des effets secondaires . La seule façon de vérifier que ces méthodes ont été appelées est de vérifier si leurs effets secondaires correspondants se sont réellement produits ou non, ce qui pourrait être douloureux.
En effet, supposons que le détecteur de mouvement, la lanterne du jardin et le microcontrôleur de la maison intelligente soient connectés à un réseau Internet des objets et communiquent à l'aide d'un protocole sans fil. Dans ce cas, un test unitaire peut tenter de recevoir et d'analyser ce trafic réseau. Ou, si les composants matériels sont connectés avec un fil, le test unitaire peut vérifier si la tension a été appliquée au circuit électrique approprié. Ou, après tout, il peut vérifier que la lumière s'est réellement allumée ou éteinte à l'aide d'un capteur de lumière supplémentaire.
Comme nous pouvons le voir, les méthodes de test unitaire à effet secondaire peuvent être aussi difficiles que les tests unitaires non déterministes, voire impossibles. Toute tentative conduira à des problèmes similaires à ceux que nous avons déjà vus. Le test résultant sera difficile à mettre en œuvre, peu fiable, potentiellement lent et pas vraiment unitaire. Et, après tout cela, le clignotement de la lumière à chaque fois que nous exécutons la suite de tests finira par nous rendre fous !
Encore une fois, tous ces problèmes de testabilité sont causés par la mauvaise API, et non par la capacité du développeur à écrire des tests unitaires. Quelle que soit la manière exacte dont le contrôle de la lumière est mis en œuvre, l'API SmartHomeController
souffre de ces problèmes déjà familiers :
Il est étroitement couplé à la mise en œuvre concrète. L'API s'appuie sur l'instance concrète et codée en dur de
BackyardLightSwitcher
. Il n'est pas possible de réutiliser laActuateLights(bool motionDetected)
pour allumer une lumière autre que celle du jardin.Cela viole le principe de responsabilité unique. L'API a deux raisons de changer : premièrement, des modifications de la logique interne (comme choisir de faire s'allumer la lumière uniquement la nuit, mais pas le soir) et deuxièmement, si le mécanisme de commutation de la lumière est remplacé par un autre.
Il ment sur ses dépendances. Il n'y a aucun moyen pour les développeurs de savoir que
SmartHomeController
dépend du composantBackyardLightSwitcher
codé en dur, autre que de creuser dans le code source.C'est difficile à comprendre et à maintenir. Que faire si la lumière refuse de s'allumer lorsque les conditions sont réunies ? Nous pourrions passer beaucoup de temps à essayer de réparer le
SmartHomeController
en vain, pour nous rendre compte que le problème était causé par un bogue dans leBackyardLightSwitcher
(ou, encore plus drôle, une ampoule grillée !).
La solution aux problèmes de testabilité et de faible qualité des API consiste, sans surprise, à séparer les composants étroitement couplés les uns des autres. Comme dans l'exemple précédent, l'utilisation de l'injection de dépendance résoudrait ces problèmes ; ajoutez simplement une dépendance ILightSwitcher
au SmartHomeController
, déléguez-lui la responsabilité de basculer l'interrupteur d'éclairage et passez une fausse implémentation ILightSwitcher
test uniquement qui enregistrera si les méthodes appropriées ont été appelées dans les bonnes conditions. Cependant, au lieu d'utiliser à nouveau l'injection de dépendance, examinons une approche alternative intéressante pour découpler les responsabilités.
Correction de l'API : fonctions d'ordre supérieur
Cette approche est une option dans tout langage orienté objet prenant en charge des fonctions de première classe . Tirons parti des fonctionnalités de C# et faisons en sorte que la ActuateLights(bool motionDetected)
accepte deux arguments supplémentaires : une paire de délégués Action
, pointant vers les méthodes qui doivent être appelées pour allumer et éteindre la lumière. Cette solution convertira la méthode en une fonction d'ordre supérieur :
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }
Il s'agit d'une solution plus fonctionnelle que l'approche classique d'injection de dépendances orientée objet que nous avons vue auparavant. cependant, cela nous permet d'obtenir le même résultat avec moins de code et plus d'expressivité que Dependency Injection. Il n'est plus nécessaire d'implémenter une classe conforme à une interface pour fournir à SmartHomeController
la fonctionnalité requise ; à la place, nous pouvons simplement passer une définition de fonction. Les fonctions d'ordre supérieur peuvent être considérées comme une autre façon d'implémenter l'inversion de contrôle.
Maintenant, pour effectuer un test unitaire basé sur l'interaction de la méthode résultante, nous pouvons lui transmettre de fausses actions facilement vérifiables :
[TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }
Enfin, nous avons rendu l'API SmartHomeController
entièrement testable et nous sommes en mesure d'effectuer des tests unitaires basés sur l'état et sur l'interaction. Encore une fois, notez qu'en plus d'une testabilité améliorée, l'introduction d'une couture entre le code de prise de décision et d'action a aidé à résoudre le problème de couplage étroit et a conduit à une API plus propre et réutilisable.
Désormais, afin d'obtenir une couverture complète des tests unitaires, nous pouvons simplement implémenter un ensemble de tests d'apparence similaire pour valider tous les cas possibles - ce n'est pas grave car les tests unitaires sont désormais assez faciles à mettre en œuvre.
Impureté et testabilité
Le non-déterminisme incontrôlé et les effets secondaires sont similaires dans leurs effets destructeurs sur la base de code. Lorsqu'ils sont utilisés avec négligence, ils conduisent à un code trompeur, difficile à comprendre et à maintenir, étroitement couplé, non réutilisable et non testable.
D'un autre côté, les méthodes qui sont à la fois déterministes et sans effets secondaires sont beaucoup plus faciles à tester, à raisonner et à réutiliser pour construire des programmes plus importants. En termes de programmation fonctionnelle, ces méthodes sont appelées fonctions pures . Nous aurons rarement une unité de problème testant une fonction pure ; tout ce que nous avons à faire est de passer quelques arguments et de vérifier l'exactitude du résultat. Ce qui rend vraiment le code intestable, ce sont des facteurs impurs codés en dur qui ne peuvent pas être remplacés, remplacés ou abstraits d'une autre manière.
L'impureté est toxique : si la méthode Foo()
dépend de la méthode non déterministe ou à effet secondaire Bar()
, alors Foo()
devient également non déterministe ou à effet secondaire. Finalement, nous pouvons finir par empoisonner l'ensemble de la base de code. Multipliez tous ces problèmes par la taille d'une application complexe et réelle, et nous nous retrouverons encombrés d'une base de code difficile à maintenir, pleine d'odeurs, d'anti-modèles, de dépendances secrètes et de toutes sortes de choses laides et désagréables.
Cependant, l'impureté est inévitable ; toute application réelle doit, à un moment donné, lire et manipuler l'état en interagissant avec l'environnement, les bases de données, les fichiers de configuration, les services Web ou d'autres systèmes externes. Ainsi, au lieu de viser à éliminer complètement les impuretés, c'est une bonne idée de limiter ces facteurs, d'éviter de les laisser empoisonner votre base de code et de casser autant que possible les dépendances codées en dur, afin de pouvoir analyser et tester les choses indépendamment.
Signes d'avertissement courants d'un code difficile à tester
Enfin, passons en revue certains signes d'avertissement courants indiquant que notre code pourrait être difficile à tester.
Propriétés et champs statiques
Les propriétés et les champs statiques ou, en termes simples, l'état global, peuvent compliquer la compréhension et la testabilité du code, en masquant les informations nécessaires à une méthode pour faire son travail, en introduisant le non-déterminisme ou en favorisant l'utilisation intensive d'effets secondaires. Les fonctions qui lisent ou modifient l'état global modifiable sont intrinsèquement impures.
Par exemple, il est difficile de raisonner sur le code suivant, qui dépend d'une propriété globalement accessible :
if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }
Que se passe-t-il si la méthode HeatWater()
n'est pas appelée alors que nous sommes sûrs qu'elle aurait dû l'être ? Étant donné que n'importe quelle partie de l'application peut avoir modifié la valeur CostSavingEnabled
, nous devons rechercher et analyser tous les endroits modifiant cette valeur afin de déterminer ce qui ne va pas. De plus, comme nous l'avons déjà vu, il n'est pas possible de définir certaines propriétés statiques à des fins de test (par exemple, DateTime.Now
ou Environment.MachineName
; elles sont en lecture seule, mais toujours non déterministes).
D'un autre côté, un état global immuable et déterministe est tout à fait OK. En fait, il y a un nom plus familier pour cela - une constante. Les valeurs constantes comme Math.PI
n'introduisent aucun non-déterminisme et, comme leurs valeurs ne peuvent pas être modifiées, n'autorisent aucun effet secondaire :
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!
Célibataires
Essentiellement, le modèle Singleton n'est qu'une autre forme de l'état global. Les singletons promeuvent des API obscures qui mentent sur de vraies dépendances et introduisent un couplage inutilement étroit entre les composants. Ils violent également le principe de responsabilité unique car, en plus de leurs tâches principales, ils contrôlent leur propre initialisation et leur propre cycle de vie.
Les singletons peuvent facilement rendre les tests unitaires dépendants de l'ordre, car ils transportent l'état pendant toute la durée de vie de l'ensemble de l'application ou de la suite de tests unitaires. Jetez un œil à l'exemple suivant :
User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }
In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache
after each unit test run.
Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.
The new
Operator
Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.
For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:
using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }
However, sometimes new
is absolutely harmless: for example, it is OK to create simple entity objects:
var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack
methods were called or not — we just check if the end result is correct:
string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }
Static Methods
Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.
For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:
void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }
However, pure static functions are OK: any combination of them will still be a pure function. Par exemple:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }
Benefits of Unit Testing
Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.
As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.