paint-brush
Couvrant entièrement l'application console .NET C # avec des tests unitairespar@ahmedtarekhasan
2,848 lectures
2,848 lectures

Couvrant entièrement l'application console .NET C # avec des tests unitaires

par Ahmed Tarek Hasan26m2023/01/06
Read on Terminal Reader

Trop long; Pour lire

Certains collègues se plaignent de ne pas être parfois en mesure d'appliquer TDD ou d'écrire des tests unitaires pour certains modules ou applications. Les applications console en font partie. Comment pourrais-je tester une application console lorsque l'entrée est transmise par des frappes de touches et que la sortie est présentée sur un écran ?!! Vous n'avez pas besoin de tester l'application "Console", vous voulez tester la logique métier qui la sous-tend.
featured image - Couvrant entièrement l'application console .NET C # avec des tests unitaires
Ahmed Tarek Hasan HackerNoon profile picture

Meilleure pratique pour atteindre une couverture de 100 % en utilisant le développement piloté par les tests (TDD), l'injection de dépendances (DI), l'inversion de contrôle (IoC) et les conteneurs IoC.


Certains de mes collègues se plaignent qu'ils ne sont parfois pas en mesure d'appliquer TDD ou d'écrire des tests unitaires pour certains modules ou applications, les applications console en font partie.


Comment pourrais-je tester une application console lorsque l'entrée est transmise par des frappes et que la sortie est présentée sur un écran ?!!


En fait, cela arrive de temps en temps, vous vous retrouvez à essayer d'écrire des tests unitaires pour quelque chose sur lequel vous semblez n'avoir aucun contrôle.


Photo de Sangga Rima Roman Selia sur Unsplash

Idée fausse

La vérité est que vous venez de manquer le point. Vous n'avez pas besoin de tester l'application "Console", vous voulez tester la logique métier qui la sous-tend.


Lorsque vous créez une application console, vous créez une application que quelqu'un peut utiliser, il s'attend à transmettre certaines entrées et à obtenir des sorties correspondantes, et c'est ce que vous devez vraiment tester .


Vous ne voulez pas tester la classe statique System.Console , il s'agit d'une classe intégrée qui est incluse dans le framework .NET et vous devez faire confiance à Microsoft à ce sujet.


Maintenant, vous devez réfléchir à la façon de séparer ces deux domaines en composants ou modules distincts afin que vous puissiez commencer à écrire des tests pour celui que vous désirez sans interférer avec l'autre, et c'est ce que je vais vous expliquer…


Photo de Mark Fletcher-Brown sur Unsplash

L'idée

Tout d'abord, imaginons une idée d'application console simple et stupide et utilisons-la comme exemple pour l'appliquer.


Tout d'abord, vous avez ce menu simple.


Image par Ahmed Tarek


Lorsque vous choisissez l'option 1 et entrez votre nom , vous obtenez le message Hello comme dans l'image ci-dessous. Appuyer sur Entrée fermerait l'application.


Image par Ahmed Tarek


Lorsque vous choisissez l'option 2 et entrez votre nom , vous obtenez le message d' adieu comme dans l'image ci-dessous. Appuyer sur Entrée fermerait l'application.


Image par Ahmed Tarek


Trop simple, non ? Oui je suis d'accord avec toi. Cependant, supposons que l'interface utilisateur, les chaînes, les caractères et tout ce que vous voyez à l'écran font partie des exigences.


Cela signifie que si vous allez écrire des tests unitaires, cela devrait également être couvert de manière à ce qu'un changement mineur sur un seul caractère dans le code de production déclenche un test unitaire défaillant.


Photo de Brett Jordan sur Unsplash

Le plan

Voici notre projet :

  1. Construisez l'application Console d'une mauvaise manière traditionnelle.
  2. Voyez si nous pouvons écrire des tests unitaires automatisés ou non.
  3. Ré-implémentez l'application Console dans le bon sens.
  4. Ecrire des tests unitaires.

Photo de Mehdi sur Unsplash

La mauvaise façon

