Lorsque vous utilisez System.Timers.Timer dans votre application .NET C# , vous pouvez rencontrer des problèmes pour l'abstraire et pouvoir couvrir vos modules avec des tests unitaires.
Dans cet article, nous discuterons des meilleures pratiques pour relever ces défis et, à la fin, vous pourrez atteindre une couverture à 100% de vos modules.
Voici comment nous allons aborder notre solution :
Trouvez un exemple très simple sur lequel travailler.
Commencez par la simple mauvaise solution.
Continuez à essayer de l'améliorer jusqu'à ce que nous atteignions le format final.
Résumant les leçons apprises tout au long de notre voyage.
Dans notre exemple, nous allons construire une application console simple qui ne ferait qu'une chose simple : utiliser un System.Timers.Timer
pour écrire sur la console la date et l'heure toutes les secondes .
Au final, vous devriez vous retrouver avec ceci :
Comme vous pouvez le voir, c'est simple en termes d'exigences, rien d'extraordinaire.
Certaines meilleures pratiques seraient ignorées/abandonnées afin de se concentrer sur les autres meilleures pratiques ciblées dans cet article.
Dans cet article, nous nous concentrerons sur la couverture du module utilisant System.Timers.Timer avec des tests unitaires. Cependant, le reste de la solution ne serait pas couvert de tests unitaires. Si vous souhaitez en savoir plus à ce sujet, vous pouvez consulter l'article
Certaines bibliothèques tierces pourraient être utilisées pour obtenir des résultats presque similaires. Cependant, dans la mesure du possible, je préfère suivre une conception simple native plutôt que de dépendre d'une grande bibliothèque tierce.
Dans cette solution, nous utiliserions directement System.Timers.Timer sans fournir de couche d'abstraction.
La structure de la solution devrait ressembler à ceci :
Il s'agit d'une solution UsingTimer avec un seul projet Console TimerApp .
J'ai intentionnellement investi du temps et des efforts dans l'abstraction System.Console
dans IConsole
pour prouver que cela ne résoudrait pas notre problème avec le Timer.
namespace TimerApp.Abstractions { public interface IConsole { void WriteLine(object? value); } }
Nous aurions seulement besoin d'utiliser System.Console.WriteLine
dans notre exemple ; c'est pourquoi c'est la seule méthode abstraite.
namespace TimerApp.Abstractions { public interface IPublisher { void StartPublishing(); void StopPublishing(); } }
Nous n'avons que deux méthodes sur l'interface IPublisher
: StartPublishing
et StopPublishing
.
Maintenant, pour les implémentations :
using TimerApp.Abstractions; namespace TimerApp.Implementations { public class Console : IConsole { public void WriteLine(object? value) { System.Console.WriteLine(value); } } }
Console
n'est qu'un mince wrapper pour 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
est une implémentation simple de IPublisher
. Il utilise un System.Timers.Timer
et le configure simplement.
Il a la IConsole
définie comme une dépendance. Ce n'est pas une bonne pratique de mon point de vue. Si vous voulez comprendre ce que je veux dire, vous pouvez consulter l'article
Cependant, uniquement pour des raisons de simplicité, nous l'injecterions simplement en tant que dépendance dans le constructeur.
Nous définissons également l'intervalle du minuteur sur 1000 millisecondes (1 seconde) et configurons le gestionnaire pour qu'il écrive le Timer SignalTime
sur la 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(); } } }
Ici, dans la classe Program
, on ne fait pas grand-chose. Nous créons juste une instance de la classe Publisher
et commençons la publication.
L'exécution de ceci devrait se terminer par quelque chose comme ceci :
Maintenant, la question est, si vous allez écrire un test unitaire pour la classe Publisher
, que pouvez-vous faire ?
Malheureusement, la réponse serait : pas trop .
Tout d'abord, vous n'injectez pas le Timer lui-même en tant que dépendance. Cela signifie que vous masquez la dépendance à l'intérieur de la classe Publisher
. Par conséquent, nous ne pouvons pas nous moquer ou écraser le minuteur.
Deuxièmement, disons que nous avons modifié le code pour que le Timer soit maintenant injecté dans le constructeur ; encore, la question serait, comment écrire un test unitaire et remplacer le Timer par un mock ou un stub ?
J'entends quelqu'un crier, "Enveloppons le Timer dans une abstraction et injectons-le à la place du Timer."
Oui, c'est vrai, cependant, ce n'est pas si simple. Il y a quelques astuces que je vais expliquer dans la section suivante.
C'est le moment d'une bonne solution. Voyons ce que nous pouvons faire à ce sujet.
La structure de la solution devrait ressembler à ceci :
Il s'agit de la même solution UsingTimer avec un nouveau projet Console BetterTimerApp .
IConsole
, IPublisher
et Console
seraient les mêmes.
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(); } }
Ce que l'on peut remarquer ici :
Nous avons défini le nouveau délégué TimerIntervalElapsedEventHandler
. Ce délégué représente l'événement à déclencher par notre ITimer
.
Vous pourriez dire que nous n'avons pas besoin de ce nouveau délégué car nous avons déjà le ElapsedEventHandler
natif qui est déjà utilisé par System.Timers.Timer
.
Oui c'est vrai. Cependant, vous remarquerez que l'événement ElapsedEventHandler
fournit ElapsedEventArgs
comme arguments d'événement. Cet ElapsedEventArgs
a un constructeur privé et vous ne pourrez pas créer votre propre instance. De plus, la propriété SignalTime
définie dans la classe ElapsedEventArgs
est en lecture seule. Par conséquent, vous ne pourrez pas le remplacer dans une classe enfant.
Un ticket de demande de modification a été ouvert pour que Microsoft mette à jour cette classe, mais jusqu'au moment de la rédaction de cet article, aucune modification n'a été appliquée.
Notez également que ITimer
étend le 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); } } }
C'est presque le même que l'ancien Publisher
, à quelques petites modifications près. Maintenant, nous avons le ITimer
défini comme une dépendance qui est injectée via le constructeur. Le reste du code serait le même.
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); } } }
C'est là que presque toute la magie se produit.
Ce que l'on peut remarquer ici :
En interne, nous utilisons System.Timers.Timer
.
Nous avons appliqué le design pattern IDisposable . C'est pourquoi vous pouvez voir le private bool m_IsDisposed
, public void Dispose()
, protected virtual void Dispose(bool disposing)
et ~Timer()
.
Dans le constructeur, nous initialisons une nouvelle instance de System.Timers.Timer
. Nous appellerions cela la minuterie interne dans le reste des étapes.
Pour public bool Enabled
, public double Interval
, public void Start()
et public void Stop()
, nous déléguons simplement l'implémentation au minuteur interne.
Pour public event TimerIntervalElapsedEventHandler TimerIntervalElapsed
, c'est la partie la plus importante ; alors analysons-le étape par étape.
Ce que nous devons faire avec cet événement est de gérer le moment où quelqu'un s'abonne/se désabonne de l'extérieur. Dans ce cas, nous voulons refléter cela sur la minuterie interne.
En d'autres termes, si quelqu'un de l'extérieur a une instance de notre ITimer
, il devrait pouvoir faire quelque chose comme ceci t.TimerIntervalElapsed += (sender, dateTime) => { //do something }
.
À ce moment, ce que nous devrions faire est de faire en interne quelque chose comme m_Timer.Elapsed += (sender, elapsedEventArgs) => { //do something }
.
Cependant, nous devons garder à l'esprit que les deux gestionnaires ne sont pas les mêmes car ils sont en fait de types différents ; TimerIntervalElapsedEventHandler
et ElapsedEventHandler
.
Par conséquent, ce que nous devons faire est d'envelopper l'arrivée de TimerIntervalElapsedEventHandler
dans un nouveau ElapsedEventHandler
interne. C'est quelque chose que nous pouvons faire.
Cependant, nous devons également garder à l'esprit qu'à un moment donné, quelqu'un pourrait avoir besoin de désabonner un gestionnaire de l'événement TimerIntervalElapsedEventHandler
.
Cela signifie qu'à ce moment, nous devons être en mesure de savoir quel gestionnaire ElapsedEventHandler
correspond à ce gestionnaire TimerIntervalElapsedEventHandler
afin que nous puissions le désinscrire du minuteur interne.
La seule façon d'y parvenir est de suivre chaque gestionnaire TimerIntervalElapsedEventHandler
et le gestionnaire ElapsedEventHandler
nouvellement créé dans un dictionnaire. De cette façon, en connaissant le gestionnaire TimerIntervalElapsedEventHandler
passé, nous pouvons connaître le gestionnaire ElapsedEventHandler
correspondant.
Cependant, nous devons également garder à l'esprit que de l'extérieur, quelqu'un peut s'abonner plusieurs fois au même gestionnaire TimerIntervalElapsedEventHandler
.
Oui, ce n'est pas logique, mais tout de même, c'est faisable. Par conséquent, par souci d'exhaustivité, pour chaque gestionnaire TimerIntervalElapsedEventHandler
, nous conserverions une liste des gestionnaires ElapsedEventHandler
.
Dans la plupart des cas, cette liste n'aurait qu'une seule entrée, sauf en cas d'abonnement en double.
Et c'est pourquoi vous pouvez voir ce 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); } } }
Dans l' add
, nous créons un nouveau ElapsedEventHandler
, en ajoutant un enregistrement dans m_Handlers
le dictionnaire le mappant à TimerIntervalElapsedEventHandler
, et enfin en nous abonnant au minuteur interne.
Dans le remove
, nous obtenons la liste correspondante des gestionnaires ElapsedEventHandler
, en sélectionnant le dernier gestionnaire, en le désinscrivant du minuteur interne, en le supprimant de la liste et en supprimant toute l'entrée si la liste est vide.
Il convient également de mentionner l'implémentation 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; }
Nous désinscrivons tous les gestionnaires restants du minuteur interne, supprimons le minuteur interne et effaçons le dictionnaire 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(); } } }
Ici, on ne fait pas encore grand-chose. C'est presque la même que l'ancienne solution.
L'exécution de ceci devrait se terminer par quelque chose comme ceci :
Maintenant, nous avons notre conception finale. Cependant, nous devons voir si cette conception peut vraiment nous aider à couvrir notre module Publisher
avec des tests unitaires.
La structure de la solution devrait ressembler à ceci :
J'utilise NUnit et Moq pour les tests. Vous pouvez à coup sûr travailler avec vos bibliothèques préférées.
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; } } }
Ce que l'on peut remarquer ici :
Nous avons défini l'énumération Action
à utiliser lors de la journalisation des actions effectuées via notre stub Timer. Cela serait utilisé plus tard pour affirmer les actions internes effectuées.
De plus, nous avons défini la classe ActionLog
à utiliser pour la journalisation.
Nous avons défini la classe TimerStub
comme un stub de ITimer
. Nous utiliserons ce stub plus tard lors du test du module Publisher
.
La mise en œuvre est simple. Il convient de mentionner que nous avons ajouté une méthode public void TriggerTimerIntervalElapsed(DateTime dateTime)
afin que nous puissions déclencher le stub manuellement dans un test unitaire.
Nous pouvons également transmettre la valeur attendue de dateTime
afin d'avoir une valeur connue à affirmer.
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); } } }
Maintenant, comme vous pouvez le voir, nous avons le contrôle total et nous pouvons facilement couvrir notre module Publisher
avec des tests unitaires.
Si nous calculons la couverture, nous devrions obtenir ceci :
Comme vous pouvez le constater, le module Publisher
est couvert à 100 %. Pour le reste, cela sort du cadre de cet article, mais vous pouvez simplement le couvrir si vous suivez l'approche de l'article
Tu peux le faire. Il s'agit simplement de diviser de grands modules en plus petits, de définir vos abstractions, de faire preuve de créativité avec des parties délicates, et puis vous avez terminé.
Si vous souhaitez vous entraîner davantage, vous pouvez consulter mes autres articles sur certaines bonnes pratiques.
Voilà, j'espère que vous avez trouvé la lecture de cet article aussi intéressante que j'ai trouvé l'écrire.
Également publié ici