portaldacalheta.pt
  • Principal
  • La Technologie
  • Personnes Et Équipes
  • Gestion De Projet
  • Équipes Distribuées
Back-End

Tests unitaires, comment écrire du code testable et pourquoi c'est important



Les tests unitaires sont un instrument essentiel dans la boîte à outils de tout développeur de logiciels . 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 didacticiel de test unitaire, j'ai l'intention de démontrer que les tests unitaires sont assez faciles; les vrais problèmes qui compliquent les tests unitaires, et introduisent une complexité coûteuse, sont le résultat d'une mauvaise conception, non testable code. 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.



Tutoriel de test unitaire: illustration de la couverture



Qu'est-ce que le test unitaire?

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 veut 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 appartiendra très probablement à l'une des deux catégories suivantes: basé sur l'état ou basé sur l'interaction . La vérification que le système testé produit des résultats corrects ou que son état résultant est correct est appelée basé sur l'état le test unitaire, tout en vérifiant qu'il invoque correctement certaines méthodes est appelé basé sur l'interaction tests unitaires.

En tant que métaphore pour des tests unitaires de logiciels appropriés, imaginez un savant fou qui veut construire du surnaturel chimère , avec des cuisses de grenouilles, 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 pourrait-il s'assurer que chaque pièce (ou unité) qu'il a choisie fonctionne réellement? Eh bien, il peut prendre, disons, une seule jambe de grenouille, lui appliquer un stimulus électrique et vérifier la bonne contraction musculaire. Ce qu'il fait, ce sont essentiellement les mêmes étapes Arrange-Act-Assert du test unitaire; la seule différence est que, dans ce cas, unité fait référence à un objet physique et non à un objet abstrait à partir duquel nous construisons nos programmes.



qu

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 différentes parties d'un système travailler 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 de 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 pousse avec un bâton, observant si elle marche et bouge comme prévu. Après avoir terminé un test, le savant fou nettoie toute la saleté, le sable et les roches qui sont maintenant dispersés dans son charmant laboratoire.

illustration d



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 un 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.

c'est peut-être tout ce que j'ai besoin de savoir

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 jouent bien une fois 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 implémentons: 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 nous rendons compte que la présence de ressources externes telles que des services Web ou des bases de données est nécessaire, 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 didacticiel et d'écrire des tests unitaires, parlons 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, nous pouvons corriger un bogue sans déboguer réellement le code!

  • Fiable. Les tests unitaires ne doivent échouer qu'en cas de bogue dans le système testé. Cela semble assez évident, mais les programmeurs rencontrent souvent un problème lorsque leurs tests échouent, même si 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 indiquent un défaut de conception. Les bons tests unitaires doivent être reproductibles et indépendants de facteurs externes tels que l'environnement ou l'ordre d'exécution.

  • 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 de différence significative; ajoutez mille autres et nous attendons sûrement un moment. Des tests unitaires lents peuvent également indiquer que soit le système testé, soit 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 l'avons déjà mentionné, 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’ya pas de secrets à écrire tests unitaires . Cependant, certaines techniques nous permettent d'écrire 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 cela. Alors, qu'est-ce qui rend le code difficile à tester? Passons en revue quelques anti-modèles, odeurs de code et mauvaises pratiques à éviter lors de l'écriture de code testable.

lois de la gestalt de la perception visuelle

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 l'une des exigences est d'allumer automatiquement la lumière dans la cour arrière si un mouvement y est détecté pendant la soirée 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 («Nuit», «Matin», «Après-midi» ou «Soir»):

public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour = 6 && 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, quel est le problème 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 cachée, qui changera probablement pendant l'exécution du programme ou entre les exécutions de test. Ainsi, les appels ultérieurs produiront des résultats différents.

Tel non déterministe comportement rend impossible de tester la logique interne du GetTimeOfDay() méthode 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 enfreindraient un grand nombre des règles évoquées précédemment. Il serait coûteux à écrire (en raison de la configuration non triviale et de la logique de démontage), 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 cours vite. Et, enfin, ce test ne serait pas en fait un test unitaire - ce serait quelque chose entre un test unitaire et un test d'intégration, car il prétend tester un cas de bord simple mais nécessite la mise en place d'un environnement d'une manière particulière. Le résultat ne vaut pas l'effort, hein?