Tout simplement, faites tout au même endroit.


 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace MyConsoleApp { class Program { static void Main(string[] args) { var input = string.Empty; do { Console.WriteLine("Welcome to my console app"); Console.WriteLine("[1] Say Hello?"); Console.WriteLine("[2] Say Goodbye?"); Console.WriteLine(""); Console.Write("Please enter a valid choice: "); input = Console.ReadLine(); if (input == "1" || input == "2") { Console.Write("Please enter your name: "); string name = Console.ReadLine(); if (input == "1") { Console.WriteLine("Hello " + name); } else { Console.WriteLine("Goodbye " + name); } Console.WriteLine(""); Console.Write("Press any key to exit... "); Console.ReadKey(); } else { Console.Clear(); } } while (input != "1" && input != "2"); } } }


Ce que l'on peut remarquer ici :

  1. Tout est au même endroit.
  2. Nous utilisons directement la classe statique System.Console .
  3. Nous ne pouvons pas tester la logique métier sans tomber sur System.Console .

Photo de Brett Jordan sur Unsplash

Essayer d'écrire des tests unitaires

Vraiment? vous attendez-vous vraiment à pouvoir écrire un test unitaire pour ce code ?

Voici les défis :

  1. Selon des classes statiques telles que System.Console .
  2. Impossible de définir et d'isoler les dépendances.
  3. Impossible de remplacer les dépendances par des simulations ou des stubs.

Si vous pouvez faire quelque chose à ce sujet, vous êtes un héros… croyez-moi.


Photo de Volkan Olmez sur Unsplash

La bonne façon

Maintenant, divisons notre solution en modules plus petits.


Gestionnaire de consoles

C'est le module qui est chargé de fournir les fonctionnalités dont nous avons besoin depuis la console… n'importe quelle console.


Ce module serait composé de deux parties :

  1. Abstractions.
  2. Implémentations.


Nous aurons donc ceci :

  1. IConsoleManager : Il s'agit de l'interface définissant ce que nous attendons de tout gestionnaire de console.
  2. ConsoleManagerBase : il s'agit de la classe abstraite implémentant IConsoleManager et fournissant toutes les implémentations communes entre tous les gestionnaires de console.
  3. ConsoleManager : il s'agit de l'implémentation par défaut du gestionnaire de console qui encapsule System.Console et est réellement utilisée lors de l'exécution.


 using System; namespace ConsoleManager { public interface IConsoleManager { void Write(string value); void WriteLine(string value); ConsoleKeyInfo ReadKey(); string ReadLine(); void Clear(); } }


 using System; namespace ConsoleManager { public abstract class ConsoleManagerBase : IConsoleManager { public abstract void Clear(); public abstract ConsoleKeyInfo ReadKey(); public abstract string ReadLine(); public abstract void Write(string value); public abstract void WriteLine(string value); } }


 using System; namespace ConsoleManager { public class ConsoleManager : ConsoleManagerBase { public override void Clear() { Console.Clear(); } public override ConsoleKeyInfo ReadKey() { return Console.ReadKey(); } public override string ReadLine() { return Console.ReadLine(); } public override void Write(string value) { Console.Write(value); } public override void WriteLine(string value) { Console.WriteLine(value); } } }


Ce que l'on peut remarquer ici :

  1. Nous avons maintenant IConsoleManager .
  2. Nous pouvons utiliser Mocks et Stubs pour remplacer IConsoleManager lors de l'écriture de tests unitaires.
  3. Pour la classe de base commune ConsoleManagerBase , nous ne fournissons aucune implémentation commune à utiliser par les enfants.
  4. Je sais que ce n'est pas la meilleure chose à faire, cependant, je le fais ici juste pour vous rappeler que cette option est là et que vous pouvez l'utiliser chaque fois que nécessaire.

Gestionnaire de programme

Il s'agit du module chargé de fournir les principales fonctionnalités de l'application.


Ce module serait composé de deux parties :

  1. Abstractions.
  2. Implémentations.


