paint-brush
使用单元测试完全覆盖 .NET C# 控制台应用程序经过@ahmedtarekhasan
2,848 讀數
2,848 讀數

使用单元测试完全覆盖 .NET C# 控制台应用程序

经过 Ahmed Tarek Hasan26m2023/01/06
Read on Terminal Reader

太長; 讀書

一些同事抱怨说,有时他们无法应用 TDD 或为某些模块或应用程序编写单元测试。控制台应用程序就是其中之一。当输入通过击键传递并且输出显示在屏幕上时,我如何测试控制台应用程序?!!您不需要测试“控制台”应用程序,您想要测试其背后的业务逻辑。
featured image - 使用单元测试完全覆盖 .NET C# 控制台应用程序
Ahmed Tarek Hasan HackerNoon profile picture

使用测试驱动开发 (TDD)、依赖注入 (DI)、控制反转 (IoC) 和 IoC 容器实现 100% 覆盖率的最佳实践。


我的一些同事抱怨说,有时他们无法应用 TDD 或为某些模块或应用程序编写单元测试,控制台应用程序就是其中之一。


当输入通过击键传递并且输出显示在屏幕上时,我如何测试控制台应用程序?!!


实际上,这种情况时有发生,您发现自己正在尝试为您似乎无法控制的事情编写单元测试。


Sangga Rima Roman Selia 在 Unsplash 上拍摄的照片

误解

事实是,你只是错过了重点。您不需要测试“控制台”应用程序,您想要测试其背后的业务逻辑


当你在构建一个 Console 应用程序时,你是在构建一个应用程序供某人使用,他期望传递一些输入并得到一些相应的输出,而这才是你真正需要测试的


您不想测试System.Console静态类,这是包含在 .NET 框架中的内置类,您必须在这方面信任 Microsoft。


现在,您需要考虑如何将这两个区域分离成单独的组件或模块,以便您可以开始为您想要的一个编写测试而不会干扰另一个,这就是我要向您解释的……


Mark Fletcher-Brown 在 Unsplash 上拍摄的照片

理念

首先,让我们想出一个愚蠢的简单控制台应用程序想法,并以此为例进行应用。


首先,你有这个简单的菜单。


图片由 Ahmed Tarek 提供


当您选择选项1并输入您的姓名时,您会收到如下图所示的Hello消息。点击回车将关闭应用程序。


图片由 Ahmed Tarek 提供


当您选择选项2并输入您的姓名时,您会收到如下图所示的再见消息。点击回车将关闭应用程序。


图片由 Ahmed Tarek 提供


太简单了吧?是的,我同意你的看法。但是,我们假设 UI、字符串、字符以及您在屏幕上看到的所有内容都是要求的一部分。


这意味着,如果您要编写单元测试,也应该以一种方式涵盖生产代码中单个字符的微小更改,应该触发失败的单元测试。


布雷特乔丹在 Unsplash 上拍摄的照片

计划

这是我们的计划:

  1. 以传统的错误方式构建控制台应用程序。
  2. 看看我们是否可以编写自动化单元测试。
  3. 以一种好的方式重新实现控制台应用程序。
  4. 写一些单元测试。

Mehdi 在 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"); } } }


我们可以在这里注意到:

  1. 一切都在一个地方。
  2. 我们直接使用静态System.Console类。
  3. 我们无法在不碰到System.Console的情况下测试业务逻辑。

布雷特乔丹在 Unsplash 上拍摄的照片

尝试编写单元测试

真的吗?您真的希望能够为该代码编写单元测试吗?

以下是挑战:

  1. 取决于像System.Console这样的静态类。
  2. 无法定义和隔离依赖项。
  3. 不能用模拟或存根替换依赖项。

如果你能为此做点什么,你就是英雄……相信我。


Volkan Olmez 在 Unsplash 上拍摄的照片

好方法

现在,让我们将我们的解决方案拆分成更小的模块。


