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.
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…
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.
Ao escolher a opção 1 e digitar seu nome , você receberá a mensagem Hello conforme a imagem abaixo. Pressionar enter fecharia o aplicativo.
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.
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.
O plano
Este é o nosso plano:
- Construa o aplicativo Console de uma maneira ruim tradicional.
- Veja se podemos escrever testes de unidade automatizados ou não.
- Reimplemente o aplicativo Console de uma maneira boa.
- Escreva alguns testes de unidade.
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:
- Tudo está em um só lugar.
- Estamos usando diretamente a classe estática
System.Console
. - Não podemos testar a lógica de negócios sem esbarrar em
System.Console
.
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:
- Dependendo de classes estáticas como
System.Console
. - Não é possível definir e isolar dependências.
- 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.
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:
- Abstrações.
- Implementações.
Portanto teremos o seguinte:
-
IConsoleManager
: Esta é a interface que define o que esperamos de qualquer Console Manager. -
ConsoleManagerBase
: esta é a classe abstrata que implementaIConsoleManager
e fornece todas as implementações comuns entre todos os gerenciadores de console. -
ConsoleManager
: Esta é a implementação padrão do Console Manager que envolveSystem.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:
- Agora temos
IConsoleManager
. - Podemos usar Mocks e Stubs para substituir
IConsoleManager
enquanto escrevemos testes de unidade. - Para a classe base comum
ConsoleManagerBase
, não estamos fornecendo nenhuma implementação comum para ser usada por crianças. - 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:
- Abstrações.
- Implementações.
Portanto teremos o seguinte:
-
IProgramManager
: Esta é a interface que define o que esperamos de qualquer gerente de programa. -
ProgramManagerBase
: Esta é a classe abstrata que implementaIProgramManager
e fornece todas as implementações comuns entre todos os gerentes de programa. -
ProgramManager
: Esta é a implementação padrão do Gerenciador de Programas que é realmente usada no tempo de execução. Também depende doIConsoleManager
.
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:
- Agora temos nossa dependência de
ProgramManager
emIConsoleManager
bem definida. - Temos
IProgramManager
e podemos usar mocks e stubs para substituirIProgramManager
enquanto escrevemos testes de unidade. - Para a classe base comum
ProgramManagerBase
, não estamos fornecendo nenhuma implementação comum para ser usada por filhos. - 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
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:
- A classe
NinjectDependencyResolver
está herdandoNinjectModule
. - 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:
- Estamos dependendo do
IProgramManager
. - Criamos o contêiner IoC por meio de
var kernel = new StandardKernel();
. - 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 oNinjectModule
dentro do assembly/projeto atual. - Isso significa que as ligações viriam de nossa classe
NinjectDependencyResolver
, pois ela herdaNinjectModule
e está localizada dentro do assembly/projeto atual. - Para obter uma instância do
IProgramManager
, estamos usando o contêiner IoC da seguinte maneirakernel.Get<IProgramManager>();
.
Agora, vamos ver se esse projeto e trabalho que fizemos até o momento resolveram nosso problema.
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(); } } }
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