Полное покрытие консольного приложения .NET C# модульными тестами

Полное покрытие консольного приложения .NET C# модульными тестами

7 января 2023 г.

Рекомендации по достижению 100 % охвата с помощью разработки через тестирование (TDD), внедрения зависимостей (DI), инверсии управления (IoC) и контейнеров IoC.

Некоторые мои коллеги жалуются, что иногда они не могут применить TDD или написать модульные тесты для некоторых модулей или приложений, Консольные приложения являются одним из них.

Как я могу протестировать консольное приложение, когда ввод осуществляется нажатием клавиш, а вывод отображается на экране?!!

На самом деле, это случается время от времени, вы пытаетесь написать модульные тесты для чего-то, над чем у вас нет никакого контроля.


Photo by Sangga Rima Roman Selia on Unsplash

Заблуждение

Правда в том, что вы просто упустили суть. Вам не нужно тестировать консольное приложение, вы хотите протестировать его бизнес-логику.

Когда вы создаете консольное приложение, вы создаете приложение для использования кем-то, он ожидает, что ему будут переданы некоторые входные данные и получены соответствующие выходные данные, и это то, что вам действительно нужно протестировать.

Вы не хотите тестировать статический класс System.Console, это встроенный класс, который включен в платформу .NET, и вы должны доверять Microsoft в этом.

Теперь вам нужно подумать о том, как разделить эти две области на отдельные компоненты или модули, чтобы вы могли начать писать тесты для той, которую вы хотите, не мешая другой, и это то, что я вам объясню…< /p>


Photo by Mark Fletcher-Brown on Unsplash

Идея

Во-первых, давайте придумаем глупую идею простого консольного приложения и используем ее в качестве примера для применения.

Во-первых, у вас есть это простое меню.

Image by Ahmed Tarek

Когда вы выбираете вариант 1 и вводите ваше имя, вы получаете сообщение Hello, как на изображении ниже. Нажатие Enter закроет приложение.

Image by Ahmed Tarek

Когда вы выбираете вариант 2 и вводите ваше имя, вы получаете сообщение До свидания, как на изображении ниже. Нажатие Enter закроет приложение.

Image by Ahmed Tarek

Слишком просто, да? Да, я согласен с тобой. Однако предположим, что пользовательский интерфейс, строки, символы и все, что вы видите на экране, является частью требований.

Это означает, что если вы собираетесь писать модульные тесты, это также должно быть предусмотрено таким образом, чтобы незначительное изменение одного символа в производственном коде вызывало сбой модульного теста.


Photo by Brett Jordan on Unsplash

План

Это наш план:

  1. Создайте консольное приложение традиционным плохим способом.
  2. Посмотрите, сможем ли мы написать автоматизированные модульные тесты или нет.
  3. Повторно реализовать консольное приложение.
  4. Напишите несколько модульных тестов.

Photo by Mehdi on Unsplash

Плохой путь

Просто делайте все в одном месте.

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 Что мы можем здесь заметить:

  1. Все в одном месте.
  2. Мы напрямую используем статический класс System.Console.
  3. Мы не можем протестировать бизнес-логику, не наткнувшись на System.Console.

Photo by Brett Jordan on Unsplash

Попытка написать модульные тесты

Правда? вы действительно рассчитываете написать модульный тест для этого кода?

Вот проблемы:

  1. В зависимости от статических классов, таких как System.Console.
  2. Невозможно определить и изолировать зависимости.
  3. Невозможно заменить зависимости макетами или заглушками.

Если вы можете что-то с этим поделать, вы герой… поверьте мне.


Photo by Volkan Olmez on Unsplash

Хороший способ

Теперь давайте разделим наше решение на более мелкие модули.

Диспетчер консоли

Это модуль, который отвечает за предоставление необходимых нам функций из консоли… любой консоли.

Этот модуль будет состоять из двух частей:

  1. Абстракции.
  2. Реализации.

Поэтому у нас будет следующее:

  1. IConsoleManager: это интерфейс, определяющий, что мы ожидаем от любого диспетчера консоли.
  2. ConsoleManagerBase: это абстрактный класс, реализующий IConsoleManager и предоставляющий любые общие реализации для всех диспетчеров консоли.
  3. 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);
        }
    }
}