控制台管理器

这是负责从控制台提供我们需要的功能的模块......任何控制台。


该模块将由两部分组成:

  1. 抽象。
  2. 实施。


因此,我们将有以下内容:

  1. IConsoleManager :这是定义我们对任何控制台管理器的期望的接口。
  2. ConsoleManagerBase :这是实现IConsoleManager并提供所有控制台管理器之间的任何通用实现的抽象类。
  3. ConsoleManager :这是默认的 Console Manager 实现,它包装了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. 我们可以在编写单元测试时使用 Mocks 和 Stubs 来代替IConsoleManager
  3. 对于公共基类ConsoleManagerBase ,我们不提供任何供儿童使用的公共实现。
  4. 我知道这不是最好的做法,但是,我在这里这样做只是为了提醒您这个选项就在那里,您可以在需要时使用它。

项目经理

这是负责提供主要应用程序功能的模块。


该模块将由两部分组成:

  1. 抽象。
  2. 实施。


因此,我们将有以下内容:

  1. IProgramManager :这是定义我们对任何 Program Manager 的期望的接口。
  2. ProgramManagerBase :这是实现IProgramManager并提供所有程序管理器之间的任何通用实现的抽象类。
  3. ProgramManager :这是在运行时实际使用的默认程序管理器实现。它还取决于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. 现在我们已经明确定义了ProgramManagerIConsoleManager的依赖。
  2. 我们有IProgramManager ,我们可以在编写单元测试时使用模拟和存根来替换IProgramManager
  3. 对于公共基类ProgramManagerBase ,我们不提供任何供儿童使用的公共实现。
  4. 我知道这不是最好的做法,但是,我在这里这样做只是为了提醒您这个选项就在那里,您可以在需要时使用它。


ProgramManager类可以拆分成更小的部分。这将使跟踪和覆盖单元测试变得更加容易。然而,这是我留给你做的事情。


控制台应用程序

这是主要的应用程序。


在这里我们要使用忍射作为我们的 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. 我们通过var kernel = new StandardKernel();创建了 IoC 容器。 .
  3. 然后我们通过kernel.Load(Assembly.GetExecutingAssembly());将依赖加载到 IoC 容器中。 .这指示 Ninject 从当前程序集/项目中继承NinjectModule的所有类获取其绑定。
  4. 这意味着绑定将来自我们的NinjectDependencyResolver类,因为它继承NinjectModule并位于当前程序集/项目中。
  5. 要获取IProgramManager的实例,我们使用 IoC 容器,如下所示kernel.Get<IProgramManager>(); .


现在,让我们看看到目前为止我们所做的设计和工作是否解决了我们的问题。


Markus Winkler 在 Unsplash 上拍摄的照片

关键时刻

那么,现在的问题是,我们可以用单元测试来覆盖我们的控制台应用程序吗?为了回答这个问题,让我们尝试编写一些单元测试……


存根或模拟

如果您有一些单元测试经验,您应该知道我们有 Stubs 和 Mocks 可以用来替换我们的依赖项。


只是为了好玩,我将在此处的示例中使用存根。


因此,我会将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 + "\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; } } }


最后,单元测试如下:


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

David Griffiths 在 Unsplash 上拍摄的照片

最后

现在我们已经能够用单元测试覆盖我们的控制台应用程序。但是,您可能认为这对于我们这里的简单应用程序来说太多了。这不是矫枉过正吗?


实际上,这取决于您要涵盖的内容。例如,在我们的简单应用程序中,我将 UI 上的每个字符都视为单元测试应涵盖的要求。因此,如果您去更改主要实现中的一个字符,单元测试将失败。


也许在你的情况下,它会有所不同。但是,如果您知道如何做到这一点,甚至是最小的字符,那总是好的。


就是这样,希望您觉得阅读这篇文章和我写这篇文章一样有趣。


也在这里发布