.NET C#アプリケーションでSystem.Timers.Timer を使用する場合、それを抽象化し、単体テストでモジュールをカバーできるという問題に直面する可能性があります。
この記事では、これらの課題を克服するためのベスト プラクティスについて説明します。最終的には、モジュールの 100% のカバレッジを達成できるようになります。
これが、ソリューションにアプローチする方法です。
作業するための非常に簡単な例を考え出してください。
単純な悪い解決策から始めます。
最終フォーマットに到達するまで、改良を続けてください。
私たちの旅を通して学んだ教訓をまとめます。
この例では、 System.Timers.Timer
を使用して毎秒日付と時刻をコンソールに書き込む単純な 1 つのことだけを行う単純なコンソール アプリケーションを作成します。
最終的には、次のようになります。
ご覧のとおり、要件に関しては単純で、特別なことは何もありません。
この記事で対象とする他のベスト プラクティスに主な焦点を当てるために、一部のベスト プラクティスは無視または削除されます。
この記事では、単体テストでSystem.Timers.Timerを使用してモジュールをカバーすることに焦点を当てます。ただし、残りのソリューションは単体テストではカバーされません。詳しく知りたい方はこちらの記事もチェック
ほぼ同様の結果を得るために使用できるサードパーティ ライブラリがいくつかあります。ただし、可能な限り、大きなサードパーティ製ライブラリ全体に依存するよりも、ネイティブのシンプルな設計に従うことをお勧めします。
このソリューションでは、抽象化レイヤーを提供せずに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
インターフェイスにはStartPublishing
とStopPublishing
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
定義されています。これは、私の観点からはベスト プラクティスではありません。私の言いたいことを理解したい場合は、記事を確認してください
ただし、簡単にするために、コンストラクターに依存関係として挿入するだけです。
また、タイマー間隔を 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 の代わりに注入しよう」と誰かが叫んでいるのが聞こえます。
はい、そうですが、それほど単純ではありません。次のセクションで説明するいくつかのトリックがあります。
これは良い解決策の時です。それについて何ができるか見てみましょう。
ソリューションの構造は次のようになります。
これは、新しいConsole BetterTimerAppプロジェクトを使用した同じUsingTimerソリューションです。
IConsole
、 IPublisher
、およびConsole
は同じです。
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
モジュールを単体テストで簡単にカバーできます。
カバレッジを計算すると、次のようになります。
ご覧のとおり、 Publisher
モジュールは 100% カバーされています。残りについては、これはこの記事の範囲外ですが、記事のアプローチに従えば簡単にカバーできます。
あなたはそれを行うことができます。大きなモジュールを小さなモジュールに分割し、抽象化を定義し、トリッキーな部分で創造的になれば、それで完了です。
もっと自分自身をトレーニングしたい場合は、いくつかのベスト プラクティスに関する私の他の記事を参照してください。
それだけです。この記事を読んで、私が書いたのと同じくらい面白いと思っていただければ幸いです。
こちらにも掲載