Il s'avère que tous ces problèmes de testabilité sont causés par la mauvaise qualité GetTimeOfDay() API. Dans sa forme actuelle, cette méthode souffre de plusieurs problèmes:

  • Il est étroitement lié à 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 extraites 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 principale cause de la plupart des problèmes de testabilité.

  • Il viole le Principe de responsabilité unique (FAUCILLE). La méthode a de multiples responsabilités; il consomme les informations et les traite également. Un autre indicateur de violation de SRP est lorsqu'une seule classe ou méthode a plus d'un raison de changer . De ce point de vue, le GetTimeOfDay() La méthode 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 proviennent. 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 est nécessaire de prendre en compte sa valeur actuelle, ainsi que 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, résolvons enfin le problème! Heureusement, c'est beaucoup plus facile que de discuter de tous ses défauts - il nous suffit 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 && dateTime.Hour = 12 && dateTime.Hour <18) { return 'Noon'; } return 'Evening'; }

Désormais, la méthode demande à l'appelant de fournir un DateTime argument, au lieu de chercher secrètement cette information par elle-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 le test basé sur l'état est aussi simple que de passer un certain DateTime valeur et vérification du 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 simple refactor a également résolu tous les problèmes d'API évoqués précédemment (couplage étroit, violation de SRP, API peu claire et difficile à comprendre) en introduisant une couture claire entre quoi les données doivent être traitées et Comment Ca devrait être fait.

Excellent - la méthode est testable, mais qu'en est-il clients ? Maintenant c'est le l'appelant responsabilité de fournir la date et l'heure au GetTimeOfDay(DateTime dateTime) méthode, ce qui signifie que ils pourrait devenir impossible à 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

Supposons que nous continuions à travailler sur le système de maison intelligente et implémentions le client suivant du GetTimeOfDay(DateTime dateTime) méthode - le code de microcontrôleur de maison intelligente susmentionné chargé d'allumer ou d'éteindre la lumière, en fonction de l'heure du jour 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 genre de DateTime.Now cachés | problème d'entrée - 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 un DateTime valeur à l'appelant d'une nouvelle méthode avec signature ActuateLights(bool motionDetected, DateTime dateTime). Mais, au lieu de déplacer à nouveau le problème d'un niveau plus haut dans la pile d'appels, employons une autre technique qui nous permettra de conserver les deux ActuateLights(bool motionDetected) méthode et ses clients testables: Inversion de contrôle , 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 faiblement couplées est essentiel pour pouvoir les analyser indépendamment les uns des autres.) Le point clé de l'IoC est de séparer le code de prise de décision ( quand faire quelque chose) à partir du code d'action ( quoi à 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 implémentée de plusieurs manières; Regardons un exemple en particulier - Injection de dépendance en utilisant un constructeur - et comment il peut aider à construire un testable SmartHomeController API.

Commençons par créer un IDateTimeProvider interface, contenant une signature de méthode pour obtenir une date et une heure:

public interface IDateTimeProvider { DateTime GetDateTime(); }

Ensuite, faites SmartHomeController référence an IDateTimeProvider mise en œuvre, et lui déléguer 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... } }

Nous pouvons maintenant 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 inversé , et appartient maintenant au client de SmartHomeController, pas SmartHomeController lui-même. Ainsi, l'exécution du ActuateLights(bool motionDetected) La méthode dépend entièrement de deux choses qui peuvent être facilement gérées de l'extérieur: le motionDetected argument, et une implémentation concrète de IDateTimeProvider, passée dans un SmartHomeController constructeur.

Pourquoi est-ce important pour les tests unitaires? Cela signifie que différent IDateTimeProvider les implémentations 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 implémentation «fausse» qui renvoie une constante ou prédéfinie DateTime valeur approprié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; } }

A l'aide de cette classe, il est possible d'isoler SmartHomeController à partir de facteurs non déterministes et 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 le LastMotionTime propriété:

[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 la refactorisation. 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) n'est toujours pas testable!

