paint-brush
La mejor manera de usar temporizadores en .NET C#por@ahmedtarekhasan
5,927 lecturas
5,927 lecturas

La mejor manera de usar temporizadores en .NET C#

por Ahmed Tarek Hasan30m2023/03/29
Read on Terminal Reader

Demasiado Largo; Para Leer

Al usar System.Timers.Timer en su aplicación C#, es posible que tenga problemas para abstraerlo y poder cubrir sus módulos con pruebas unitarias. En este artículo, estaríamos discutiendo las mejores prácticas sobre cómo conquistar estos desafíos. Al final, podrá lograr una cobertura del 100 % de sus módulos.
featured image - La mejor manera de usar temporizadores en .NET C#
Ahmed Tarek Hasan HackerNoon profile picture

Cómo tener control total sobre el temporizador y poder alcanzar el 100 % de cobertura con pruebas unitarias

Al usar System.Timers.Timer en su aplicación .NET C# , es posible que tenga problemas para abstraerlo y poder cubrir sus módulos con pruebas unitarias.


En este artículo, discutiremos las mejores prácticas sobre cómo conquistar estos desafíos y, al final, podrá lograr una cobertura del 100 % de sus módulos.


Foto de Lina Trochez en Unsplash

El enfoque

Así es como vamos a abordar nuestra solución:


  1. Piensa en un ejemplo muy simple para trabajar.


  2. Comience con la mala solución simple.


  3. Sigue intentando mejorarlo hasta que alcancemos el formato final.


  4. Resumiendo las lecciones aprendidas a lo largo de nuestro viaje.


Foto de James Harrison en Unsplash

El ejemplo

En nuestro ejemplo, crearemos una aplicación de consola simple que solo haría una cosa simple: usar System.Timers.Timer para escribir en la consola la fecha y la hora cada segundo .


Al final, deberías terminar con esto:


Imagen de Ahmed Tarek


Como puede ver, es simple en términos de requisitos, nada lujoso.


Foto de Mikael Seegen en Unsplash, ajustada por Ahmed Tarek

Descargo de responsabilidad

  1. Algunas prácticas recomendadas se ignorarían o eliminarían para centrar la atención principal en las otras prácticas recomendadas que se abordan en este artículo.


  2. En este artículo, nos centraremos en cubrir el módulo usando System.Timers.Timer con pruebas unitarias. Sin embargo, el resto de la solución no se cubriría con pruebas unitarias. Si quieres saber más sobre esto, puedes consultar el artículo Cómo cubrir completamente la aplicación de consola .NET C# con pruebas unitarias .


  3. Hay algunas bibliotecas de terceros que podrían usarse para lograr resultados casi similares. Sin embargo, siempre que sea posible, prefiero seguir un diseño nativo simple que depender de una gran biblioteca de terceros.


Foto de Maria Teneva en Unsplash, ajustada por Ahmed Tarek

Mala solución

En esta solución, usaríamos directamente System.Timers.Timer sin proporcionar una capa de abstracción.


La estructura de la solución debería verse así:


Imagen de Ahmed Tarek


Es una solución de UsingTimer con solo un proyecto Console TimerApp .


Deliberadamente invertí algo de tiempo y esfuerzo en abstraer System.Console en IConsole para demostrar que esto no resolvería nuestro problema con el temporizador.


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


Solo necesitaríamos usar System.Console.WriteLine en nuestro ejemplo; es por eso que este es el único método abstracto.


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


Solo tenemos dos métodos en la interfaz IPublisher : StartPublishing y StopPublishing .


Ahora, para las implementaciones:


 using TimerApp.Abstractions; namespace TimerApp.Implementations { public class Console : IConsole { public void WriteLine(object? value) { System.Console.WriteLine(value); } } }


Console es solo una envoltura delgada para 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 es una implementación simple de IPublisher . Está usando un System.Timers.Timer y simplemente configurándolo.


Tiene la IConsole definida como una dependencia. Esto no es una buena práctica desde mi punto de vista. Si quieres entender a lo que me refiero, puedes consultar el artículo Cuándo no usar contenedores DI, IoC e IoC en .NET C# .


Sin embargo, solo por simplicidad, simplemente lo inyectaríamos como una dependencia en el constructor.


También estamos configurando el intervalo del temporizador en 1000 milisegundos (1 segundo) y configurando el controlador para escribir Timer SignalTime en la consola.


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


