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.
É assim que vamos abordar nossa solução:
Crie um exemplo muito simples para trabalhar.
Comece com a solução ruim simples.
Continue tentando aprimorá-lo até chegarmos ao formato final.
Resumindo as lições aprendidas em nossa jornada.
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:
Como você pode ver, é simples em termos de requisitos, nada extravagante.
Algumas práticas recomendadas seriam ignoradas/eliminadas para direcionar o foco principal para as outras práticas recomendadas abordadas neste artigo.
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
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.
Nesta solução, usaríamos diretamente System.Timers.Timer sem fornecer uma camada de abstração.
A estrutura da solução deve ficar assim:
É 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
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:
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.
Este é o momento para uma boa solução. Vamos ver o que podemos fazer sobre isso.
A estrutura da solução deve ficar assim:
É a mesma solução UsingTimer com um novo projeto Console BetterTimerApp .
IConsole
, IPublisher
e Console
seriam os mesmos.
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:
Definimos o novo delegado TimerIntervalElapsedEventHandler
. Este delegado representa o evento a ser gerado pelo nosso ITimer
.
Você pode argumentar que não precisamos desse novo delegado, pois já temos o ElapsedEventHandler
nativo que já é usado por System.Timers.Timer
.
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.
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.
Além disso, observe que ITimer
estende o 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); } } }
É 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.
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:
Internamente, estamos usando System.Timers.Timer
.
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()
.
No construtor, estamos inicializando uma nova instância de System.Timers.Timer
. Nos referiremos a isso como o Timer Interno no restante das etapas.
Para public bool Enabled
, public double Interval
, public void Start()
e public void Stop()
, estamos apenas delegando a implementação ao Internal Timer.
Para public event TimerIntervalElapsedEventHandler TimerIntervalElapsed
, esta é a parte mais importante; então vamos analisá-lo passo a passo.
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.
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 }
.
Neste momento, o que devemos fazer internamente é algo como m_Timer.Elapsed += (sender, elapsedEventArgs) => { //do something }
.
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
.
Portanto, o que precisamos fazer é agrupar a entrada TimerIntervalElapsedEventHandler
em um novo ElapsedEventHandler
interno. Isso é algo que podemos fazer.
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
.
Isso significa que, neste momento, precisamos saber qual handler ElapsedEventHandler
corresponde a esse handler TimerIntervalElapsedEventHandler
para que possamos cancelá-lo no Timer Interno.
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.
No entanto, também precisamos ter em mente que, de fora, alguém pode assinar o mesmo manipulador TimerIntervalElapsedEventHandler
mais de uma vez.
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
.
Na maioria dos casos, esta lista teria apenas uma entrada, a menos que no caso de uma assinatura duplicada.
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
.
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:
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:
Estou usando NUnit e Moq para testes. Você pode com certeza trabalhar com suas bibliotecas preferidas.
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:
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.
Além disso, definimos a classe ActionLog
a ser usada para registro.
Definimos a classe TimerStub
como um stub de ITimer
. Nós usaríamos este stub mais tarde ao testar o módulo Publisher
.
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.
Também podemos passar o valor esperado de dateTime
para que tenhamos um valor conhecido para afirmar.
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:
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
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