Nous aurons donc ceci :

  1. IProgramManager : C'est l'interface définissant ce que nous attendons de tout gestionnaire de programme.
  2. ProgramManagerBase : il s'agit de la classe abstraite implémentant IProgramManager et fournissant toutes les implémentations communes entre tous les gestionnaires de programme.
  3. ProgramManager : il s'agit de l'implémentation par défaut du gestionnaire de programmes qui est réellement utilisée lors de l'exécution. Cela dépend aussi de IConsoleManager .


 namespace ProgramManager { public interface IProgramManager { void Run(); } }


 namespace ProgramManager { public abstract class ProgramManagerBase : IProgramManager { public abstract void Run(); } }


 using ConsoleManager; namespace ProgramManager { public class ProgramManager : ProgramManagerBase { private readonly IConsoleManager m_ConsoleManager; public ProgramManager(IConsoleManager consoleManager) { m_ConsoleManager = consoleManager; } public override void Run() { string input; do { m_ConsoleManager.WriteLine("Welcome to my console app"); m_ConsoleManager.WriteLine("[1] Say Hello?"); m_ConsoleManager.WriteLine("[2] Say Goodbye?"); m_ConsoleManager.WriteLine(""); m_ConsoleManager.Write("Please enter a valid choice: "); input = m_ConsoleManager.ReadLine(); if (input == "1" || input == "2") { m_ConsoleManager.Write("Please enter your name: "); var name = m_ConsoleManager.ReadLine(); if (input == "1") { m_ConsoleManager.WriteLine("Hello " + name); } else { m_ConsoleManager.WriteLine("Goodbye " + name); } m_ConsoleManager.WriteLine(""); m_ConsoleManager.Write("Press any key to exit... "); m_ConsoleManager.ReadKey(); } else { m_ConsoleManager.Clear(); } } while (input != "1" && input != "2" && input != "Exit"); } } }


Ce que l'on peut remarquer ici :

  1. Nous avons maintenant notre dépendance de ProgramManager sur IConsoleManager bien définie.
  2. Nous avons IProgramManager et nous pouvons utiliser des mocks et des stubs pour remplacer IProgramManager lors de l'écriture de tests unitaires.
  3. Pour la classe de base commune ProgramManagerBase , nous ne fournissons aucune implémentation commune à utiliser par les enfants.
  4. Je sais que ce n'est pas la meilleure chose à faire, cependant, je le fais ici juste pour vous rappeler que cette option est là et que vous pouvez l'utiliser chaque fois que nécessaire.


La classe ProgramManager peut être divisée en parties plus petites. Cela faciliterait le suivi et la couverture des tests unitaires. Cependant, c'est quelque chose que je vous laisse faire.


Application de la console

C'est l'application principale.


Ici, nous allons utiliser Ninjecter comme notre conteneur IoC. Il est simple à utiliser et vous pouvez toujours consulter leur documentation en ligne.


Sur le projet principal de l'application console, nous créons le fichier NinjectDependencyResolver.cs . Ce fichier serait le suivant.


 using Ninject.Modules; using ConsoleManager; using ProgramManager; namespace MyConsoleApp { public class NinjectDependencyResolver : NinjectModule { public override void Load() { Bind<IConsoleManager>().To<ConsoleManager.ConsoleManager>(); Bind<IProgramManager>().To<ProgramManager.ProgramManager>(); } } }


Ce que l'on peut remarquer ici :

  1. La classe NinjectDependencyResolver hérite NinjectModule .
  2. Nous redéfinissons la méthode void Load() où nous définissons nos liaisons comme prévu.


Maintenant, sur le Program.cs :


 using Ninject; using System.Reflection; using ProgramManager; namespace MyConsoleApp { class Program { private static IProgramManager m_ProgramManager = null; static void Main(string[] args) { var kernel = new StandardKernel(); kernel.Load(Assembly.GetExecutingAssembly()); m_ProgramManager = kernel.Get<IProgramManager>(); m_ProgramManager.Run(); } } }


Ce que l'on peut remarquer ici :

  1. Nous dépendons du IProgramManager .
  2. Nous avons créé le conteneur IoC via var kernel = new StandardKernel(); .
  3. Ensuite, nous avons chargé les dépendances dans le conteneur IoC via kernel.Load(Assembly.GetExecutingAssembly()); . Cela indique à Ninject d'obtenir ses liaisons de toutes les classes héritant de NinjectModule à l'intérieur de l'assembly/projet actuel.
  4. Cela signifie que les liaisons proviendraient de notre classe NinjectDependencyResolver car elle hérite NinjectModule et se trouve dans l'assembly/projet actuel.
  5. Pour obtenir une instance de IProgramManager nous utilisons le conteneur IoC comme suit kernel.Get<IProgramManager>(); .


