paint-brush
Abrangendo totalmente o aplicativo de console .NET C# com testes de unidadepor@ahmedtarekhasan
2,848 leituras
2,848 leituras

Abrangendo totalmente o aplicativo de console .NET C# com testes de unidade

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

Muito longo; Para ler

Alguns colegas estão reclamando que às vezes não conseguem aplicar TDD ou escrever testes de unidade para alguns módulos ou aplicativos. Aplicativos de console é um deles. Como eu poderia testar um aplicativo de console quando a entrada é passada por teclas e a saída é apresentada em uma tela?!! Você não precisa testar o aplicativo "Console", mas sim a lógica de negócios por trás dele.
featured image - Abrangendo totalmente o aplicativo de console .NET C# com testes de unidade
Ahmed Tarek Hasan HackerNoon profile picture

Prática recomendada para atingir 100% de cobertura usando Test Driven Development (TDD), injeção de dependência (DI), inversão de controle (IoC) e contêineres IoC.


Alguns colegas meus estão reclamando que às vezes não são capazes de aplicar TDD ou escrever testes de unidade para alguns módulos ou aplicativos, aplicativos de console são um deles.


Como eu poderia testar um aplicativo de console quando a entrada é passada por teclas e a saída é apresentada em uma tela?!!


Na verdade, isso acontece de vez em quando, você tenta escrever testes de unidade para algo sobre o qual parece não ter controle.


Foto de Sangga Rima Roman Selia no Unsplash

Equívoco

A verdade é que você perdeu o ponto. Você não precisa testar o aplicativo “Console”, você deseja testar a lógica de negócios por trás dele.


Quando você está construindo um aplicativo de console, está construindo um aplicativo para alguém usar, ele espera passar algumas entradas e obter algumas saídas correspondentes, e é isso que você realmente precisa testar .


Você não quer testar a classe estática System.Console , esta é uma classe interna incluída no framework .NET e você deve confiar na Microsoft nisso.


Agora, você precisa pensar em como separar essas duas áreas em componentes ou módulos separados para poder começar a escrever testes para aquela que deseja sem interferir na outra, e é isso que vou te explicar…


Foto de Mark Fletcher-Brown no Unsplash

A ideia

Primeiro, vamos criar uma ideia estúpida de aplicativo de console simples e usá-la como um exemplo para aplicar.


Primeiro, você tem este menu simples.


Imagem de Ahmed Tarek


Ao escolher a opção 1 e digitar seu nome , você receberá a mensagem Hello conforme a imagem abaixo. Pressionar enter fecharia o aplicativo.


Imagem de Ahmed Tarek


Ao escolher a opção 2 e digitar seu nome , você receberá a mensagem de Adeus conforme a imagem abaixo. Pressionar enter fecharia o aplicativo.


Imagem de Ahmed Tarek


Muito simples, certo? Sim, eu concordo com você. No entanto, vamos supor que a interface do usuário, strings, caracteres e tudo o que você vê na tela façam parte dos requisitos.


Isso significa que, se você for escrever testes de unidade, isso também deve ser coberto de forma que uma pequena alteração em um único caractere no código de produção acione um teste de unidade com falha.


Foto de Brett Jordan no Unsplash

O plano

Este é o nosso plano:

  1. Construa o aplicativo Console de uma maneira ruim tradicional.
  2. Veja se podemos escrever testes de unidade automatizados ou não.
  3. Reimplemente o aplicativo Console de uma maneira boa.
  4. Escreva alguns testes de unidade.

Foto de Mehdi no Unsplash

O jeito ruim

Simplesmente, faça tudo em um só 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"); } } }


O que podemos notar aqui:

  1. Tudo está em um só lugar.
  2. Estamos usando diretamente a classe estática System.Console .
  3. Não podemos testar a lógica de negócios sem esbarrar em System.Console .

Foto de Brett Jordan no Unsplash

Tentando escrever testes de unidade

Sério? você realmente espera ser capaz de escrever um teste de unidade para esse código?

Aqui estão os desafios:

  1. Dependendo de classes estáticas como System.Console .
  2. Não é possível definir e isolar dependências.
  3. Não é possível substituir dependências por Mocks ou Stubs.

Se você pode fazer algo a respeito, você é um herói... acredite em mim.


Foto de Volkan Olmez no Unsplash

O bom caminho

Agora, vamos dividir nossa solução em módulos menores.


Gerenciador de Console

Este é o módulo responsável por fornecer a funcionalidade que precisamos do console... qualquer console.


Este módulo consistiria em duas partes:

  1. Abstrações.
  2. Implementações.