Passons en revue la partie suivante du ActuateLights(bool motionDetected) méthode responsable 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 on peut le voir, SmartHomeController délègue la responsabilité d'allumer ou d'éteindre la lumière à un BackyardLightSwitcher objet, qui implémente un Motif singleton . Quel est le problème avec cette conception?

Pour tester complètement l'unité ActuateLights(bool motionDetected) méthode, nous devrions 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: le TurnOn() et TurnOff() méthodes de BackyardLightSwitcher déclencher des changements d'état dans le système ou, en d'autres termes, produire 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 capteur de mouvement, la lanterne d'arrière-cour et le microcontrôleur de 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 est effectivement allumée ou éteinte à l'aide d'un capteur de lumière supplémentaire.

Comme nous pouvons le voir, les méthodes à effet secondaire des tests unitaires pourraient être aussi difficiles que les tests unitaires non déterministes, et peuvent même être impossibles. Toute tentative entraînera des problèmes similaires à ceux que nous avons déjà rencontrés. Le test qui en résultera 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 précision de mise en œuvre du contrôle de la lumière, le SmartHomeController L'API souffre de ces problèmes déjà familiers:

  • Il est étroitement lié à la mise en œuvre concrète. L'API repose sur l'instance concrète codée en dur de BackyardLightSwitcher. Il n'est pas possible de réutiliser le ActuateLights(bool motionDetected) méthode pour allumer une lumière autre que celle de la cour arrière.

  • Cela viole le principe de responsabilité unique. L'API a deux raisons de changer: premièrement, des changements dans la logique interne (comme choisir de ne faire allumer la lumière que la nuit, mais pas le soir) et deuxièmement, si le mécanisme de commutation de lumière est remplacé par un autre.

  • Il ment sur ses dépendances. Les développeurs n'ont aucun moyen de savoir que SmartHomeController dépend du codé en dur BackyardLightSwitcher composant, autre que de creuser dans le code source.

    intelligence d'affaires vs intelligence artificielle
  • C'est difficile à comprendre et à maintenir. Et 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, seulement pour réaliser que le problème a été causé par un bogue dans le BackyardLightSwitcher (ou, encore plus drôle, une ampoule grillée!).

La solution aux problèmes de testabilité et d'API de faible qualité est, sans surprise, de briser les composants étroitement couplés les uns des autres. Comme dans l'exemple précédent, l'utilisation de l'injection de dépendances résoudrait ces problèmes; ajoutez simplement un ILightSwitcher dépendance au SmartHomeController, déléguez-lui la responsabilité de basculer l'interrupteur d'éclairage, et passez un faux, test uniquement ILightSwitcher mise en œuvre 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épendances, examinons une autre approche 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 fonctions de première classe . Tirons parti des fonctionnalités fonctionnelles de C # et faisons le ActuateLights(bool motionDetected) méthode accepte deux arguments supplémentaires: une paire de Action délégués, en indiquant les méthodes qui devraient être appelées pour allumer et éteindre la lumière. Cette solution convertira la méthode en un 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 l'injection de dépendance. Il n'est plus nécessaire d'implémenter une classe conforme à une interface pour fournir SmartHomeController avec la fonctionnalité requise; au lieu de cela, 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 y passer 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 fait le SmartHomeController API 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 de la testabilité améliorée, l'introduction d'un lien entre la prise de décision et le code d'action a aidé à résoudre le problème de couplage étroit et a conduit à une API plus propre et réutilisable.

Désormais, pour atteindre une couverture complète des tests unitaires, nous pouvons simplement implémenter un tas de tests similaires pour valider tous les cas possibles - ce qui n'est pas grave puisque les tests unitaires sont désormais assez faciles à implémenter.

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 insouciance, ils conduisent à un code trompeur, difficile à comprendre et à maintenir, étroitement couplé, non réutilisable et non testable.

En revanche, des méthodes à la fois déterministes et sans effets secondaires sont beaucoup plus faciles à tester, à raisonner et à réutiliser pour créer des programmes plus volumineux. En termes de programmation fonctionnelle, ces méthodes sont appelées fonctions pures . Nous aurons rarement un problème de test unitaire d'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 non testable, ce sont des facteurs impurs codés en dur qui ne peuvent pas être remplacés, surchargés ou abstraits d'une autre manière.