Voyons maintenant si cette conception et ce travail que nous avons effectués jusqu'à présent ont résolu notre problème.


Photo de Markus Winkler sur Unsplash

Moment de vérité

Donc, la question est maintenant, pouvons-nous couvrir notre application console avec des tests unitaires ? Pour répondre à cette question, essayons d'écrire quelques tests unitaires…


Stubs ou maquettes

Si vous avez une certaine expérience des tests unitaires, sachez que nous avons des stubs et des mocks à utiliser pour remplacer nos dépendances.


Juste pour le plaisir, j'utiliserais des stubs pour notre exemple ici.


Donc, je définirais ConsoleManagerStub comme un stub pour IConsoleManager comme suit :


 using System; using System.Collections.Generic; using System.Text; namespace ConsoleManager { public class ConsoleManagerStub : ConsoleManagerBase { private int m_CurrentOutputEntryNumber; private readonly List<string> m_Outputs = new List<string>(); public event Action<int> OutputsUpdated; public event Action OutputsCleared; public Queue<object> UserInputs { get; } = new Queue<object>(); public override void Clear() { m_CurrentOutputEntryNumber++; m_Outputs.Clear(); OnOutputsCleared(); OnOutputsUpdated(m_CurrentOutputEntryNumber); } public override ConsoleKeyInfo ReadKey() { ConsoleKeyInfo result; object input; if (UserInputs.Count > 0) { input = UserInputs.Dequeue(); } else { throw new ArgumentException("No input was presented when an input was expected"); } if (input is ConsoleKeyInfo key) { result = key; } else { throw new ArgumentException("Invalid input was presented when ConsoleKeyInfo was expected"); } return result; } public override string ReadLine() { object input; if (UserInputs.Count > 0) { input = UserInputs.Dequeue(); } else { throw new ArgumentException("No input was presented when an input was expected"); } string result; if (input is string str) { result = str; WriteLine(result); } else { throw new ArgumentException("Invalid input was presented when String was expected"); } return result; } public override void Write(string value) { m_Outputs.Add(value); m_CurrentOutputEntryNumber++; OnOutputsUpdated(m_CurrentOutputEntryNumber); } public override void WriteLine(string value) { m_Outputs.Add(value + "\r\n"); m_CurrentOutputEntryNumber++; OnOutputsUpdated(m_CurrentOutputEntryNumber); } protected void OnOutputsUpdated(int outputEntryNumber) { OutputsUpdated?.Invoke(outputEntryNumber); } protected void OnOutputsCleared() { OutputsCleared?.Invoke(); } public override string ToString() { var result = string.Empty; if (m_Outputs == null || m_Outputs.Count <= 0) return result; var builder = new StringBuilder(); foreach (var output in m_Outputs) { builder.Append(output); } result = builder.ToString(); return result; } } }


