私の同僚の何人かは、TDD を適用したり、一部のモジュールやアプリケーションの単体テストを作成したりできない場合があると不満を漏らしています。コンソール アプリケーションはその 1 つです。
入力がキーストロークによって渡され、出力が画面に表示される場合、コンソール アプリケーションをテストするにはどうすればよいでしょうか?!!
実際、これはときどき起こります。自分では制御できないと思われるものの単体テストを作成しようとしていることに気づきます。
真実は、あなたは要点を逃しただけです。 「コンソール」アプリケーションをテストする必要はありません。その背後にあるビジネス ロジックをテストする必要があります。
コンソール アプリケーションを構築しているときは、誰かが使用するアプリケーションを構築していることになります。その人は、いくつかの入力を渡し、対応する出力を取得することを期待しています。それが、あなたが実際にテストする必要があるものです。
System.Console
静的クラスをテストしたくありません。これは .NET フレームワークに含まれている組み込みクラスであり、Microsoft を信頼する必要があります。
ここで、これら 2 つの領域を別のコンポーネントまたはモジュールに分割する方法を考える必要があります。これにより、他の領域に干渉することなく、必要な領域のテストを書き始めることができます。これについて説明します…
まず、ばかげた単純なコンソール アプリケーションのアイデアを思いつき、それを適用する例として使用しましょう。
まずは、こんな簡単メニュー。
オプション1を選択して名前を入力すると、下の画像のようにHelloメッセージが表示されます。 Enter キーを押すと、アプリケーションが閉じます。
オプション2を選択して名前を入力すると、下の画像のようにさようならメッセージが表示されます。 Enter キーを押すと、アプリケーションが閉じます。
シンプルすぎますよね?はい、あなたに賛成です。ただし、UI、文字列、文字、および画面に表示されるすべてのものが要件の一部であると仮定しましょう。
これは、単体テストを作成する場合は、製品コードの 1 文字の小さな変更が単体テストの失敗をトリガーするようにカバーする必要があることを意味します。
これが私たちの計画です:
簡単に言えば、すべてを 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"); } } }
ここで気付くこと:
System.Console
クラスを直接使用しています。System.Console
にぶつからずにビジネス ロジックをテストすることはできません。 本当に?そのコードの単体テストを書けると本当に期待していますか?
System.Console
のような静的クラスに依存します。あなたがそれについて何かをすることができれば、あなたはヒーローです... 私を信じてください.
それでは、ソリューションを小さなモジュールに分割しましょう。
これは、コンソールから必要な機能を提供する役割を担うモジュールです…任意のコンソールです。
このモジュールは、次の 2 つの部分で構成されます。
したがって、次のようになります。
IConsoleManager
: これは、コンソール マネージャーに期待されるものを定義するインターフェイスです。ConsoleManagerBase
: これはIConsoleManager
を実装し、すべてのコンソール マネージャー間で共通の実装を提供する抽象クラスです。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); } } }
ここで気付くこと:
IConsoleManager
ができました。IConsoleManager
を置き換えることができます。ConsoleManagerBase
については、子によって使用される共通の実装は提供されていません。これは、メインのアプリケーション機能を提供するモジュールです。
このモジュールは、次の 2 つの部分で構成されます。
したがって、次のようになります。
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"); } } }
ここで気付くこと:
IConsoleManager
のProgramManager
への依存関係が明確に定義されました。IProgramManager
があり、モックとスタブを使用して、単体テストの作成中にIProgramManager
を置き換えることができます。ProgramManagerBase
については、子によって使用される共通の実装は提供されていません。
ProgramManager
クラスは、小さな部分に分割できます。これにより、単体テストの追跡とカバーが容易になります。しかし、これは私があなたに任せていることです。
これが主なアプリケーションです。
ここで使用します
メインのコンソール アプリケーション プロジェクトでは、 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
に依存しています。var kernel = new StandardKernel();
を使用して IoC コンテナーを作成しました。 .kernel.Load(Assembly.GetExecutingAssembly());
を介して依存関係を IoC コンテナーにロードしました。 .これは、現在のアセンブリ/プロジェクト内のNinjectModule
を継承するすべてのクラスからバインディングを取得するように Ninject に指示します。NinjectModule
を継承し、現在のアセンブリ/プロジェクト内にあるNinjectDependencyResolver
クラスから取得されることを意味します。IProgramManager
のインスタンスを取得するために、 kernel.Get<IProgramManager>();
のように IoC コンテナーを使用しています。 .
では、この設計とこれまでに行った作業によって問題が解決したかどうかを見てみましょう。
ここでの問題は、コンソール アプリケーションを単体テストでカバーできるかどうかです。この質問に答えるために、いくつかの単体テストを書いてみましょう…
単体テストの経験がある場合は、依存関係を置き換えるために使用するスタブとモックがあることを知っておく必要があります。
楽しみのために、ここでの例ではスタブを使用します。
したがって、 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(); } } }
これで、コンソール アプリケーションを単体テストでカバーできるようになりました。ただし、ここにあるような単純なアプリケーションには多すぎると思うかもしれません。これはやり過ぎではありませんか?
実際には、何をカバーしたいかによって異なります。たとえば、この単純なアプリケーションでは、UI 上のすべての文字を単体テストでカバーする必要がある要件として扱いました。そのため、メインの実装でキャラクターを変更すると、単体テストが失敗します。
たぶんあなたの場合は違うでしょう。ただし、最小の文字まで実行する方法を知っていると、常に良い.
それだけです。この記事を読んで、私が書いたのと同じくらい面白いと思っていただければ幸いです。
こちらにも掲載