paint-brush
A melhor maneira de usar temporizadores em .NET C#por@ahmedtarekhasan
4,376 leituras
4,376 leituras

A melhor maneira de usar temporizadores em .NET C#

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

Muito longo; Para ler

Usando System.Timers.Timer em seu aplicativo C#, você pode enfrentar problemas para abstraí-lo e conseguir cobrir seus módulos com testes de unidade. Neste artigo, estaríamos discutindo as melhores práticas sobre como vencer esses desafios. No final, você será capaz de atingir 100% de cobertura de seus módulos.
featured image - A melhor maneira de usar temporizadores em .NET C#
Ahmed Tarek Hasan HackerNoon profile picture

Como ter controle total sobre o cronômetro e ser capaz de atingir 100% de cobertura com testes de unidade

Ao usar System.Timers.Timer em seu aplicativo .NET C# , você pode ter problemas para abstraí-lo e conseguir cobrir seus módulos com testes de unidade.


Neste artigo, estaríamos discutindo as melhores práticas para vencer esses desafios e, ao final, você conseguiria atingir 100% de cobertura de seus módulos.


Foto de Lina Trochez no Unsplash

A abordagem

É assim que vamos abordar nossa solução:


  1. Crie um exemplo muito simples para trabalhar.


  2. Comece com a solução ruim simples.


  3. Continue tentando aprimorá-lo até chegarmos ao formato final.


  4. Resumindo as lições aprendidas em nossa jornada.


Foto de James Harrison no Unsplash

O exemplo

Em nosso exemplo, criaremos um aplicativo de console simples que faria apenas uma coisa simples: usar um System.Timers.Timer para gravar no console a data e a hora a cada segundo .


No final, você deve terminar com isso:


Imagem de Ahmed Tarek


Como você pode ver, é simples em termos de requisitos, nada extravagante.


Foto de Mikael Seegen no Unsplash, ajustada por Ahmed Tarek

Isenção de responsabilidade

  1. Algumas práticas recomendadas seriam ignoradas/eliminadas para direcionar o foco principal para as outras práticas recomendadas abordadas neste artigo.


  2. Neste artigo, vamos nos concentrar em cobrir o módulo usando System.Timers.Timer com testes de unidade. No entanto, o restante da solução não seria coberto por testes de unidade. Se você gostaria de saber mais sobre isso, você pode verificar o artigo Como cobrir totalmente o aplicativo de console .NET C# com testes de unidade .


  3. Existem algumas bibliotecas de terceiros que podem ser usadas para obter resultados quase semelhantes. No entanto, sempre que possível, prefiro seguir um design nativo simples do que depender de toda uma grande biblioteca de terceiros.


Foto de Maria Teneva no Unsplash, ajustada por Ahmed Tarek

Solução Ruim

Nesta solução, usaríamos diretamente System.Timers.Timer sem fornecer uma camada de abstração.


A estrutura da solução deve ficar assim:


Imagem de Ahmed Tarek


É uma solução UsingTimer com apenas um projeto Console TimerApp .


Eu intencionalmente investi algum tempo e esforço em abstrair System.Console em IConsole para provar que isso não resolveria nosso problema com o Timer.


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


Só precisaríamos usar System.Console.WriteLine em nosso exemplo; é por isso que este é o único método abstrato.


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


Temos apenas dois métodos na interface IPublisher : StartPublishing e StopPublishing .


Agora, para as implementações:


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


Console é apenas um wrapper fino 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 é uma implementação simples de IPublisher . Ele está usando um System.Timers.Timer e apenas configurando-o.


Tem o IConsole definido como uma dependência. Esta não é uma prática recomendada do meu ponto de vista. Se você quiser entender o que quero dizer, você pode verificar o artigo Quando não usar contêineres DI, IoC e IoC no .NET C# .


No entanto, apenas por uma questão de simplicidade, apenas o injetaríamos como uma dependência no construtor.


Também estamos definindo o intervalo do Timer para 1000 milissegundos (1 segundo) e definindo o manipulador para gravar o Timer SignalTime no console.


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


Aqui, na aula Program , não estamos fazendo muita coisa. Estamos apenas criando uma instância da classe Publisher e iniciando a publicação.


