タイマーを完全に制御し、単体テストで 100% のカバレッジを実現する方法 アプリケーションで 使用する場合、それを抽象化し、単体テストでモジュールをカバーできるという問題に直面する可能性があります。 .NET C# System.Timers.Timer を この記事では、これらの課題を克服するための について説明します。最終的には、モジュールの 100% のカバレッジを達成できるようになります。 ベスト プラクティス アプローチ これが、ソリューションにアプローチする方法です。 作業するための非常に簡単な例を考え出してください。 単純な悪い解決策から始めます。 最終フォーマットに到達するまで、改良を続けてください。 私たちの旅を通して学んだ教訓をまとめます。 例 この例では、 コンソールに書き込む単純な 1 つのことだけを行う単純な を作成します。 System.Timers.Timer を使用して 毎秒 日付と時刻を コンソール アプリケーション 最終的には、次のようになります。 ご覧のとおり、要件に関しては単純で、特別なことは何もありません。 免責事項 この記事で対象とする他のベスト プラクティスに主な焦点を当てるために、一部のベスト プラクティスは無視または削除されます。 この記事では、単体テストで を使用してモジュールをカバーすることに焦点を当てます。ただし、残りのソリューションは単体テストではカバーされません。詳しく知りたい方はこちらの記事もチェック . System.Timers.Timer 単体テストで .NET C# コンソール アプリケーションを完全にカバーする方法 ほぼ同様の結果を得るために使用できるサードパーティ ライブラリがいくつかあります。ただし、可能な限り、大きなサードパーティ製ライブラリ全体に依存するよりも、ネイティブのシンプルな設計に従うことをお勧めします。 悪い解決策 このソリューションでは、抽象化レイヤーを提供せずに 直接使用します。 System.Timers.Timer を ソリューションの構造は次のようになります。 これは プロジェクトが 1 つだけの ソリューションです。 、Console TimerApp UsingTimer に抽象化することに意図的に時間と労力を費やして、Timer に関する問題が解決しないことを証明しました。 System.Console IConsole namespace TimerApp.Abstractions { public interface IConsole { void WriteLine(object? value); } } この例では のみを使用する必要があります。これが唯一の抽象化されたメソッドである理由です。 System.Console.WriteLine namespace TimerApp.Abstractions { public interface IPublisher { void StartPublishing(); void StopPublishing(); } } インターフェイスには と 2 つのメソッドしかありません。 IPublisher StartPublishing StopPublishing さて、実装のために: using TimerApp.Abstractions; namespace TimerApp.Implementations { public class Console : IConsole { public void WriteLine(object? value) { System.Console.WriteLine(value); } } } の薄いラッパーです。 Console System.Console using System.Timers; using TimerApp.Abstractions; namespace TimerApp.Implementations { public class Publisher : IPublisher { private readonly Timer m_Timer; private readonly IConsole m_Console; public Publisher(IConsole console) { m_Timer = new Timer(); m_Timer.Enabled = true; m_Timer.Interval = 1000; m_Timer.Elapsed += Handler; m_Console = console; } public void StartPublishing() { m_Timer.Start(); } public void StopPublishing() { m_Timer.Stop(); } private void Handler(object sender, ElapsedEventArgs args) { m_Console.WriteLine(args.SignalTime); } } } の単純な実装です。 を使用していて、それを構成しているだけです。 Publisher IPublisher System.Timers.Timer 依存関係として 定義されています。これは、私の観点からはベスト プラクティスではありません。私の言いたいことを理解したい場合は、記事を確認してください . IConsole .NET C# で DI、IoC、および IoC コンテナーを使用しない場合 ただし、簡単にするために、コンストラクターに依存関係として挿入するだけです。 また、タイマー間隔を 1000 ミリ秒 (1 秒) に設定し、タイマー をコンソールに書き込むようにハンドラーを設定します。 SignalTime using TimerApp.Abstractions; using TimerApp.Implementations; namespace TimerApp { public class Program { static void Main(string[] args) { IPublisher publisher = new Publisher(new Implementations.Console()); publisher.StartPublishing(); System.Console.ReadLine(); publisher.StopPublishing(); } } } ここで、 クラスでは、多くのことを行っていません。 クラスのインスタンスを作成し、発行を開始しています。 Program Publisher これを実行すると、次のようになります。 ここで問題は、 クラスの単体テストを作成する場合、何ができるかということです。 Publisher 残念ながら、答えは なります。 次のように まず、タイマー自体を依存関係として注入していません。これは、 クラス内に依存関係を隠していることを意味します。したがって、Timer をモックまたはスタブすることはできません。 Publisher 次に、Timer がコンストラクターに挿入されるようにコードを変更したとします。それでも、問題は、単体テストを作成し、Timer をモックまたはスタブに置き換える方法です。 「Timer を抽象化してラップし、Timer の代わりに注入しよう」と誰かが叫んでいるのが聞こえます。 はい、そうですが、それほど単純ではありません。次のセクションで説明するいくつかのトリックがあります。 良い解決策 これは良い解決策の時です。それについて何ができるか見てみましょう。 ソリューションの構造は次のようになります。 これは、新しい プロジェクトを使用した同じ ソリューションです。 Console BetterTimerApp UsingTimer 、 、および は同じです。 IConsole IPublisher Console ITimer using System; namespace BetterTimerApp.Abstractions { public delegate void TimerIntervalElapsedEventHandler(object sender, DateTime dateTime); public interface ITimer : IDisposable { event TimerIntervalElapsedEventHandler TimerIntervalElapsed; bool Enabled { get; set; } double Interval { get; set; } void Start(); void Stop(); } } ここで気付くこと: 新しいデリゲート を定義しました。このデリゲートは、 によって発生するイベントを表します。 TimerIntervalElapsedEventHandler ITimer で既に使用されているネイティブの が既にあるため、この新しいデリゲートは必要ないと主張するかもしれません。 System.Timers.Timer ElapsedEventHandler ええそれはそうです。ただし、 イベントがイベント引数として を提供していることに気付くでしょう。この にはプライベート コンストラクターがあり、独自のインスタンスを作成することはできません。さらに、 クラスで定義されている プロパティは読み取り専用です。したがって、子クラスでオーバーライドすることはできません。 ElapsedEventHandler ElapsedEventArgs ElapsedEventArgs ElapsedEventArgs SignalTime Microsoft がこのクラスを更新するために開かれた変更要求チケットがありますが、この記事を書いている時点まで、変更は適用されていません。 また、 を拡張することに注意してください。 ITimer IDisposable 出版社 using System; using BetterTimerApp.Abstractions; namespace BetterTimerApp.Implementations { public class Publisher : IPublisher { private readonly ITimer m_Timer; private readonly IConsole m_Console; public Publisher(ITimer timer, IConsole console) { m_Timer = timer; m_Timer.Enabled = true; m_Timer.Interval = 1000; m_Timer.TimerIntervalElapsed += Handler; m_Console = console; } public void StartPublishing() { m_Timer.Start(); } public void StopPublishing() { m_Timer.Stop(); } private void Handler(object sender, DateTime dateTime) { m_Console.WriteLine(dateTime); } } } 小さな変更点を除いて、旧 とほぼ同じです。これで、コンストラクターを介して注入される依存関係として 定義されました。コードの残りの部分は同じです。 Publisher ITimer タイマー using System; using System.Collections.Generic; using System.Linq; using System.Timers; using BetterTimerApp.Abstractions; namespace BetterTimerApp.Implementations { public class Timer : ITimer { private Dictionary<TimerIntervalElapsedEventHandler, List<ElapsedEventHandler>> m_Handlers = new(); private bool m_IsDisposed; private System.Timers.Timer m_Timer; public Timer() { m_Timer = new System.Timers.Timer(); } public event TimerIntervalElapsedEventHandler TimerIntervalElapsed { add { var internalHandler = (ElapsedEventHandler)((sender, args) => { value.Invoke(sender, args.SignalTime); }); if (!m_Handlers.ContainsKey(value)) { m_Handlers.Add(value, new List<ElapsedEventHandler>()); } m_Handlers[value].Add(internalHandler); m_Timer.Elapsed += internalHandler; } remove { m_Timer.Elapsed -= m_Handlers[value].Last(); m_Handlers[value].RemoveAt(m_Handlers[value].Count - 1); if (!m_Handlers[value].Any()) { m_Handlers.Remove(value); } } } public bool Enabled { get => m_Timer.Enabled; set => m_Timer.Enabled = value; } public double Interval { get => m_Timer.Interval; set => m_Timer.Interval = value; } public void Start() { m_Timer.Start(); } public void Stop() { m_Timer.Stop(); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (m_IsDisposed) return; if (disposing && m_Handlers.Any()) { foreach (var internalHandlers in m_Handlers.Values) { if (internalHandlers?.Any() ?? false) { internalHandlers.ForEach(handler => m_Timer.Elapsed -= handler); } } m_Timer.Dispose(); m_Timer = null; m_Handlers.Clear(); m_Handlers = null; } m_IsDisposed = true; } ~Timer() { Dispose(false); } } } これは、ほとんどすべての魔法が起こる場所です。 ここで気付くこと: 内部的には、 を使用しています。 System.Timers.Timer デザイン パターンを適用しました。そのため、 、 、 、および を確認できます。 IDisposable private bool m_IsDisposed public void Dispose() protected virtual void Dispose(bool disposing) ~Timer() コンストラクターでは、 の新しいインスタンスを初期化しています。残りの手順では、これを と呼びます。 System.Timers.Timer 内部タイマー 、 、 、および については、実装を内部タイマーに委譲しているだけです。 public bool Enabled public double Interval public void Start() public void Stop() の場合、これが最も重要な部分です。それでは、順を追って分析していきましょう。 public event TimerIntervalElapsedEventHandler TimerIntervalElapsed このイベントで行う必要があるのは、誰かが外部からサブスクライブ/サブスクライブ解除したときに処理することです。この場合、これを内部タイマーにミラーリングします。 言い換えれば、外部の誰かが私たちの のインスタンスを持っている場合、彼はこの のようなことができるはずです。 ITimer t.TimerIntervalElapsed += (sender, dateTime) => { //do something } この時点で、私たちがすべきことは ことを内部的に行うことです。 m_Timer.Elapsed += (sender, elapsedEventArgs) => { //do something } ただし、2 つのハンドラーは実際には異なる型であるため、同じではないことに注意する必要があります。 および 。 TimerIntervalElapsedEventHandler ElapsedEventHandler したがって、必要なことは、入ってくる を新しい内部 にラップすることです。これは私たちにできることです。 TimerIntervalElapsedEventHandler ElapsedEventHandler ただし、ある時点で、誰かが イベントからハンドラーのサブスクライブを解除する必要があるかもしれないことにも留意する必要があります。 TimerIntervalElapsedEventHandler これは、現時点で、どの ハンドラーがその ハンドラーに対応しているかを知る必要があることを意味し、内部タイマーからのサブスクライブを解除できるようにする必要があります。 ElapsedEventHandler TimerIntervalElapsedEventHandler これを実現する唯一の方法は、各 ハンドラーと新しく作成された ハンドラーを辞書で追跡することです。このように、渡された ハンドラーを知ることで、対応する ハンドラーを知ることができます。 TimerIntervalElapsedEventHandler ElapsedEventHandler TimerIntervalElapsedEventHandler ElapsedEventHandler ただし、外部から誰かが同じ ハンドラーを複数回サブスクライブする可能性があることにも留意する必要があります。 TimerIntervalElapsedEventHandler はい、これは論理的ではありませんが、実行可能です。したがって、完全を期すために、 ハンドラーごとに、 ハンドラーのリストを保持します。 TimerIntervalElapsedEventHandler ElapsedEventHandler サブスクリプションが重複している場合を除き、ほとんどの場合、このリストにはエントリが 1 つしかありません。 これが、この 見ることができる理由です。 . private Dictionary<TimerIntervalElapsedEventHandler, List<ElapsedEventHandler>> m_Handlers = new(); public event TimerIntervalElapsedEventHandler TimerIntervalElapsed { add { var internalHandler = (ElapsedEventHandler)((sender, args) => { value.Invoke(sender, args.SignalTime); }); if (!m_Handlers.ContainsKey(value)) { m_Handlers.Add(value, new List<ElapsedEventHandler>()); } m_Handlers[value].Add(internalHandler); m_Timer.Elapsed += internalHandler; } remove { m_Timer.Elapsed -= m_Handlers[value].Last(); m_Handlers[value].RemoveAt(m_Handlers[value].Count - 1); if (!m_Handlers[value].Any()) { m_Handlers.Remove(value); } } } では、新しい を作成し、これを にマッピングするディクショナリの にレコードを追加し、最後に内部タイマーをサブスクライブします。 add ElapsedEventHandler TimerIntervalElapsedEventHandler m_Handlers では、対応する ハンドラーのリストを取得し、最後のハンドラーを選択し、それを内部タイマーからサブスクライブ解除し、リストから削除し、リストが空の場合はエントリ全体を削除します。 remove ElapsedEventHandler 実装についても言及する価値があります。 Dispose protected virtual void Dispose(bool disposing) { if (m_IsDisposed) return; if (disposing && m_Handlers.Any()) { foreach (var internalHandlers in m_Handlers.Values) { if (internalHandlers?.Any() ?? false) { internalHandlers.ForEach(handler => m_Timer.Elapsed -= handler); } } m_Timer.Dispose(); m_Timer = null; m_Handlers.Clear(); m_Handlers = null; } m_IsDisposed = true; } 内部タイマーから残りのすべてのハンドラーのサブスクライブを解除し、内部タイマーを破棄し、 ディクショナリをクリアします。 m_Handlers プログラム using BetterTimerApp.Abstractions; using BetterTimerApp.Implementations; namespace BetterTimerApp { public class Program { static void Main(string[] args) { var timer = new Timer(); IPublisher publisher = new Publisher(timer, new Implementations.Console()); publisher.StartPublishing(); System.Console.ReadLine(); publisher.StopPublishing(); timer.Dispose(); } } } ここでは、まだ多くのことを行っていません。これは、古いソリューションとほぼ同じです。 これを実行すると、次のようになります。 テストの時間、真実の瞬間 これで、最終的なデザインができました。ただし、この設計が、 モジュールを単体テストでカバーするのに本当に役立つかどうかを確認する必要があります。 Publisher ソリューションの構造は次のようになります。 テストには と を使用しています。お好みのライブラリを確実に操作できます。 NUnit Moq タイマースタブ using System; using System.Collections.Generic; using BetterTimerApp.Abstractions; namespace BetterTimerApp.Tests.Stubs { public enum Action { Start = 1, Stop = 2, Triggered = 3, Enabled = 4, Disabled = 5, IntervalSet = 6 } public class ActionLog { public Action Action { get; } public string Message { get; } public ActionLog(Action action, string message) { Action = action; Message = message; } } public class TimerStub : ITimer { private bool m_Enabled; private double m_Interval; public event TimerIntervalElapsedEventHandler TimerIntervalElapsed; public Dictionary<int, ActionLog> Log = new(); public bool Enabled { get => m_Enabled; set { m_Enabled = value; Log.Add(Log.Count + 1, new ActionLog(value ? Action.Enabled : Action.Disabled, value ? "Enabled" : "Disabled")); } } public double Interval { get => m_Interval; set { m_Interval = value; Log.Add(Log.Count + 1, new ActionLog(Action.IntervalSet, m_Interval.ToString("G17"))); } } public void Start() { Log.Add(Log.Count + 1, new ActionLog(Action.Start, "Started")); } public void Stop() { Log.Add(Log.Count + 1, new ActionLog(Action.Stop, "Stopped")); } public void TriggerTimerIntervalElapsed(DateTime dateTime) { OnTimerIntervalElapsed(dateTime); Log.Add(Log.Count + 1, new ActionLog(Action.Triggered, "Triggered")); } protected void OnTimerIntervalElapsed(DateTime dateTime) { TimerIntervalElapsed?.Invoke(this, dateTime); } public void Dispose() { Log.Clear(); Log = null; } } } ここで気付くこと: Timer スタブを介して実行されたアクションをログに記録する際に使用される 列挙型を定義しました。これは、実行された内部アクションをアサートするために後で使用されます。 Action また、ロギングに使用する クラスを定義しました。 ActionLog クラスを のスタブとして定義しました。このスタブは、後で モジュールをテストするときに使用します。 TimerStub ITimer Publisher 実装は簡単です。特筆すべきは、追加の メソッドを追加して、単体テスト内でスタブを手動でトリガーできるようにすることです。 public void TriggerTimerIntervalElapsed(DateTime dateTime) の期待値を渡すこともできるので、アサートする既知の値を得ることができます。 dateTime パブリッシャーテスト using System; using BetterTimerApp.Abstractions; using BetterTimerApp.Implementations; using BetterTimerApp.Tests.Stubs; using Moq; using NUnit.Framework; using Action = BetterTimerApp.Tests.Stubs.Action; namespace BetterTimerApp.Tests.Tests { [TestFixture] public class PublisherTests { private TimerStub m_TimerStub; private Mock<IConsole> m_ConsoleMock; private Publisher m_Sut; [SetUp] public void SetUp() { m_TimerStub = new TimerStub(); m_ConsoleMock = new Mock<IConsole>(); m_Sut = new Publisher(m_TimerStub, m_ConsoleMock.Object); } [TearDown] public void TearDown() { m_Sut = null; m_ConsoleMock = null; m_TimerStub = null; } [Test] public void ConstructorTest() { Assert.AreEqual(Action.Enabled, m_TimerStub.Log[1].Action); Assert.AreEqual(Action.Enabled.ToString(), m_TimerStub.Log[1].Message); Assert.AreEqual(Action.IntervalSet, m_TimerStub.Log[2].Action); Assert.AreEqual(1000.ToString("G17"), m_TimerStub.Log[2].Message); } [Test] public void StartPublishingTest() { // Arrange var expectedDateTime = DateTime.Now; m_ConsoleMock .Setup ( m => m.WriteLine ( It.Is<DateTime>(p => p == expectedDateTime) ) ) .Verifiable(); // Act m_Sut.StartPublishing(); m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime); // Assert ConstructorTest(); m_ConsoleMock .Verify ( m => m.WriteLine(expectedDateTime) ); Assert.AreEqual(Action.Start, m_TimerStub.Log[3].Action); Assert.AreEqual("Started", m_TimerStub.Log[3].Message); Assert.AreEqual(Action.Triggered, m_TimerStub.Log[4].Action); Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[4].Message); } [Test] public void StopPublishingTest() { // Act m_Sut.StopPublishing(); // Assert ConstructorTest(); Assert.AreEqual(Action.Stop, m_TimerStub.Log[3].Action); Assert.AreEqual("Stopped", m_TimerStub.Log[3].Message); } [Test] public void FullProcessTest() { // Arrange var expectedDateTime1 = DateTime.Now; var expectedDateTime2 = expectedDateTime1 + TimeSpan.FromSeconds(1); var expectedDateTime3 = expectedDateTime2 + TimeSpan.FromSeconds(1); var sequence = new MockSequence(); m_ConsoleMock .InSequence(sequence) .Setup ( m => m.WriteLine ( It.Is<DateTime>(p => p == expectedDateTime1) ) ) .Verifiable(); m_ConsoleMock .InSequence(sequence) .Setup ( m => m.WriteLine ( It.Is<DateTime>(p => p == expectedDateTime2) ) ) .Verifiable(); m_ConsoleMock .InSequence(sequence) .Setup ( m => m.WriteLine ( It.Is<DateTime>(p => p == expectedDateTime3) ) ) .Verifiable(); // Act m_Sut.StartPublishing(); m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime1); // Assert ConstructorTest(); m_ConsoleMock .Verify ( m => m.WriteLine(expectedDateTime1) ); Assert.AreEqual(Action.Start, m_TimerStub.Log[3].Action); Assert.AreEqual("Started", m_TimerStub.Log[3].Message); Assert.AreEqual(Action.Triggered, m_TimerStub.Log[4].Action); Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[4].Message); // Act m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime2); // Assert m_ConsoleMock .Verify ( m => m.WriteLine(expectedDateTime2) ); Assert.AreEqual(Action.Triggered, m_TimerStub.Log[5].Action); Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[5].Message); // Act m_Sut.StopPublishing(); // Assert Assert.AreEqual(Action.Stop, m_TimerStub.Log[6].Action); Assert.AreEqual("Stopped", m_TimerStub.Log[6].Message); } } } これで、ご覧のとおり、完全に制御できるようになり、 モジュールを単体テストで簡単にカバーできます。 Publisher カバレッジを計算すると、次のようになります。 ご覧のとおり、 モジュールは 100% カバーされています。残りについては、これはこの記事の範囲外ですが、記事のアプローチに従えば簡単にカバーできます。 . Publisher 単体テストで .NET C# コンソール アプリケーションを完全にカバーする方法 最後の言葉 あなたはそれを行うことができます。大きなモジュールを小さなモジュールに分割し、抽象化を定義し、トリッキーな部分で創造的になれば、それで完了です。 もっと自分自身をトレーニングしたい場合は、いくつかのベスト プラクティスに関する私の他の記事を参照してください。 それだけです。この記事を読んで、私が書いたのと同じくらい面白いと思っていただければ幸いです。 も掲載 こちらに