Полное покрытие консольного приложения .NET C# модульными тестами
7 января 2023 г.Рекомендации по достижению 100 % охвата с помощью разработки через тестирование (TDD), внедрения зависимостей (DI), инверсии управления (IoC) и контейнеров IoC.
Некоторые мои коллеги жалуются, что иногда они не могут применить TDD или написать модульные тесты для некоторых модулей или приложений, Консольные приложения являются одним из них.
Как я могу протестировать консольное приложение, когда ввод осуществляется нажатием клавиш, а вывод отображается на экране?!!
На самом деле, это случается время от времени, вы пытаетесь написать модульные тесты для чего-то, над чем у вас нет никакого контроля.
Заблуждение
Правда в том, что вы просто упустили суть. Вам не нужно тестировать консольное приложение, вы хотите протестировать его бизнес-логику.
Когда вы создаете консольное приложение, вы создаете приложение для использования кем-то, он ожидает, что ему будут переданы некоторые входные данные и получены соответствующие выходные данные, и это то, что вам действительно нужно протестировать.
Вы не хотите тестировать статический класс System.Console
, это встроенный класс, который включен в платформу .NET, и вы должны доверять Microsoft в этом.
Теперь вам нужно подумать о том, как разделить эти две области на отдельные компоненты или модули, чтобы вы могли начать писать тесты для той, которую вы хотите, не мешая другой, и это то, что я вам объясню…< /p>
Идея
Во-первых, давайте придумаем глупую идею простого консольного приложения и используем ее в качестве примера для применения.
Во-первых, у вас есть это простое меню.
Когда вы выбираете вариант 1 и вводите ваше имя, вы получаете сообщение Hello, как на изображении ниже. Нажатие Enter закроет приложение.
Когда вы выбираете вариант 2 и вводите ваше имя, вы получаете сообщение До свидания, как на изображении ниже. Нажатие Enter закроет приложение.
Слишком просто, да? Да, я согласен с тобой. Однако предположим, что пользовательский интерфейс, строки, символы и все, что вы видите на экране, является частью требований.
Это означает, что если вы собираетесь писать модульные тесты, это также должно быть предусмотрено таким образом, чтобы незначительное изменение одного символа в производственном коде вызывало сбой модульного теста.
План
Это наш план:
- Создайте консольное приложение традиционным плохим способом.
- Посмотрите, сможем ли мы написать автоматизированные модульные тесты или нет.
- Повторно реализовать консольное приложение.
- Напишите несколько модульных тестов.
Плохой путь
Просто делайте все в одном месте.
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");
}
}
}
n Что мы можем здесь заметить:
- Все в одном месте.
- Мы напрямую используем статический класс
System.Console
. - Мы не можем протестировать бизнес-логику, не наткнувшись на
System.Console
.
Попытка написать модульные тесты
Правда? вы действительно рассчитываете написать модульный тест для этого кода?
Вот проблемы:
- В зависимости от статических классов, таких как
System.Console
. - Невозможно определить и изолировать зависимости.
- Невозможно заменить зависимости макетами или заглушками.
Если вы можете что-то с этим поделать, вы герой… поверьте мне.
Хороший способ
Теперь давайте разделим наше решение на более мелкие модули.
Диспетчер консоли
Это модуль, который отвечает за предоставление необходимых нам функций из консоли… любой консоли.
Этот модуль будет состоять из двух частей:
- Абстракции.
- Реализации.
Поэтому у нас будет следующее:
IConsoleManager
: это интерфейс, определяющий, что мы ожидаем от любого диспетчера консоли.ConsoleManagerBase
: это абстрактный класс, реализующийIConsoleManager
и предоставляющий любые общие реализации для всех диспетчеров консоли.ConsoleManager
: это реализация диспетчера консоли по умолчанию, которая является оболочкойSystem.Console
и фактически используется во время выполнения.
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);
}
}
}
Что мы можем здесь заметить:
- Теперь у нас есть
IConsoleManager
. - Мы можем использовать макеты и заглушки для замены
IConsoleManager
при написании модульных тестов. - Для общего базового класса
ConsoleManagerBase
мы не предоставляем какой-либо общей реализации для использования дочерними элементами. - Я знаю, что это не лучший способ, однако я делаю это здесь, просто чтобы напомнить вам, что эта опция существует, и вы можете использовать ее, когда это необходимо.
Менеджер программ
Это модуль, отвечающий за обеспечение основной функциональности приложения.
Этот модуль будет состоять из двух частей:
- Абстракции.
- Реализации.
Поэтому у нас будет следующее:
IProgramManager
: это интерфейс, определяющий, что мы ожидаем от любого диспетчера программ.ProgramManagerBase
: это абстрактный класс, реализующийIProgramManager
и предоставляющий любые общие реализации для всех менеджеров программ.ProgramManager
: это реализация Program Manager по умолчанию, которая фактически используется во время выполнения. Это также зависит от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");
}
}
}
Что мы можем здесь заметить:
- Теперь у нас есть четко определенная зависимость
ProgramManager
отIConsoleManager
. - У нас есть
IProgramManager
, и мы можем использовать макеты и заглушки для заменыIProgramManager
при написании модульных тестов. - Для общего базового класса
ProgramManagerBase
мы не предоставляем какой-либо общей реализации для использования дочерними элементами. - Я знаю, что это не лучший способ, однако я делаю это здесь, просто чтобы напомнить вам, что эта опция существует, и вы можете использовать ее, когда это необходимо.
:::информация
Класс ProgramManager
можно разделить на более мелкие части. Это облегчило бы отслеживание и покрытие модульными тестами. Однако это то, что я оставляю вам.
:::
Консольное приложение
Это основное приложение.
Здесь мы собираемся использовать Ninject в качестве нашего контейнера IoC. Он прост в использовании, и вы всегда можете проверить их онлайн-документацию.
В основном проекте консольного приложения мы создадим файл NinjectDependencyResolver.cs
. Этот файл будет выглядеть следующим образом.
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>();
}
}
}
Что мы можем здесь заметить:
- Класс
NinjectDependencyResolver
наследуетNinjectModule
. - Мы переопределяем метод
void Load()
, в котором мы устанавливаем наши привязки, как и ожидалось.
Теперь в 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();
}
}
}
Что мы можем здесь заметить:
- Мы зависим от
IProgramManager
. - Мы создали контейнер IoC с помощью
var kernel = new StandardKernel();
. - Затем мы загрузили зависимости в контейнер IoC с помощью
kernel.Load(Assembly.GetExecutingAssembly());
. Это указывает Ninject получить свои привязки от всех классов, наследующихNinjectModule
внутри текущей сборки/проекта. - Это означает, что привязки будут получены из нашего класса
NinjectDependencyResolver
, поскольку он наследуетNinjectModule
и находится внутри текущей сборки/проекта. - Чтобы получить экземпляр
IProgramManager
, мы используем контейнер IoC следующим образом:kernel.Get<IProgramManager>();
.
Теперь давайте посмотрим, устранили ли нашу проблему дизайн и работа, которую мы проделали до этого момента.
Момент истины
Итак, теперь вопрос в том, можем ли мы покрыть наше консольное приложение модульными тестами? Чтобы ответить на этот вопрос, давайте попробуем написать несколько модульных тестов…
Заглушки или макеты
Если у вас есть опыт модульного тестирования, вы должны знать, что у нас есть заглушки и макеты, которые можно использовать для замены наших зависимостей.
Просто для удовольствия я бы использовал заглушки для нашего примера здесь.
Итак, я бы определил ConsoleManagerStub
как заглушку для IConsoleManager
следующим образом:
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 + "rn");
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;
}
}
}
И, наконец, модульные тесты будут такими:
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 apprn",
"Welcome to my console apprn[1] Say Hello?rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: ",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 1rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 1rnPlease enter your name: ",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 1rnPlease enter your name: " + name + "rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 1rnPlease enter your name: " + name + "rnHello " + name +"rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 1rnPlease enter your name: " + name + "rnHello " + name +"rnrn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 1rnPlease enter your name: " + name + "rnHello " + name +"rnrnPress 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 apprn",
"Welcome to my console apprn[1] Say Hello?rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: ",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 2rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 2rnPlease enter your name: ",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 2rnPlease enter your name: " + name + "rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 2rnPlease enter your name: " + name + "rnGoodbye " + name + "rn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 2rnPlease enter your name: " + name + "rnGoodbye " + name + "rnrn",
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: 2rnPlease enter your name: " + name + "rnGoodbye " + name + "rnrnPress 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 apprn", // outputEntryNumber 1
"Welcome to my console apprn[1] Say Hello?rn", // outputEntryNumber 2
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rn", // outputEntryNumber 3
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrn", // outputEntryNumber 4
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: ", // outputEntryNumber 5
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: any invalid input 1rn", // outputEntryNumber 6
// after first trial
"", // outputEntryNumber 7
"Welcome to my console apprn", // outputEntryNumber 8
"Welcome to my console apprn[1] Say Hello?rn", // outputEntryNumber 9
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rn", // outputEntryNumber 10
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrn", // outputEntryNumber 11
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: ", // outputEntryNumber 12
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: any invalid input 2rn", // outputEntryNumber 13
// after second trial
"", // outputEntryNumber 14
"Welcome to my console apprn", // outputEntryNumber 15
"Welcome to my console apprn[1] Say Hello?rn", // outputEntryNumber 16
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rn", // outputEntryNumber 17
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrn", // outputEntryNumber 18
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: ", // outputEntryNumber 19
"Welcome to my console apprn[1] Say Hello?rn[2] Say Goodbye?rnrnPlease enter a valid choice: Exitrn" // outputEntryNumber 20
};
m_ConsoleManager.OutputsUpdated +=
outputEntryNumber =>
{
if (outputEntryNumber - 1 < expectedOutput.Count)
{
Assert.AreEqual(
expectedOutput[outputEntryNumber - 1],
m_ConsoleManager.ToString());
}
};
m_ProgramManager.Run();
}
}
}
Наконец-то
Теперь мы можем покрыть наше консольное приложение модульными тестами. Однако вы можете подумать, что это слишком много для простого приложения, подобного тому, что мы имеем здесь. Разве это не перебор?
На самом деле, это зависит от того, что вы хотите покрыть. Например, в нашем простом приложении я рассматривал каждый символ в пользовательском интерфейсе как требование, которое должно быть покрыто модульными тестами. Итак, если вы пойдете и измените символ в основной реализации, модульный тест завершится ошибкой.
Возможно, в вашем случае было бы иначе. Однако всегда было бы хорошо, если бы вы знали, как это сделать, вплоть до самого маленького символа.
Вот и все, надеюсь, вам было так же интересно читать эту статью, как мне было ее писать.
Также опубликовано здесь
Оригинал