Portanto teremos o seguinte:

  1. IConsoleManager : Esta é a interface que define o que esperamos de qualquer Console Manager.
  2. ConsoleManagerBase : esta é a classe abstrata que implementa IConsoleManager e fornece todas as implementações comuns entre todos os gerenciadores de console.
  3. ConsoleManager : Esta é a implementação padrão do Console Manager que envolve System.Console e é realmente usado em tempo de execução.


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


O que podemos notar aqui:

  1. Agora temos IConsoleManager .
  2. Podemos usar Mocks e Stubs para substituir IConsoleManager enquanto escrevemos testes de unidade.
  3. Para a classe base comum ConsoleManagerBase , não estamos fornecendo nenhuma implementação comum para ser usada por crianças.
  4. Sei que não é a melhor coisa a se fazer, porém estou fazendo isso aqui apenas para lembrar que essa opção existe e você pode usá-la sempre que precisar.

Gerenciador de programa

Este é o módulo responsável por fornecer a funcionalidade principal do aplicativo.


Este módulo consistiria em duas partes:

  1. Abstrações.
  2. Implementações.


Portanto teremos o seguinte:

  1. IProgramManager : Esta é a interface que define o que esperamos de qualquer gerente de programa.
  2. ProgramManagerBase : Esta é a classe abstrata que implementa IProgramManager e fornece todas as implementações comuns entre todos os gerentes de programa.
  3. ProgramManager : Esta é a implementação padrão do Gerenciador de Programas que é realmente usada no tempo de execução. Também depende do 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"); } } }


O que podemos notar aqui:

  1. Agora temos nossa dependência de ProgramManager em IConsoleManager bem definida.
  2. Temos IProgramManager e podemos usar mocks e stubs para substituir IProgramManager enquanto escrevemos testes de unidade.
  3. Para a classe base comum ProgramManagerBase , não estamos fornecendo nenhuma implementação comum para ser usada por filhos.
  4. Sei que não é a melhor coisa a se fazer, porém estou fazendo isso aqui apenas para lembrar que essa opção existe e você pode usá-la sempre que precisar.


A classe ProgramManager pode ser dividida em partes menores. Isso tornaria mais fácil rastrear e cobrir com testes de unidade. No entanto, isso é algo que estou deixando para você fazer.


Aplicação de console

Esta é a aplicação principal.


Aqui vamos usar Ninject como nosso contêiner IoC. É simples de usar e você sempre pode verificar a documentação online.


No projeto principal do Console Application, criaríamos o arquivo NinjectDependencyResolver.cs . Este arquivo seria o seguinte.


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


O que podemos notar aqui:

  1. A classe NinjectDependencyResolver está herdando NinjectModule .
  2. Estamos substituindo o método void Load() onde estamos definindo nossas ligações como esperado.


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


O que podemos notar aqui:

  1. Estamos dependendo do IProgramManager .
  2. Criamos o contêiner IoC por meio de var kernel = new StandardKernel(); .
  3. Em seguida, carregamos as dependências no contêiner IoC por meio de kernel.Load(Assembly.GetExecutingAssembly()); . Isso instrui o Ninject a obter suas ligações de todas as classes que herdam o NinjectModule dentro do assembly/projeto atual.
  4. Isso significa que as ligações viriam de nossa classe NinjectDependencyResolver , pois ela herda NinjectModule e está localizada dentro do assembly/projeto atual.
  5. Para obter uma instância do IProgramManager , estamos usando o contêiner IoC da seguinte maneira kernel.Get<IProgramManager>(); .


Agora, vamos ver se esse projeto e trabalho que fizemos até o momento resolveram nosso problema.


Foto de Markus Winkler no Unsplash

Momento da verdade

Portanto, a questão agora é: podemos cobrir nosso aplicativo de console com testes de unidade? Para responder a esta pergunta, vamos tentar escrever alguns testes de unidade…


Stubs ou Mocks

Se você tem alguma experiência com testes de unidade, saiba que temos Stubs e Mocks para serem usados para substituir nossas dependências.


Apenas por diversão, eu usaria stubs para nosso exemplo aqui.


Então, eu definiria ConsoleManagerStub como um stub para IConsoleManager da seguinte forma:


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


E por fim, os testes unitários seriam os seguintes:


 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 no Unsplash

Finalmente

Agora conseguimos cobrir nosso aplicativo de console com testes de unidade. No entanto, você pode pensar que isso é demais para um aplicativo simples como o que temos aqui. Isso não é um exagero?


Na verdade, depende do que você deseja cobrir. Por exemplo, em nosso aplicativo simples, tratei de cada caractere da IU como um requisito que deveria ser coberto por testes de unidade. Portanto, se você alterar um caractere na implementação principal, um teste de unidade falhará.


Talvez no seu caso seja diferente. No entanto, seria sempre bom se você soubesse como fazer isso até o menor personagem.


É isso, espero que você tenha achado a leitura deste artigo tão interessante quanto eu achei ao escrevê-lo.


Também publicado aqui