paint-brush
.NET C# でタイマーを使用する最良の方法@ahmedtarekhasan
4,376 測定値
4,376 測定値

.NET C# でタイマーを使用する最良の方法

Ahmed Tarek Hasan30m2023/03/29
Read on Terminal Reader

長すぎる; 読むには

C# アプリケーションで System.Timers.Timer を使用すると、それを抽象化し、単体テストでモジュールをカバーできるという問題に直面する可能性があります。この記事では、これらの課題を克服するためのベスト プラクティスについて説明します。最終的に、モジュールの 100% のカバレッジを達成できるようになります。
featured image - .NET C# でタイマーを使用する最良の方法
Ahmed Tarek Hasan HackerNoon profile picture

タイマーを完全に制御し、単体テストで 100% のカバレッジを実現する方法

.NET C#アプリケーションでSystem.Timers.Timer を使用する場合、それを抽象化し、単体テストでモジュールをカバーできるという問題に直面する可能性があります。


この記事では、これらの課題を克服するためのベスト プラクティスについて説明します。最終的には、モジュールの 100% のカバレッジを達成できるようになります。


UnsplashのLina Trochezによる写真

アプローチ

これが、ソリューションにアプローチする方法です。


  1. 作業するための非常に簡単な例を考え出してください。


  2. 単純な悪い解決策から始めます。


  3. 最終フォーマットに到達するまで、改良を続けてください。


  4. 私たちの旅を通して学んだ教訓をまとめます。


UnsplashのJames Harrisonによる写真

この例では、 System.Timers.Timerを使用して毎秒日付と時刻をコンソールに書き込む単純な 1 つのことだけを行う単純なコンソール アプリケーションを作成します。


最終的には、次のようになります。


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


ご覧のとおり、要件に関しては単純で、特別なことは何もありません。


UnsplashのMikael Seegenによる写真、Ahmed Tarekによる調整

免責事項

  1. この記事で対象とする他のベスト プラクティスに主な焦点を当てるために、一部のベスト プラクティスは無視または削除されます。


  2. この記事では、単体テストでSystem.Timers.Timerを使用してモジュールをカバーすることに焦点を当てます。ただし、残りのソリューションは単体テストではカバーされません。詳しく知りたい方はこちらの記事もチェック単体テストで .NET C# コンソール アプリケーションを完全にカバーする方法.


  3. ほぼ同様の結果を得るために使用できるサードパーティ ライブラリがいくつかあります。ただし、可能な限り、大きなサードパーティ製ライブラリ全体に依存するよりも、ネイティブのシンプルな設計に従うことをお勧めします。


UnsplashのMaria Tenevaによる写真、Ahmed Tarekによる調整

悪い解決策

このソリューションでは、抽象化レイヤーを提供せずにSystem.Timers.Timer を直接使用します。


ソリューションの構造は次のようになります。


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


これは、Console TimerAppプロジェクトが 1 つだけのUsingTimerソリューションです。


System.Console IConsoleに抽象化することに意図的に時間と労力を費やして、Timer に関する問題が解決しないことを証明しました。


 namespace TimerApp.Abstractions { public interface IConsole { void WriteLine(object? value); } }


この例ではSystem.Console.WriteLineのみを使用する必要があります。これが唯一の抽象化されたメソッドである理由です。


 namespace TimerApp.Abstractions { public interface IPublisher { void StartPublishing(); void StopPublishing(); } }


IPublisherインターフェイスにはStartPublishingStopPublishing 2 つのメソッドしかありません。


さて、実装のために:


 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クラスの単体テストを作成する場合、何ができるかということです。


残念ながら、答えは次のようになります。


まず、タイマー自体を依存関係として注入していません。これは、 Publisherクラス内に依存関係を隠していることを意味します。したがって、Timer をモックまたはスタブすることはできません。


次に、Timer がコンストラクターに挿入されるようにコードを変更したとします。それでも、問題は、単体テストを作成し、Timer をモックまたはスタブに置き換える方法です。


「Timer を抽象化してラップし、Timer の代わりに注入しよう」と誰かが叫んでいるのが聞こえます。


はい、そうですが、それほど単純ではありません。次のセクションで説明するいくつかのトリックがあります。


UnsplashのCarson Mastersonによる写真、Ahmed Tarekによる調整

良い解決策

これは良い解決策の時です。それについて何ができるか見てみましょう。


ソリューションの構造は次のようになります。


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


これは、新しいConsole BetterTimerAppプロジェクトを使用した同じUsingTimerソリューションです。


IConsoleIPublisher 、および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(); } }


ここで気付くこと:


  1. 新しいデリゲートTimerIntervalElapsedEventHandlerを定義しました。このデリゲートは、 ITimerによって発生するイベントを表します。


  2. System.Timers.Timerで既に使用されているネイティブのElapsedEventHandlerが既にあるため、この新しいデリゲートは必要ないと主張するかもしれません。


  3. ええそれはそうです。ただし、 ElapsedEventHandlerイベントがイベント引数としてElapsedEventArgsを提供していることに気付くでしょう。このElapsedEventArgsにはプライベート コンストラクターがあり、独自のインスタンスを作成することはできません。さらに、 ElapsedEventArgsクラスで定義されているSignalTimeプロパティは読み取り専用です。したがって、子クラスでオーバーライドすることはできません。


  4. Microsoft がこのクラスを更新するために開かれた変更要求チケットがありますが、この記事を書いている時点まで、変更は適用されていません。


  5. また、 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); } } }