Executar isso deve terminar com algo assim:


Imagem de Ahmed Tarek


Agora, a questão é: se você for escrever um teste de unidade para a classe Publisher , o que poderá fazer?


Infelizmente, a resposta seria: não muito .


Primeiro, você não está injetando o próprio Timer como uma dependência. Isso significa que você está ocultando a dependência dentro da classe Publisher . Portanto, não podemos zombar ou interromper o Timer.


Em segundo lugar, digamos que modificamos o código para que o Timer agora seja injetado no construtor; ainda assim, a pergunta seria: como escrever um teste de unidade e substituir o Timer por um mock ou stub?


Ouço alguém gritando: “vamos envolver o Timer em uma abstração e injetá-lo em vez do Timer”.


Sim, isso mesmo, porém, não é tão simples assim. Existem alguns truques que explicarei na próxima seção.


Foto de Carson Masterson no Unsplash, ajustada por Ahmed Tarek

Boa solução

Este é o momento para uma boa solução. Vamos ver o que podemos fazer sobre isso.


A estrutura da solução deve ficar assim:


Imagem de Ahmed Tarek


É a mesma solução UsingTimer com um novo projeto Console BetterTimerApp .


IConsole , IPublisher e Console seriam os mesmos.

ITimer

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


O que podemos notar aqui:


  1. Definimos o novo delegado TimerIntervalElapsedEventHandler . Este delegado representa o evento a ser gerado pelo nosso ITimer .


  2. Você pode argumentar que não precisamos desse novo delegado, pois já temos o ElapsedEventHandler nativo que já é usado por System.Timers.Timer .


  3. Sim isso é verdade. No entanto, você notaria que o evento ElapsedEventHandler está fornecendo ElapsedEventArgs como os argumentos do evento. Este ElapsedEventArgs tem um construtor privado e você não poderá criar sua própria instância. Além disso, a propriedade SignalTime definida na classe ElapsedEventArgs é somente leitura. Portanto, você não seria capaz de substituí-lo em uma classe filha.


  4. Há um ticket de solicitação de alteração aberto para a Microsoft atualizar esta classe, mas até o momento da redação deste artigo, nenhuma alteração foi aplicada.


  5. Além disso, observe que ITimer estende o 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); } } }


É quase o mesmo que o antigo Publisher , exceto por pequenas alterações. Agora, temos o ITimer definido como uma dependência que é injetada através do construtor. O restante do código seria o mesmo.

Cronômetro

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


É aqui que quase toda a mágica acontece.


O que podemos notar aqui:


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


  2. Aplicamos o padrão de design IDisposable . É por isso que você pode ver o private bool m_IsDisposed , public void Dispose() , protected virtual void Dispose(bool disposing) e ~Timer() .


  3. No construtor, estamos inicializando uma nova instância de System.Timers.Timer . Nos referiremos a isso como o Timer Interno no restante das etapas.


  4. Para public bool Enabled , public double Interval , public void Start() e public void Stop() , estamos apenas delegando a implementação ao Internal Timer.


  5. Para public event TimerIntervalElapsedEventHandler TimerIntervalElapsed , esta é a parte mais importante; então vamos analisá-lo passo a passo.


  6. O que precisamos fazer com este evento é lidar com quando alguém se inscreve/desinscreve de fora. Neste caso, queremos espelhar isso no Timer Interno.


  7. Em outras palavras, se alguém de fora está tendo uma instância do nosso ITimer , ele deve ser capaz de fazer algo assim t.TimerIntervalElapsed += (sender, dateTime) => { //do something } .


  8. Neste momento, o que devemos fazer internamente é algo como m_Timer.Elapsed += (sender, elapsedEventArgs) => { //do something } .


  9. No entanto, precisamos ter em mente que os dois manipuladores não são iguais, pois na verdade são de tipos diferentes; TimerIntervalElapsedEventHandler e ElapsedEventHandler .


  10. Portanto, o que precisamos fazer é agrupar a entrada TimerIntervalElapsedEventHandler em um novo ElapsedEventHandler interno. Isso é algo que podemos fazer.


  11. No entanto, também precisamos ter em mente que, em algum momento, alguém pode precisar cancelar a assinatura de um manipulador do evento TimerIntervalElapsedEventHandler .


  12. Isso significa que, neste momento, precisamos saber qual handler ElapsedEventHandler corresponde a esse handler TimerIntervalElapsedEventHandler para que possamos cancelá-lo no Timer Interno.


  13. A única maneira de conseguir isso é acompanhando cada manipulador TimerIntervalElapsedEventHandler e o manipulador ElapsedEventHandler recém-criado em um dicionário. Dessa forma, conhecendo o manipulador TimerIntervalElapsedEventHandler passado, podemos conhecer o manipulador ElapsedEventHandler correspondente.


  14. No entanto, também precisamos ter em mente que, de fora, alguém pode assinar o mesmo manipulador TimerIntervalElapsedEventHandler mais de uma vez.


  15. Sim, isso não é lógico, mas ainda assim é factível. Portanto, para fins de integridade, para cada manipulador TimerIntervalElapsedEventHandler , manteríamos uma lista de manipuladores ElapsedEventHandler .


  16. Na maioria dos casos, esta lista teria apenas uma entrada, a menos que no caso de uma assinatura duplicada.


  17. E é por isso que você pode 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); } } }