Aquí, en la clase Program , no estamos haciendo mucho. Solo estamos creando una instancia de la clase Publisher y comenzando a publicar.


Ejecutar esto debería terminar con algo como esto:


Imagen de Ahmed Tarek


Ahora, la pregunta es, si va a escribir una prueba unitaria para la clase Publisher , ¿qué puede hacer?


Desafortunadamente, la respuesta sería: no demasiado .


Primero, no está inyectando el propio Timer como una dependencia. Esto significa que está ocultando la dependencia dentro de la clase Publisher . Por lo tanto, no podemos burlarnos o bloquear el temporizador.


En segundo lugar, digamos que modificamos el código para que el temporizador ahora se inyecte en el constructor; aún así, la pregunta sería, ¿cómo escribir una prueba unitaria y reemplazar el temporizador con un simulacro o un resguardo?


Escucho a alguien gritar, "envolvamos el Timer en una abstracción e inyectémoslo en lugar del Timer".


Sí, así es, sin embargo, no es tan simple. Hay algunos trucos que voy a explicar en la siguiente sección.


Foto de Carson Masterson en Unsplash, ajustada por Ahmed Tarek

Buena solución

Este es el momento de una buena solución. Veamos qué podemos hacer al respecto.


La estructura de la solución debería verse así:


Imagen de Ahmed Tarek


Es la misma solución de UsingTimer con un nuevo proyecto Console BetterTimerApp .


IConsole , IPublisher y Console serían lo mismo.

Temporizador

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


Lo que podemos notar aquí:


  1. Definimos el nuevo delegado TimerIntervalElapsedEventHandler . Este delegado representa el evento que generará nuestro ITimer .


  2. Puede argumentar que no necesitamos este nuevo delegado ya que tenemos el ElapsedEventHandler nativo que ya usa System.Timers.Timer .


  3. Sí, es cierto. Sin embargo, notará que el evento ElapsedEventHandler proporciona ElapsedEventArgs como argumentos del evento. Este ElapsedEventArgs tiene un constructor privado y no podría crear su propia instancia. Además, la propiedad SignalTime definida en la clase ElapsedEventArgs es de solo lectura. Por lo tanto, no podrá anularlo en una clase secundaria.


  4. Hay un ticket de solicitud de cambio abierto para que Microsoft actualice esta clase, pero hasta el momento de escribir este artículo, no se aplicaron cambios.


  5. Además, tenga en cuenta que ITimer extiende IDisposable .



Editor

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


Es casi igual que el antiguo Publisher excepto por pequeños cambios. Ahora, tenemos el ITimer definido como una dependencia que se inyecta a través del constructor. El resto del código sería el mismo.

Temporizador

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


Aquí es donde sucede casi toda la magia.


Lo que podemos notar aquí:


  1. Internamente, estamos usando System.Timers.Timer .


  2. Aplicamos el patrón de diseño IDisposable . Es por eso que puede ver el private bool m_IsDisposed , public void Dispose() , protected virtual void Dispose(bool disposing) y ~Timer() .


  3. En el constructor, estamos inicializando una nueva instancia de System.Timers.Timer . Nos referiremos a esto como el temporizador interno en el resto de los pasos.


  4. Para public bool Enabled , public double Interval , public void Start() y public void Stop() , solo estamos delegando la implementación al temporizador interno.


  5. Para public event TimerIntervalElapsedEventHandler TimerIntervalElapsed , esta es la parte más importante; así que vamos a analizarlo paso a paso.


  6. Lo que tenemos que hacer con este evento es controlar cuándo alguien se suscribe o cancela su suscripción desde el exterior. En este caso, queremos reflejar esto en el temporizador interno.


  7. En otras palabras, si alguien externo tiene una instancia de nuestro ITimer , debería poder hacer algo como esto t.TimerIntervalElapsed += (sender, dateTime) => { //do something } .


  8. En este momento, lo que deberíamos hacer internamente es hacer algo como m_Timer.Elapsed += (sender, elapsedEventArgs) => { //do something } .


  9. Sin embargo, debemos tener en cuenta que los dos controladores no son iguales, ya que en realidad son de diferentes tipos; TimerIntervalElapsedEventHandler y ElapsedEventHandler .


  10. Por lo tanto, lo que debemos hacer es envolver el TimerIntervalElapsedEventHandler entrante en un nuevo ElapsedEventHandler interno. Esto es algo que podemos hacer.


  11. Sin embargo, también debemos tener en cuenta que, en algún momento, es posible que alguien deba cancelar la suscripción de un controlador del evento TimerIntervalElapsedEventHandler .


  12. Esto quiere decir que, en este momento, necesitamos poder saber qué manejador ElapsedEventHandler corresponde a ese manejador TimerIntervalElapsedEventHandler para poder darlo de baja del Temporizador Interno.


  13. La única forma de lograr esto es realizar un seguimiento de cada controlador TimerIntervalElapsedEventHandler y el controlador ElapsedEventHandler recién creado en un diccionario. De esta manera, al conocer el controlador TimerIntervalElapsedEventHandler pasado, podemos conocer el controlador ElapsedEventHandler correspondiente.


  14. Sin embargo, también debemos tener en cuenta que desde el exterior, alguien puede suscribirse al mismo controlador TimerIntervalElapsedEventHandler más de una vez.


  15. Sí, esto no es lógico, pero aun así, es factible. Por lo tanto, en aras de la exhaustividad, para cada controlador TimerIntervalElapsedEventHandler , mantendríamos una lista de controladores ElapsedEventHandler .


  16. En la mayoría de los casos, esta lista tendría una sola entrada a menos que se trate de una suscripción duplicada.


  17. Y es por eso que puede ver este 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); } } }