L'impureté est toxique: si 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 pourrions finir par empoisonner toute la base de code. Multipliez tous ces problèmes par la taille d'une application complexe de la vie réelle et nous nous retrouverons encombrés d'une base de code difficile à entretenir pleine d'odeurs, d'anti-modèles, de dépendances secrètes et de toutes sortes de choses laides et désagréables.

exemple de test unitaire: illustration

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. Donc, 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 briser autant que possible les dépendances codées en dur, afin de pouvoir analyser et tester les choses de manière indépendante.

Signes d'avertissement courants d'un code difficile à tester

Problème d'écriture des tests? Le problème ne se trouve pas dans votre suite de tests. C'est dans votre code. Tweet

Enfin, passons en revue certains signes d’avertissement courants indiquant que notre code peut être difficile à tester.

Propriétés et champs statiques

Les propriétés et les champs statiques ou, tout simplement, l'état global, peuvent compliquer la compréhension du code et la testabilité, en masquant les informations nécessaires à une méthode pour faire son travail, en introduisant le non-déterminisme ou en promouvant une utilisation intensive des effets secondaires. Les fonctions qui lisent ou modifient l'état global mutable 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(); }

Et si le HeatWater() La méthode 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 changé le CostSavingEnabled valeur, nous devons trouver et analyser tous les endroits modifiant cette valeur afin de découvrir 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'autre part, immuable et l'état global déterministe est tout à fait OK. En fait, il existe un nom plus familier pour cela - une constante. Des valeurs constantes comme Math.PI n'introduisez aucun non-déterminisme et, puisque 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!

Singletons

Essentiellement, le modèle Singleton n'est qu'une autre forme de l'état global. Les singletons promeuvent des API obscures qui mentent sur des dépendances réelles et introduisent un couplage inutilement étroit entre les composants. Ils violent également le principe de responsabilité unique car, en plus de leurs fonctions 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; }

Dans l'exemple ci-dessus, si un test pour le scénario d'accès au cache s'exécute en premier, il ajoutera un nouvel utilisateur au cache, de sorte qu'un test ultérieur du scénario d'absence de cache peut échouer car il suppose que le cache est vide. Pour surmonter cela, nous devrons écrire un code de démontage supplémentaire pour nettoyer le UserCache après chaque exécution de test unitaire.

L'utilisation de Singletons est une mauvaise pratique qui peut (et devrait) être évitée dans la plupart des cas; cependant, il est important de faire la distinction entre Singleton en tant que modèle de conception et une seule instance d'un objet. Dans ce dernier cas, la responsabilité de créer et de maintenir une seule instance incombe à l'application elle-même. En règle générale, cela est remis avec une fabrique ou un conteneur d'injection de dépendances, qui crée une instance unique quelque part près du «sommet» de l'application (c'est-à-dire plus proche d'un point d'entrée d'application), puis la transmet à chaque objet qui en a besoin. Cette approche est absolument correcte, tant du point de vue de la testabilité que de la qualité de l'API.

Le new Opérateur

La création d'une instance d'un objet afin de faire du travail présente le même problème que l'anti-pattern Singleton: des API peu claires avec des dépendances cachées, un couplage étroit et une faible testabilité.

Par exemple, afin de tester si la boucle suivante s'arrête lorsqu'un code d'état 404 est renvoyé, le développeur doit configurer un serveur Web de test:

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); }

Cependant, parfois new est absolument inoffensif: par exemple, il est acceptable de créer des objets entité simples:

designer visuel vs graphiste
var person = new Person('John', 'Doe', new DateTime(1970, 12, 31));

Il est également possible de créer un petit objet temporaire qui ne produit aucun effet secondaire, sauf pour modifier son propre état, puis renvoyer le résultat en fonction de cet état. Dans l'exemple suivant, nous ne nous soucions pas de savoir si Stack méthodes ont été appelées ou non - nous vérifions simplement si le résultat final est 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(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

Méthodes statiques

