在您的.NET C#应用程序中使用System.Timers.Timer时,您可能会遇到抽象它和能够使用单元测试覆盖您的模块的问题。
在本文中,我们将讨论如何克服这些挑战的最佳实践,到最后,您将能够实现 100% 的模块覆盖率。
这就是我们将如何处理我们的解决方案:
想出一个非常简单的例子来处理。
从简单的糟糕解决方案开始。
继续尝试增强它,直到我们达到最终格式。
总结我们在旅途中吸取的教训。
在我们的示例中,我们将构建一个简单的控制台应用程序,它只会做一件简单的事情:使用System.Timers.Timer
每秒将日期和时间写入控制台。
最后,你应该得到这样的结果:
如您所见,它的要求很简单,没有什么花哨的。
一些最佳实践将被忽略/删除,以便将主要焦点转移到本文中针对的其他最佳实践。
在本文中,我们将重点介绍使用System.Timers.Timer和单元测试的模块。但是,单元测试不会涵盖解决方案的其余部分。如果您想了解更多相关信息,可以查看文章
有一些第三方库可用于实现几乎相似的结果。但是,只要有可能,我宁愿遵循原生的简单设计,也不愿依赖整个大型第三方库。
在此解决方案中,我们将直接使用System.Timers.Timer而无需提供抽象层。
解决方案的结构应如下所示:
它是一个只有一个Console TimerApp项目的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
。
现在,对于实现:
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
类编写单元测试,您可以做什么?
不幸的是,答案是:不要太多。
首先,您没有将 Timer 本身作为依赖项注入。这意味着您将依赖项隐藏在Publisher
类中。因此,我们不能模拟或存根计时器。
其次,假设我们修改了代码,现在将 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 }
。
但是,我们需要记住,这两个处理程序并不相同,因为它们实际上属于不同的类型; TimerIntervalElapsedEventHandler
和ElapsedEventHandler
。
因此,我们需要做的是将传入的TimerIntervalElapsedEventHandler
包装成一个新的内部ElapsedEventHandler
。这是我们可以做的。
但是,我们还需要记住,在某些时候,某人可能需要取消订阅TimerIntervalElapsedEventHandler
事件的处理程序。
这意味着,此时,我们需要能够知道哪个ElapsedEventHandler
处理程序对应于那个TimerIntervalElapsedEventHandler
处理程序,以便我们可以取消订阅内部计时器。
实现此目的的唯一方法是在字典中跟踪每个TimerIntervalElapsedEventHandler
处理程序和新创建的ElapsedEventHandler
处理程序。这样,通过知道传入的TimerIntervalElapsedEventHandler
处理程序,我们可以知道对应的ElapsedEventHandler
处理程序。
但是,我们还需要记住,从外部来看,有人可能会多次订阅同一个TimerIntervalElapsedEventHandler
处理程序。
是的,这不合逻辑,但仍然可行。因此,为了完整起见,对于每个TimerIntervalElapsedEventHandler
处理程序,我们将保留一个ElapsedEventHandler
处理程序列表。
在大多数情况下,除非重复订阅,否则此列表将只有一个条目。
这就是为什么你可以看到这个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
,在m_Handlers
中添加一条记录,将其映射到TimerIntervalElapsedEventHandler
字典,最后订阅内部计时器。
在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; } } }
我们可以在这里注意到:
我们定义了在记录通过我们的计时器存根执行的操作时要使用的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% 覆盖。对于其余部分,这超出了本文的范围,但如果您按照本文中的方法进行操作,则可以简单地涵盖它
你能行的。只需将大型模块拆分为较小的模块,定义抽象,用棘手的部分发挥创意,然后就大功告成了。
如果你想更多地训练自己,你可以查看我其他关于一些最佳实践的文章。
就是这样,希望您觉得阅读这篇文章和我写这篇文章一样有趣。
也发布在这里