En el add , estamos creando un nuevo ElapsedEventHandler , agregando un registro en m_Handlers , el diccionario lo asigna a TimerIntervalElapsedEventHandler y, finalmente, suscribiéndonos al temporizador interno.


En remove , obtenemos la lista correspondiente de controladores ElapsedEventHandler , seleccionamos el último controlador, cancelamos la suscripción del temporizador interno, lo eliminamos de la lista y eliminamos la entrada completa si la lista está vacía.


También vale la pena mencionar la implementación 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; }


Estamos cancelando la suscripción de todos los controladores restantes del temporizador interno, eliminando el temporizador interno y borrando el diccionario m_Handlers .

Programa

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


Aquí, todavía no estamos haciendo mucho. Es casi lo mismo que la solución anterior.


Ejecutar esto debería terminar con algo como esto:


Imagen de Ahmed Tarek


Foto de Testalize.me en Unsplash, ajustada por Ahmed Tarek

Tiempo de prueba, el momento de la verdad

Ahora, tenemos nuestro diseño final. Sin embargo, necesitamos ver si este diseño realmente puede ayudarnos a cubrir nuestro módulo Publisher con pruebas unitarias.


La estructura de la solución debería verse así:


Imagen de Ahmed Tarek


Estoy usando NUnit y Moq para probar. Seguro que puede trabajar con sus bibliotecas preferidas.

Temporizador

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


Lo que podemos notar aquí:


  1. Definimos la enumeración Action que se usará al registrar las acciones realizadas a través de nuestro resguardo del temporizador. Esto se utilizaría más tarde para afirmar las acciones internas realizadas.


  2. Además, definimos la clase ActionLog que se utilizará para el registro.


  3. Definimos la clase TimerStub como un stub de ITimer . Usaríamos este código auxiliar más tarde al probar el módulo Publisher .


  4. La implementación es sencilla. Vale la pena mencionar que agregamos un método public void TriggerTimerIntervalElapsed(DateTime dateTime) para que podamos activar el código auxiliar manualmente dentro de una prueba unitaria.


  5. También podemos pasar el valor esperado de dateTime para que tengamos un valor conocido contra el cual afirmar.

PublisherTests

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


Ahora, como puede ver, tenemos el control total y podemos cubrir fácilmente nuestro módulo Publisher con pruebas unitarias.


Si calculamos la cobertura, deberíamos obtener esto:


Imagen de Ahmed Tarek


Como puede ver, el módulo Publisher está 100% cubierto. Por lo demás, esto está fuera del alcance de este artículo, pero simplemente puede cubrirlo si sigue el enfoque del artículo. Cómo cubrir completamente la aplicación de consola .NET C# con pruebas unitarias .


Foto de Jingda Chen en Unsplash, ajustada por Ahmed Tarek

Ultimas palabras

Puedes hacerlo. Es solo una cuestión de dividir módulos grandes en otros más pequeños, definir sus abstracciones, ser creativo con partes complicadas, y luego ya está.


Si desea capacitarse más, puede consultar mis otros artículos sobre algunas mejores prácticas.


Eso es todo, espero que hayas encontrado la lectura de este artículo tan interesante como yo encontré al escribirlo.


También publicado aquí