Les méthodes statiques sont une autre source potentielle de comportement non déterministe ou à effet secondaire. Ils peuvent facilement introduire un couplage étroit et rendre notre code non testable.

Par exemple, pour vérifier le comportement de la méthode suivante, les tests unitaires doivent manipuler les variables d'environnement et lire le flux de sortie de la console pour s'assurer que les données appropriées ont été imprimées:

void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable('PATH') != null) { Console.WriteLine('PATH environment variable exists.'); } else { Console.WriteLine('PATH environment variable is not defined.'); } }

cependant, pur les fonctions statiques sont OK: toute combinaison d'entre elles sera toujours une fonction pure. Par exemple:

double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Avantages des tests unitaires

De toute évidence, l'écriture de code testable nécessite une certaine discipline, de la concentration et des efforts supplémentaires. Mais le développement de logiciels est de toute façon une activité mentale complexe, et nous devons toujours être prudents et éviter de lancer imprudemment du nouveau code du haut de la tête.

En récompense de cet acte de bonne assurance qualité des logiciels , nous nous retrouverons avec des API propres, faciles à entretenir, faiblement couplées et réutilisables, qui n'endommageront pas le cerveau des développeurs lorsqu'ils essaient de le comprendre. Après tout, l'avantage ultime de code testable n'est pas seulement la testabilité elle-même, mais aussi la capacité de comprendre, maintenir et étendre facilement ce code.

Comprendre les bases

Qu'est-ce que le test unitaire?

Le test unitaire est une méthode qui instancie une petite partie de notre code et vérifie son comportement indépendamment des autres parties du projet.

Comment faire des tests unitaires et qu'est-ce que cela implique?

Un test unitaire comporte généralement trois phases différentes: organiser, agir et affirmer (parfois appelé AAA). Pour qu'un test unitaire réussisse, le comportement qui en résulte dans les trois phases doit être conforme aux attentes.

Qu'est-ce que le test d'intégration?

Les tests d'intégration se concentrent sur le test et l'observation de différents modules logiciels en tant que groupe. Il est généralement effectué une fois les tests unitaires terminés et précède les tests de validation.

La démocratisation de l'éducation - une frontière mondiale

L'avenir Du Travail

La démocratisation de l'éducation - une frontière mondiale
Choisir une alternative à Tech Stack - Les hauts et les bas

Choisir une alternative à Tech Stack - Les hauts et les bas

Back-End

Articles Populaires
Ingénieur Senior Ruby on Rails
Ingénieur Senior Ruby on Rails
Repenser l'interface utilisateur de la plate-forme TV
Repenser l'interface utilisateur de la plate-forme TV
Soutenir l'offre technologique grâce à l'éducation STEM
Soutenir l'offre technologique grâce à l'éducation STEM
UX personnalisé et puissance du design et de l'émotion
UX personnalisé et puissance du design et de l'émotion
Explication du flux Git amélioré
Explication du flux Git amélioré
 
Un guide sur les moteurs Rails dans la nature: Exemples concrets de moteurs Rails en action
Un guide sur les moteurs Rails dans la nature: Exemples concrets de moteurs Rails en action
Conception d'une VUI - Interface utilisateur vocale
Conception d'une VUI - Interface utilisateur vocale
Huit raisons pour lesquelles Microsoft Stack est toujours un choix viable
Huit raisons pour lesquelles Microsoft Stack est toujours un choix viable
Tirer le meilleur parti des actions - Leçons d'un ancien analyste de recherche
Tirer le meilleur parti des actions - Leçons d'un ancien analyste de recherche
Addiction au rachat d'actions: études de cas de succès
Addiction au rachat d'actions: études de cas de succès
Articles Populaires
  • principes et éléments de conception
  • tailles d'écran standard pour un design réactif
  • création d'un plan comptable
  • exemple d'authentification basée sur un jeton de sécurité Spring
  • llc c corp ou s corp
  • meilleures lettres de PDG aux actionnaires
  • comment calculer le marché adressable total
Catégories
  • La Technologie
  • Personnes Et Équipes
  • Gestion De Projet
  • Équipes Distribuées
  • © 2022 | Tous Les Droits Sont Réservés

    portaldacalheta.pt