Лучший способ использовать таймеры в .NET C#
31 марта 2023 г.Как получить полный контроль над таймером и добиться стопроцентного покрытия с помощью модульных тестов
При использовании System.Timers.Timer в приложении .NET C# могут возникнуть проблемы с его абстрагированием и возможностью охвата ваши модули с модульными тестами.
В этой статье мы обсудим рекомендации о том, как решить эти проблемы, и к концу вы сможете достичь 100% охвата ваших модулей.
Подход
Вот как мы собираемся подойти к нашему решению:
- Придумайте очень простой пример для работы.
2. Начните с простого плохого решения.
3. Продолжайте улучшать его, пока мы не достигнем окончательного формата.
4. Обобщение уроков, извлеченных в ходе нашего путешествия.
Пример
В нашем примере мы будем создавать простое консольное приложение, которое будет делать только одну простую вещь: использовать System.Timers.Timer
писать в консоль дату и время каждую секунду.
В итоге у вас должно получиться вот это:
Как видите, требования просты, ничего вычурного.
Отказ от ответственности
- Некоторые рекомендации будут проигнорированы/отброшены, чтобы сосредоточить основное внимание на других рекомендациях, рассматриваемых в этой статье.
2. В этой статье мы сосредоточимся на рассмотрении модуля, использующего System.Timers.Timer с модульными тестами. Однако остальная часть решения не будет покрыта модульными тестами. Если вы хотите узнать об этом больше, вы можете прочитать статью Как полностью покрыть консольное приложение .NET C# модульными тестами.
3. Есть несколько сторонних библиотек, которые можно использовать для достижения почти аналогичных результатов. Однако, когда это возможно, я предпочел бы следовать простому дизайну, а не полагаться на целую большую стороннюю библиотеку.
Плохое решение
В этом решении мы будем использовать System.Timers.Timer напрямую, не создавая уровень абстракции.
Структура решения должна выглядеть следующим образом:
Это решение UsingTimer с одним проектом Console TimerApp.
Я намеренно потратил некоторое время и усилия на абстрагирование 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
, определенный как зависимость. Это не лучшая практика с моей точки зрения. Если вы хотите понять, что я имею в виду, вы можете проверить статью Когда не следует использовать DI, IoC и контейнеры IoC в .NET C#.
Однако только для простоты мы просто внедрили бы его как зависимость в конструкторе.
Мы также устанавливаем интервал таймера на 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 макетом или заглушкой?
Я слышу, как кто-то кричит: «Давайте завернем Таймер в абстракцию и внедрим его вместо Таймера».
Да, верно, однако не все так просто. Есть несколько приемов, которые я объясню в следующем разделе.
Хорошее решение
Настало время найти хорошее решение. Давайте посмотрим, что мы можем с этим сделать.
Структура решения должна выглядеть следующим образом:
Это то же самое решение UsingTimer с новым проектом Console BetterTimerApp.
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
.
2. Вы можете возразить, что нам не нужен этот новый делегат, поскольку у нас уже есть собственный ElapsedEventHandler
, который уже используется System.Timers.Timer
.
3. Да, это правда. Однако вы заметите, что событие ElapsedEventHandler
предоставляет ElapsedEventArgs
в качестве аргументов события. Этот ElapsedEventArgs
имеет частный конструктор, и вы не сможете создать свой собственный экземпляр. Кроме того, свойство SignalTime
, определенное в классе ElapsedEventArgs
, доступно только для чтения. Поэтому вы не сможете переопределить его в дочернем классе.
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);
}
}
}
Именно здесь происходит почти все волшебство.
Что мы можем здесь заметить:
-
Внутри мы используем
System.Timers.Timer
.2. Мы применили шаблон проектирования IDisposable. Вот почему вы можете увидеть
private bool m_IsDisposed
,public void Dispose()
,защищенный виртуальный void Dispose(bool dispose)
и~Таймер()
.3. В конструкторе мы инициализируем новый экземпляр
System.Timers.Timer
. В остальных шагах мы будем называть его внутренним таймером.4. Для
public bool Enabled
,public double Interval
,public void Start()
иpublic void Stop()
, мы просто делегируем реализацию внутреннему таймеру.5. Для
открытого события TimerIntervalElapsedEventHandler TimerIntervalElapsed
это самая важная часть; так что давайте проанализируем это шаг за шагом.6. Что нам нужно сделать с этим событием, так это обработать, когда кто-то подпишется/отпишется на него извне. В данном случае мы хотим отразить это во внутреннем таймере.
7. Другими словами, если у кого-то извне есть экземпляр нашего
ITimer
, он должен иметь возможность сделать что-то вроде этогоt.TimerIntervalElapsed += (sender, dateTime) => { // сделать что-нибудь
.8. На данный момент нам следует сделать что-то вроде
m_Timer.Elapsed += (sender, elapsedEventArgs) => { // сделать что-нибудь
.9. Однако нам нужно помнить, что два обработчика не одинаковы, поскольку они на самом деле разных типов;
TimerIntervalElapsedEventHandler
иElapsedEventHandler
.10. Поэтому нам нужно обернуть поступающий
TimerIntervalElapsedEventHandler
в новый внутреннийElapsedEventHandler
. Это то, что мы можем сделать.11. Однако мы также должны помнить, что в какой-то момент кому-то может понадобиться отменить подписку на обработчик события
TimerIntervalElapsedEventHandler
.12. Это означает, что в данный момент нам нужно знать, какой обработчик
ElapsedEventHandler
соответствует этому обработчикуTimerIntervalElapsedEventHandler
, чтобы мы могли отменить его подписку на внутреннем таймере.13. Единственный способ добиться этого — отслеживать каждый обработчик
TimerIntervalElapsedEventHandler
и вновь созданный обработчикElapsedEventHandler
в словаре. Таким образом, зная переданный обработчикTimerIntervalElapsedEventHandler
, мы можем узнать соответствующий обработчикElapsedEventHandler
.14. Однако мы также должны помнить, что кто-то извне может подписаться на один и тот же обработчик
TimerIntervalElapsedEventHandler
более одного раза.15. Да, это не логично, но все же выполнимо. Поэтому для полноты картины для каждого обработчика
TimerIntervalElapsedEventHandler
мы будем вести список обработчиковElapsedEventHandler
.16. В большинстве случаев в этом списке будет только одна запись, за исключением случаев дублирования подписки.
17. Вот почему вы можете видеть этот
private Dictionary<TimerIntervalElapsedEventHandler, List<ElapsedEventHandler>> m_Handlers = новый();
.
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
, которое будет использоваться при регистрации действий, выполняемых с помощью нашей заглушки Timer. Это будет использоваться позже для утверждения выполненных внутренних действий.
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# модульными тестами.
Заключительные слова
Вы можете это сделать. Нужно просто разбить большие модули на более мелкие, определить свои абстракции, проявить творческий подход к сложным частям, и все готово.
Если вы хотите больше тренироваться, ознакомьтесь с другими моими статьями о некоторых передовых методах.
Вот и все, надеюсь, вам было так же интересно читать эту статью, как мне было ее писать.
Также опубликовано здесь
Оригинал