paint-brush
Cobertura completa de la aplicación de consola .NET C# con pruebas unitariaspor@ahmedtarekhasan
3,568 lecturas
3,568 lecturas

Cobertura completa de la aplicación de consola .NET C# con pruebas unitarias

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

Demasiado Largo; Para Leer

Algunos colegas se quejan de que a veces no pueden aplicar TDD o escribir pruebas unitarias para algunos módulos o aplicaciones. Aplicaciones de consola es una de ellas. ¿Cómo podría probar una aplicación de consola cuando la entrada se pasa por pulsaciones de teclas y la salida se presenta en una pantalla? No necesita probar la aplicación "Consola", quiere probar la lógica comercial detrás de ella.
featured image - Cobertura completa de la aplicación de consola .NET C# con pruebas unitarias
Ahmed Tarek Hasan HackerNoon profile picture

Mejores prácticas para lograr una cobertura del 100 % mediante el desarrollo dirigido por pruebas (TDD), la inyección de dependencia (DI), la inversión de control (IoC) y los contenedores IoC.


Algunos colegas míos se quejan de que a veces no pueden aplicar TDD o escribir pruebas unitarias para algunos módulos o aplicaciones, las aplicaciones de consola son una de ellas.


¿Cómo podría probar una aplicación de consola cuando la entrada se pasa mediante pulsaciones de teclas y la salida se presenta en una pantalla?


En realidad, esto sucede de vez en cuando, se encuentra tratando de escribir pruebas unitarias para algo sobre lo que parece no tener ningún control.


Foto de Sangga Rima Roman Selia en Unsplash

Idea equivocada

La verdad es que simplemente te perdiste el punto. No necesita probar la aplicación "Consola", quiere probar la lógica comercial detrás de ella.


Cuando está creando una aplicación de consola, está creando una aplicación para que alguien la use, espera pasar algunas entradas y obtener algunas salidas correspondientes, y eso es lo que realmente necesita probar .


No desea probar la clase estática System.Console , esta es una clase integrada que se incluye en el marco .NET y debe confiar en Microsoft en esto.


Ahora, debe pensar en cómo separar estas dos áreas en componentes o módulos separados para que pueda comenzar a escribir pruebas para la que desea sin interferir con la otra, y esto es lo que le voy a explicar...


Foto de Mark Fletcher-Brown en Unsplash

La idea

Primero, pensemos en una estúpida y simple idea de aplicación de consola y usémosla como ejemplo para aplicar.


Primero, tienes este sencillo menú.


Imagen de Ahmed Tarek


Cuando eliges la opción 1 e ingresas tu nombre , obtienes el mensaje Hola como en la imagen a continuación. Presionar enter cerraría la aplicación.


Imagen de Ahmed Tarek


Cuando elige la opción 2 e ingresa su nombre , obtiene el mensaje de despedida como en la imagen a continuación. Presionar enter cerraría la aplicación.


Imagen de Ahmed Tarek


Demasiado simple, ¿verdad? Sí estoy de acuerdo con usted. Sin embargo, supongamos que la interfaz de usuario, las cadenas, los caracteres y todo lo que ve en la pantalla es parte de los requisitos.


Esto significa que si va a escribir pruebas unitarias, esto también debe cubrirse de manera que un cambio menor en un solo carácter en el código de producción deba desencadenar una prueba unitaria fallida.


Foto de Brett Jordan en Unsplash

El plan

Este es nuestro plan:

  1. Cree la aplicación de consola de una manera tradicional.
  2. Vea si podemos escribir pruebas unitarias automatizadas o no.
  3. Vuelva a implementar la aplicación Consola de una buena manera.
  4. Escribe algunas pruebas unitarias.

Foto de Mehdi en Unsplash

el mal camino

Simplemente, haga todo en un solo lugar.


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


Lo que podemos notar aquí:

  1. Todo está en un solo lugar.
  2. Estamos usando directamente la clase estática System.Console .
  3. No podemos probar la lógica empresarial sin encontrarnos con System.Console .

Foto de Brett Jordan en Unsplash

Tratando de escribir pruebas unitarias

¿En serio? ¿Realmente espera poder escribir una prueba unitaria para ese código?

Aquí están los desafíos:

  1. Dependiendo de clases estáticas como System.Console .
  2. No se pueden definir y aislar dependencias.
  3. No se pueden reemplazar las dependencias con Mocks o Stubs.

Si puedes hacer algo al respecto, eres un héroe... créeme.


Foto de Volkan Olmez en Unsplash

el buen camino

Ahora, dividamos nuestra solución en módulos más pequeños.


Administrador de consola

Este es el módulo que se encarga de brindar la funcionalidad que necesitamos de la Consola… cualquier consola.


Este módulo constaría de dos partes:

  1. Abstracciones.
  2. Implementaciones.


Por lo tanto tendremos lo siguiente:

  1. IConsoleManager : esta es la interfaz que define lo que esperamos de cualquier administrador de consola.
  2. ConsoleManagerBase : esta es la clase abstracta que implementa IConsoleManager y proporciona implementaciones comunes entre todos los administradores de consola.
  3. ConsoleManager : esta es la implementación predeterminada de Console Manager que envuelve System.Console y se usa realmente en tiempo de ejecución.


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