Et enfin, les tests unitaires seraient les suivants :


 using ConsoleManager; using NUnit.Framework; using System; using System.Collections.Generic; namespace MyConsoleApp.Tests { [TestFixture] public class ProgramManagerTests { private ConsoleManagerStub m_ConsoleManager = null; private ProgramManager.ProgramManager m_ProgramManager = null; [SetUp] public void SetUp() { m_ConsoleManager = new ConsoleManagerStub(); m_ProgramManager = new ProgramManager.ProgramManager(m_ConsoleManager); } [TearDown] public void TearDown() { m_ProgramManager = null; m_ConsoleManager = null; } [TestCase("Ahmed")] [TestCase("")] [TestCase(" ")] public void RunWithInputAs1AndName(string name) { m_ConsoleManager.UserInputs.Enqueue("1"); m_ConsoleManager.UserInputs.Enqueue(name); m_ConsoleManager.UserInputs.Enqueue(new ConsoleKeyInfo()); var expectedOutput = new List<string> { "Welcome to my console app\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: ", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 1\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 1\r\nPlease enter your name: ", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 1\r\nPlease enter your name: " + name + "\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 1\r\nPlease enter your name: " + name + "\r\nHello " + name +"\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 1\r\nPlease enter your name: " + name + "\r\nHello " + name +"\r\n\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 1\r\nPlease enter your name: " + name + "\r\nHello " + name +"\r\n\r\nPress any key to exit... " }; m_ConsoleManager.OutputsUpdated += outputEntryNumber => { Assert.AreEqual( expectedOutput[outputEntryNumber - 1], m_ConsoleManager.ToString()); }; m_ProgramManager.Run(); } [TestCase("Ahmed")] [TestCase("")] [TestCase(" ")] public void RunWithInputAs2AndName(string name) { m_ConsoleManager.UserInputs.Enqueue("2"); m_ConsoleManager.UserInputs.Enqueue(name); m_ConsoleManager.UserInputs.Enqueue(new ConsoleKeyInfo()); var expectedOutput = new List<string> { "Welcome to my console app\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: ", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 2\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 2\r\nPlease enter your name: ", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 2\r\nPlease enter your name: " + name + "\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 2\r\nPlease enter your name: " + name + "\r\nGoodbye " + name + "\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 2\r\nPlease enter your name: " + name + "\r\nGoodbye " + name + "\r\n\r\n", "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: 2\r\nPlease enter your name: " + name + "\r\nGoodbye " + name + "\r\n\r\nPress any key to exit... " }; m_ConsoleManager.OutputsUpdated += outputEntryNumber => { Assert.AreEqual( expectedOutput[outputEntryNumber - 1], m_ConsoleManager.ToString()); }; m_ProgramManager.Run(); } [Test] public void RunShouldKeepTheMainMenuWhenInputIsNeither1Nor2() { m_ConsoleManager.UserInputs.Enqueue("any invalid input 1"); m_ConsoleManager.UserInputs.Enqueue("any invalid input 2"); m_ConsoleManager.UserInputs.Enqueue("Exit"); var expectedOutput = new List<string> { // initial menu "Welcome to my console app\r\n", // outputEntryNumber 1 "Welcome to my console app\r\n[1] Say Hello?\r\n", // outputEntryNumber 2 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n", // outputEntryNumber 3 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\n", // outputEntryNumber 4 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: ", // outputEntryNumber 5 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: any invalid input 1\r\n", // outputEntryNumber 6 // after first trial "", // outputEntryNumber 7 "Welcome to my console app\r\n", // outputEntryNumber 8 "Welcome to my console app\r\n[1] Say Hello?\r\n", // outputEntryNumber 9 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n", // outputEntryNumber 10 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\n", // outputEntryNumber 11 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: ", // outputEntryNumber 12 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: any invalid input 2\r\n", // outputEntryNumber 13 // after second trial "", // outputEntryNumber 14 "Welcome to my console app\r\n", // outputEntryNumber 15 "Welcome to my console app\r\n[1] Say Hello?\r\n", // outputEntryNumber 16 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n", // outputEntryNumber 17 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\n", // outputEntryNumber 18 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: ", // outputEntryNumber 19 "Welcome to my console app\r\n[1] Say Hello?\r\n[2] Say Goodbye?\r\n\r\nPlease enter a valid choice: Exit\r\n" // outputEntryNumber 20 }; m_ConsoleManager.OutputsUpdated += outputEntryNumber => { if (outputEntryNumber - 1 < expectedOutput.Count) { Assert.AreEqual( expectedOutput[outputEntryNumber - 1], m_ConsoleManager.ToString()); } }; m_ProgramManager.Run(); } } } 

Photo de david Griffiths sur Unsplash

Pour terminer

Nous avons maintenant pu couvrir notre application console avec des tests unitaires. Cependant, vous pourriez penser que c'est trop pour une simple application comme celle que nous avons ici. N'est-ce pas exagéré ?


En fait, cela dépend de ce que vous voulez couvrir. Par exemple, dans notre application simple, j'ai traité chaque caractère de l'interface utilisateur comme une exigence devant être couverte par des tests unitaires. Donc, si vous allez changer un personnage sur l'implémentation principale, un test unitaire échouera.


Peut-être que dans votre cas, ce serait différent. Cependant, ce serait toujours bien si vous savez comment le faire même jusqu'au plus petit caractère.


Voilà, j'espère que vous avez trouvé la lecture de cet article aussi intéressante que j'ai trouvé l'écrire.


Également publié ici