No add , estamos criando um novo ElapsedEventHandler , adicionando um registro em m_Handlers , o dicionário mapeando isso para TimerIntervalElapsedEventHandler e, finalmente, inscrevendo-se no Timer interno.


No remove , estamos obtendo a lista correspondente de manipuladores ElapsedEventHandler , selecionando o último manipulador, cancelando sua inscrição no Timer Interno, removendo-o da lista e removendo toda a entrada se a lista estiver vazia.


Também vale a pena mencionar a implementação 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 a assinatura de todos os manipuladores restantes do Internal Timer, descartando o Internal Timer e limpando o dicionário 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(); } } }


Aqui, ainda não estamos fazendo muito. É quase o mesmo que a solução antiga.


Executar isso deve terminar com algo assim:


Imagem de Ahmed Tarek


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

Hora do teste, a hora da verdade

Agora, temos nosso design final. No entanto, precisamos ver se esse design realmente pode nos ajudar a cobrir nosso módulo Publisher com testes de unidade.


A estrutura da solução deve ficar assim:


Imagem de Ahmed Tarek


Estou usando NUnit e Moq para testes. Você pode com certeza trabalhar com suas bibliotecas preferidas.

TimerStub

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


O que podemos notar aqui:


  1. Definimos a enumeração Action a ser usada ao registrar as ações executadas por meio de nosso stub Timer. Isso seria usado posteriormente para afirmar as ações internas executadas.


  2. Além disso, definimos a classe ActionLog a ser usada para registro.


  3. Definimos a classe TimerStub como um stub de ITimer . Nós usaríamos este stub mais tarde ao testar o módulo Publisher .


  4. A implementação é simples. Vale a pena mencionar que adicionamos um método public void TriggerTimerIntervalElapsed(DateTime dateTime) extra para que possamos acionar o stub manualmente em um teste de unidade.


  5. Também podemos passar o valor esperado de dateTime para que tenhamos um valor conhecido para 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); } } }


Agora, como você pode ver, temos controle total e podemos cobrir facilmente nosso módulo Publisher com testes de unidade.


Se calcularmos a cobertura, devemos obter isto:


Imagem de Ahmed Tarek


Como você pode ver, o módulo Publisher está 100% coberto. Quanto ao resto, isso está fora do escopo deste artigo, mas você pode simplesmente abordá-lo se seguir a abordagem do artigo Como cobrir totalmente o aplicativo de console .NET C# com testes de unidade .


Foto de Jingda Chen no Unsplash, ajustada por Ahmed Tarek

Palavras Finais

Você consegue. É apenas uma questão de dividir módulos grandes em módulos menores, definir suas abstrações, ser criativo com partes complicadas e pronto.


Se você quiser se treinar mais, pode conferir meus outros artigos sobre algumas práticas recomendadas.


É isso, espero que você tenha achado a leitura deste artigo tão interessante quanto eu achei ao escrevê-lo.


Também publicado aqui