Lo que podemos notar aquí:

  1. Ahora tenemos IConsoleManager .
  2. Podemos usar Mocks and Stubs para reemplazar IConsoleManager mientras escribimos pruebas unitarias.
  3. Para la clase base común ConsoleManagerBase , no proporcionamos ninguna implementación común para que la usen los niños.
  4. Sé que esto no es lo mejor que se puede hacer, sin embargo, lo hago aquí solo para recordarle que esta opción está ahí y que puede usarla cuando la necesite.

Director del programa

Este es el módulo que se encarga de proporcionar la funcionalidad principal de la aplicación.


Este módulo constaría de dos partes:

  1. Abstracciones.
  2. Implementaciones.


Por lo tanto tendremos lo siguiente:

  1. IProgramManager : esta es la interfaz que define lo que esperamos de cualquier administrador de programas.
  2. ProgramManagerBase : esta es la clase abstracta que implementa IProgramManager y proporciona implementaciones comunes entre todos los administradores de programas.
  3. ProgramManager : esta es la implementación predeterminada del Administrador de programas que se usa en tiempo de ejecución. También depende del 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"); } } }


Lo que podemos notar aquí:

  1. Ahora tenemos bien definida nuestra dependencia de ProgramManager en IConsoleManager .
  2. Tenemos IProgramManager y podemos usar simulacros y stubs para reemplazar IProgramManager mientras escribimos pruebas unitarias.
  3. Para la clase base común ProgramManagerBase , no proporcionamos ninguna implementación común para que la usen los niños.
  4. Sé que esto no es lo mejor que se puede hacer, sin embargo, lo hago aquí solo para recordarle que esta opción está ahí y que puede usarla cuando la necesite.


La clase ProgramManager podría dividirse en partes más pequeñas. Eso facilitaría el seguimiento y la cobertura con pruebas unitarias. Sin embargo, esto es algo que te dejo a ti.


Aplicación de consola

Esta es la aplicación principal.


Aquí vamos a usar inyectar como nuestro contenedor IoC. Es fácil de usar y siempre puede consultar su documentación en línea.


En el proyecto principal de la aplicación de consola, crearíamos el archivo NinjectDependencyResolver.cs . Este archivo sería el siguiente.


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


Lo que podemos notar aquí:

  1. La clase NinjectDependencyResolver hereda NinjectModule .
  2. Estamos reemplazando el método void Load() donde estamos configurando nuestros enlaces como se esperaba.


Ahora, en 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(); } } }


Lo que podemos notar aquí:

  1. Dependemos del IProgramManager .
  2. Creamos el contenedor IoC a través de var kernel = new StandardKernel(); .
  3. Luego cargamos las dependencias en el contenedor IoC a través kernel.Load(Assembly.GetExecutingAssembly()); . Esto le indica a Ninject que obtenga sus enlaces de todas las clases que heredan NinjectModule dentro del ensamblaje/proyecto actual.
  4. Esto significa que los enlaces provendrían de nuestra clase NinjectDependencyResolver , ya que hereda NinjectModule y se encuentra dentro del ensamblaje/proyecto actual.
  5. Para obtener una instancia de IProgramManager , estamos usando el contenedor IoC de la siguiente manera kernel.Get<IProgramManager>(); .


Ahora, veamos si este diseño y trabajo que hemos hecho hasta este momento ha solucionado nuestro problema.


Foto de Markus Winkler en Unsplash

Momento de la verdad

Entonces, la pregunta ahora es, ¿podemos cubrir nuestra aplicación de consola con pruebas unitarias? Para responder a esta pregunta, intentemos escribir algunas pruebas unitarias...


Talones o simulacros

Si tiene algo de experiencia con pruebas unitarias, debe saber que tenemos Stubs y Mocks para reemplazar nuestras dependencias.


Solo por diversión, usaría stubs para nuestro ejemplo aquí.


Entonces, definiría ConsoleManagerStub como un código auxiliar para IConsoleManager de la siguiente manera:


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


Y finalmente, las pruebas unitarias quedarían de la siguiente manera:


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

Foto de david Griffiths en Unsplash

Finalmente

Ahora hemos podido cubrir nuestra aplicación de consola con pruebas unitarias. Sin embargo, podría pensar que esto es demasiado para una aplicación simple como la que tenemos aquí. ¿No es esto exagerado?


En realidad, depende de lo que quieras cubrir. Por ejemplo, en nuestra aplicación simple, traté cada carácter en la interfaz de usuario como un requisito que debería cubrir las pruebas unitarias. Entonces, si va y cambia un personaje en la implementación principal, una prueba unitaria fallará.


Tal vez en tu caso, sería diferente. Sin embargo, siempre sería bueno que supieras cómo hacerlo hasta el más mínimo personaje.


Eso es todo, espero que hayas encontrado la lectura de este artículo tan interesante como yo encontré al escribirlo.


También publicado aquí