これは、ほとんどすべての魔法が起こる場所です。


ここで気付くこと:


  1. 内部的には、 System.Timers.Timerを使用しています。


  2. IDisposableデザイン パターンを適用しました。そのため、 private bool m_IsDisposedpublic void Dispose()protected virtual void Dispose(bool disposing) 、および~Timer()を確認できます。


  3. コンストラクターでは、 System.Timers.Timerの新しいインスタンスを初期化しています。残りの手順では、これを内部タイマーと呼びます。


  4. public bool Enabledpublic double Intervalpublic void Start() 、およびpublic void Stop()については、実装を内部タイマーに委譲しているだけです。


  5. public event TimerIntervalElapsedEventHandler TimerIntervalElapsedの場合、これが最も重要な部分です。それでは、順を追って分析していきましょう。


  6. このイベントで行う必要があるのは、誰かが外部からサブスクライブ/サブスクライブ解除したときに処理することです。この場合、これを内部タイマーにミラーリングします。


  7. 言い換えれば、外部の誰かが私たちのITimerのインスタンスを持っている場合、彼はこのt.TimerIntervalElapsed += (sender, dateTime) => { //do something }のようなことができるはずです。


  8. この時点で、私たちがすべきことはm_Timer.Elapsed += (sender, elapsedEventArgs) => { //do something }ことを内部的に行うことです。


  9. ただし、2 つのハンドラーは実際には異なる型であるため、同じではないことに注意する必要があります。 TimerIntervalElapsedEventHandlerおよびElapsedEventHandler


  10. したがって、必要なことは、入ってくるTimerIntervalElapsedEventHandlerを新しい内部ElapsedEventHandlerにラップすることです。これは私たちにできることです。


  11. ただし、ある時点で、誰かがTimerIntervalElapsedEventHandlerイベントからハンドラーのサブスクライブを解除する必要があるかもしれないことにも留意する必要があります。


  12. これは、現時点で、どのElapsedEventHandlerハンドラーがそのTimerIntervalElapsedEventHandlerハンドラーに対応しているかを知る必要があることを意味し、内部タイマーからのサブスクライブを解除できるようにする必要があります。


  13. これを実現する唯一の方法は、各TimerIntervalElapsedEventHandlerハンドラーと新しく作成されたElapsedEventHandlerハンドラーを辞書で追跡することです。このように、渡されたTimerIntervalElapsedEventHandlerハンドラーを知ることで、対応するElapsedEventHandlerハンドラーを知ることができます。


  14. ただし、外部から誰かが同じTimerIntervalElapsedEventHandlerハンドラーを複数回サブスクライブする可能性があることにも留意する必要があります。


  15. はい、これは論理的ではありませんが、実行可能です。したがって、完全を期すために、 TimerIntervalElapsedEventHandlerハンドラーごとに、 ElapsedEventHandlerハンドラーのリストを保持します。


  16. サブスクリプションが重複している場合を除き、ほとんどの場合、このリストにはエントリが 1 つしかありません。


  17. これが、この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(); } } }


ここでは、まだ多くのことを行っていません。これは、古いソリューションとほぼ同じです。


これを実行すると、次のようになります。


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


UnsplashのTestalize.meによる写真、Ahmed Tarekによる調整

テストの時間、真実の瞬間

これで、最終的なデザインができました。ただし、この設計が、 Publisherモジュールを単体テストでカバーするのに本当に役立つかどうかを確認する必要があります。


ソリューションの構造は次のようになります。


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


テストにはNUnitMoqを使用しています。お好みのライブラリを確実に操作できます。

タイマースタブ

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


ここで気付くこと:


  1. Timer スタブを介して実行されたアクションをログに記録する際に使用されるAction列挙型を定義しました。これは、実行された内部アクションをアサートするために後で使用されます。


  2. また、ロギングに使用するActionLogクラスを定義しました。


  3. TimerStubクラスをITimerのスタブとして定義しました。このスタブは、後でPublisherモジュールをテストするときに使用します。


  4. 実装は簡単です。特筆すべきは、追加のpublic void TriggerTimerIntervalElapsed(DateTime dateTime)メソッドを追加して、単体テスト内でスタブを手動でトリガーできるようにすることです。


  5. 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モジュールを単体テストで簡単にカバーできます。


カバレッジを計算すると、次のようになります。


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


ご覧のとおり、 Publisherモジュールは 100% カバーされています。残りについては、これはこの記事の範囲外ですが、記事のアプローチに従えば簡単にカバーできます。 単体テストで .NET C# コンソール アプリケーションを完全にカバーする方法.


UnsplashのJingda Chenによる写真、Ahmed Tarekによる調整

最後の言葉

あなたはそれを行うことができます。大きなモジュールを小さなモジュールに分割し、抽象化を定義し、トリッキーな部分で創造的になれば、それで完了です。


もっと自分自身をトレーニングしたい場合は、いくつかのベスト プラクティスに関する私の他の記事を参照してください。


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


こちらにも掲載