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.
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...
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ú.
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.
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.
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.
Este es nuestro plan:
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í:
System.Console
.System.Console
. ¿En serio? ¿Realmente espera poder escribir una prueba unitaria para ese código?
System.Console
.Si puedes hacer algo al respecto, eres un héroe... créeme.
Ahora, dividamos nuestra solución en módulos más pequeños.
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:
Por lo tanto tendremos lo siguiente:
IConsoleManager
: esta es la interfaz que define lo que esperamos de cualquier administrador de consola.ConsoleManagerBase
: esta es la clase abstracta que implementa IConsoleManager
y proporciona implementaciones comunes entre todos los administradores de consola.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í:
IConsoleManager
.IConsoleManager
mientras escribimos pruebas unitarias.ConsoleManagerBase
, no proporcionamos ninguna implementación común para que la usen los niños.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:
Por lo tanto tendremos lo siguiente:
IProgramManager
: esta es la interfaz que define lo que esperamos de cualquier administrador de programas.ProgramManagerBase
: esta es la clase abstracta que implementa IProgramManager
y proporciona implementaciones comunes entre todos los administradores de programas.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í:
ProgramManager
en IConsoleManager
.IProgramManager
y podemos usar simulacros y stubs para reemplazar IProgramManager
mientras escribimos pruebas unitarias.ProgramManagerBase
, no proporcionamos ninguna implementación común para que la usen los niños.
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.
Esta es la aplicación principal.
Aquí vamos a usar
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í:
NinjectDependencyResolver
hereda NinjectModule
.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í:
IProgramManager
.var kernel = new StandardKernel();
.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.NinjectDependencyResolver
, ya que hereda NinjectModule
y se encuentra dentro del ensamblaje/proyecto actual.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.
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...
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(); } } }
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í