paint-brush
単体テストで .NET C# コンソール アプリケーションを完全にカバーする@ahmedtarekhasan
3,223 測定値
3,223 測定値

単体テストで .NET C# コンソール アプリケーションを完全にカバーする

Ahmed Tarek Hasan26m2023/01/06
Read on Terminal Reader

長すぎる; 読むには

一部の同僚は、一部のモジュールやアプリケーションに対して TDD を適用したり、単体テストを作成したりできない場合があると不満を漏らしています。コンソール アプリケーションはその 1 つです。入力がキーストロークによって渡され、出力が画面に表示される場合、コンソールアプリケーションをテストするにはどうすればよいですか?!! 「コンソール」アプリケーションをテストする必要はありません。その背後にあるビジネス ロジックをテストする必要があります。
featured image - 単体テストで .NET C# コンソール アプリケーションを完全にカバーする
Ahmed Tarek Hasan HackerNoon profile picture

テスト駆動開発 (TDD)、依存性注入 (DI)、制御の反転 (IoC)、および IoC コンテナーを使用して 100% のカバレッジを達成するためのベスト プラクティス。


私の同僚の何人かは、TDD を適用したり、一部のモジュールやアプリケーションの単体テストを作成したりできない場合があると不満を漏らしています。コンソール アプリケーションはその 1 つです。


入力がキーストロークによって渡され、出力が画面に表示される場合、コンソール アプリケーションをテストするにはどうすればよいでしょうか?!!


実際、これはときどき起こります。自分では制御できないと思われるものの単体テストを作成しようとしていることに気づきます。


UnsplashのSangga Rima Roman Seliaによる写真

誤解

真実は、あなたは要点を逃しただけです。 「コンソール」アプリケーションをテストする必要はありません。その背後にあるビジネス ロジックをテストする必要があります。


コンソール アプリケーションを構築しているときは、誰かが使用するアプリケーションを構築していることになります。その人は、いくつかの入力を渡し、対応する出力を取得することを期待しています。それが、あなたが実際にテストする必要があるものです


System.Console静的クラスをテストしたくありません。これは .NET フレームワークに含まれている組み込みクラスであり、Microsoft を信頼する必要があります。


ここで、これら 2 つの領域を別のコンポーネントまたはモジュールに分割する方法を考える必要があります。これにより、他の領域に干渉することなく、必要な領域のテストを書き始めることができます。これについて説明します…


UnsplashのMark Fletcher-Brownによる写真

アイデア

まず、ばかげた単純なコンソール アプリケーションのアイデアを思いつき、それを適用する例として使用しましょう。


まずは、こんな簡単メニュー。


アーメド・タレクによる画像


オプション1を選択して名前を入力すると、下の画像のようにHelloメッセージが表示されます。 Enter キーを押すと、アプリケーションが閉じます。


アーメド・タレクによる画像


オプション2を選択して名前を入力すると、下の画像のようにさようならメッセージが表示されます。 Enter キーを押すと、アプリケーションが閉じます。


アーメド・タレクによる画像


シンプルすぎますよね?はい、あなたに賛成です。ただし、UI、文字列、文字、および画面に表示されるすべてのものが要件の一部であると仮定しましょう。


これは、単体テストを作成する場合は、製品コードの 1 文字の小さな変更が単体テストの失敗をトリガーするようにカバーする必要があることを意味します。


UnsplashのBrett Jordanによる写真

計画

これが私たちの計画です:

  1. 従来の悪い方法でコンソール アプリケーションをビルドします。
  2. 自動化された単体テストを記述できるかどうかを確認してください。
  3. コンソール アプリケーションを適切な方法で再実装します。
  4. いくつかの単体テストを作成します。

UnsplashのMehdiによる写真

悪い道

簡単に言えば、すべてを 1 か所で行うだけです。


 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. すべてが 1 か所にあります。
  2. 静的System.Consoleクラスを直接使用しています。
  3. System.Consoleにぶつからずにビジネス ロジックをテストすることはできません。

UnsplashのBrett Jordanによる写真

単体テストを書こうとしている

本当に?そのコードの単体テストを書けると本当に期待していますか?

課題は次のとおりです。

  1. System.Consoleのような静的クラスに依存します。
  2. 依存関係を定義して分離することはできません。
  3. 依存関係をモックまたはスタブに置き換えることはできません。

あなたがそれについて何かをすることができれば、あなたはヒーローです... 私を信じてください.


UnsplashのVolkan Olmezによる写真

良い方法

それでは、ソリューションを小さなモジュールに分割しましょう。


コンソール マネージャー

これは、コンソールから必要な機能を提供する役割を担うモジュールです…任意のコンソールです。


このモジュールは、次の 2 つの部分で構成されます。

  1. 抽象化。
  2. 実装。


したがって、次のようになります。

  1. IConsoleManager : これは、コンソール マネージャーに期待されるものを定義するインターフェイスです。
  2. ConsoleManagerBase : これはIConsoleManagerを実装し、すべてのコンソール マネージャー間で共通の実装を提供する抽象クラスです。
  3. ConsoleManager : これは、 System.Consoleをラップし、実行時に実際に使用されるデフォルトの Console Manager 実装です。


 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. これが最善の方法ではないことは承知していますが、ここでは、このオプションがあり、必要なときにいつでも使用できることを思い出していただくために、ここで行っています。

プログラムマネージャー

これは、メインのアプリケーション機能を提供するモジュールです。


このモジュールは、次の 2 つの部分で構成されます。

  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. これで、 IConsoleManagerProgramManagerへの依存関係が明確に定義されました。
  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 コンテナーにロードしました。 .これは、現在のアセンブリ/プロジェクト内のNinjectModuleを継承するすべてのクラスからバインディングを取得するように Ninject に指示します。
  4. これは、バインディングがNinjectModuleを継承し、現在のアセンブリ/プロジェクト内にあるNinjectDependencyResolverクラスから取得されることを意味します。
  5. IProgramManagerのインスタンスを取得するために、 kernel.Get<IProgramManager>();のように IoC コンテナーを使用しています。 .


では、この設計とこれまでに行った作業によって問題が解決したかどうかを見てみましょう。


UnsplashのMarkus Winklerによる写真

真実の瞬間

ここでの問題は、コンソール アプリケーションを単体テストでカバーできるかどうかです。この質問に答えるために、いくつかの単体テストを書いてみましょう…


スタブまたはモック

単体テストの経験がある場合は、依存関係を置き換えるために使用するスタブとモックがあることを知っておく必要があります。


楽しみのために、ここでの例ではスタブを使用します。


したがって、 IConsoleManager ConsoleManagerStubスタブとして次のように定義します。


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

Unsplashのデビッド・グリフィスによる写真

ついに

これで、コンソール アプリケーションを単体テストでカバーできるようになりました。ただし、ここにあるような単純なアプリケーションには多すぎると思うかもしれません。これはやり過ぎではありませんか?


実際には、何をカバーしたいかによって異なります。たとえば、この単純なアプリケーションでは、UI 上のすべての文字を単体テストでカバーする必要がある要件として扱いました。そのため、メインの実装でキャラクターを変更すると、単体テストが失敗します。


たぶんあなたの場合は違うでしょう。ただし、最小の文字まで実行する方法を知っていると、常に良い.


それだけです。この記事を読んで、私が書いたのと同じくらい面白いと思っていただければ幸いです。


こちらにも掲載