Что мы можем здесь заметить:

  1. Теперь у нас есть IConsoleManager.
  2. Мы можем использовать макеты и заглушки для замены IConsoleManager при написании модульных тестов.
  3. Для общего базового класса ConsoleManagerBase мы не предоставляем какой-либо общей реализации для использования дочерними элементами.
  4. Я знаю, что это не лучший способ, однако я делаю это здесь, просто чтобы напомнить вам, что эта опция существует, и вы можете использовать ее, когда это необходимо.

Менеджер программ

Это модуль, отвечающий за обеспечение основной функциональности приложения.

Этот модуль будет состоять из двух частей:

  1. Абстракции.
  2. Реализации.

Поэтому у нас будет следующее:

  1. IProgramManager: это интерфейс, определяющий, что мы ожидаем от любого диспетчера программ.
  2. ProgramManagerBase: это абстрактный класс, реализующий IProgramManager и предоставляющий любые общие реализации для всех менеджеров программ.
  3. 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");
        }
    }
}

Что мы можем здесь заметить:

  1. Теперь у нас есть четко определенная зависимость ProgramManager от IConsoleManager.
  2. У нас есть IProgramManager, и мы можем использовать макеты и заглушки для замены IProgramManager при написании модульных тестов.
  3. Для общего базового класса ProgramManagerBase мы не предоставляем какой-либо общей реализации для использования дочерними элементами.
  4. Я знаю, что это не лучший способ, однако я делаю это здесь, просто чтобы напомнить вам, что эта опция существует, и вы можете использовать ее, когда это необходимо.

:::информация Класс 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>();
        }
    }
}

Что мы можем здесь заметить:

  1. Класс NinjectDependencyResolver наследует NinjectModule.
  2. Мы переопределяем метод 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();
        }
    }
}

Что мы можем здесь заметить:

  1. Мы зависим от IProgramManager.
  2. Мы создали контейнер IoC с помощью var kernel = new StandardKernel();.
  3. Затем мы загрузили зависимости в контейнер IoC с помощью kernel.Load(Assembly.GetExecutingAssembly());. Это указывает Ninject получить свои привязки от всех классов, наследующих NinjectModule внутри текущей сборки/проекта.
  4. Это означает, что привязки будут получены из нашего класса NinjectDependencyResolver, поскольку он наследует NinjectModule и находится внутри текущей сборки/проекта.
  5. Чтобы получить экземпляр IProgramManager, мы используем контейнер IoC следующим образом: kernel.Get<IProgramManager>();.

Теперь давайте посмотрим, устранили ли нашу проблему дизайн и работа, которую мы проделали до этого момента.


Photo by Markus Winkler on Unsplash

Момент истины

Итак, теперь вопрос в том, можем ли мы покрыть наше консольное приложение модульными тестами? Чтобы ответить на этот вопрос, давайте попробуем написать несколько модульных тестов…

Заглушки или макеты

Если у вас есть опыт модульного тестирования, вы должны знать, что у нас есть заглушки и макеты, которые можно использовать для замены наших зависимостей.

Просто для удовольствия я бы использовал заглушки для нашего примера здесь.

Итак, я бы определил 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();
        }
    }
}

Photo by david Griffiths on Unsplash

Наконец-то

Теперь мы можем покрыть наше консольное приложение модульными тестами. Однако вы можете подумать, что это слишком много для простого приложения, подобного тому, что мы имеем здесь. Разве это не перебор?

На самом деле, это зависит от того, что вы хотите покрыть. Например, в нашем простом приложении я рассматривал каждый символ в пользовательском интерфейсе как требование, которое должно быть покрыто модульными тестами. Итак, если вы пойдете и измените символ в основной реализации, модульный тест завершится ошибкой.

Возможно, в вашем случае было бы иначе. Однако всегда было бы хорошо, если бы вы знали, как это сделать, вплоть до самого маленького символа.

Вот и все, надеюсь, вам было так же интересно читать эту статью, как мне было ее писать.

Также опубликовано здесь


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE