如何完全控制计时器并能够通过单元测试达到 100% 的覆盖率 在您的 应用程序中使用 时,您可能会遇到抽象它和能够使用单元测试覆盖您的模块的问题。 .NET C# System.Timers.Timer 在本文中,我们将讨论如何克服这些挑战的 ,到最后,您将能够实现 100% 的模块覆盖率。 最佳实践 该方法 这就是我们将如何处理我们的解决方案: 想出一个非常简单的例子来处理。 从简单的糟糕解决方案开始。 继续尝试增强它,直到我们达到最终格式。 总结我们在旅途中吸取的教训。 这个例子 在我们的示例中,我们将构建一个简单的 ,它只会做一件简单的事情: 将 写入控制台。 控制台应用程序 使用 System.Timers.Timer 每秒 日期和时间 最后,你应该得到这样的结果: 如您所见,它的要求很简单,没有什么花哨的。 免责声明 一些最佳实践将被忽略/删除,以便将主要焦点转移到本文中针对的其他最佳实践。 在本文中,我们将重点介绍使用 和单元测试的模块。但是,单元测试不会涵盖解决方案的其余部分。如果您想了解更多相关信息,可以查看文章 . System.Timers.Timer 如何使用单元测试全面覆盖 .NET C# 控制台应用程序 有一些第三方库可用于实现几乎相似的结果。但是,只要有可能,我宁愿遵循原生的简单设计,也不愿依赖整个大型第三方库。 糟糕的解决方案 在此解决方案中,我们将直接使用 而无需提供抽象层。 System.Timers.Timer 解决方案的结构应如下所示: 它是一个只有一个 项目的 解决方案。 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(); } } 我们在 接口上只有两个方法: 和 。 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 计时器 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 如果我们计算覆盖率,我们应该得到: 如您所见, 模块已被 100% 覆盖。对于其余部分,这超出了本文的范围,但如果您按照本文中的方法进行操作,则可以简单地涵盖它 . Publisher 如何使用单元测试全面覆盖 .NET C# 控制台应用程序 最后的话 你能行的。只需将大型模块拆分为较小的模块,定义抽象,用棘手的部分发挥创意,然后就大功告成了。 如果你想更多地训练自己,你可以查看我其他关于一些最佳实践的文章。 就是这样,希望您觉得阅读这篇文章和我写这篇文章一样有趣。 也发布 在这里