Comment avoir un contrôle total sur la minuterie et être capable d'atteindre une couverture de 100 % avec les tests unitaires
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.
L'approche
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.
L'exemple
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.
Clause de non-responsabilité
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
Comment couvrir entièrement l'application console .NET C # avec des tests unitaires .
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.
Mauvaise solution
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.
Bonne solution
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.
Minuteur
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 notreITimer
.
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é parSystem.Timers.Timer
.
Oui c'est vrai. Cependant, vous remarquerez que l'événement
ElapsedEventHandler
fournitElapsedEventArgs
comme arguments d'événement. CetElapsedEventArgs
a un constructeur privé et vous ne pourrez pas créer votre propre instance. De plus, la propriétéSignalTime
définie dans la classeElapsedEventArgs
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 leIDisposable
.
Éditeur
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.
Minuteur
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()
etpublic 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 cecit.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
etElapsedEventHandler
.
Par conséquent, ce que nous devons faire est d'envelopper l'arrivée de
TimerIntervalElapsedEventHandler
dans un nouveauElapsedEventHandler
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 gestionnaireTimerIntervalElapsedEventHandler
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 gestionnaireElapsedEventHandler
nouvellement créé dans un dictionnaire. De cette façon, en connaissant le gestionnaireTimerIntervalElapsedEventHandler
passé, nous pouvons connaître le gestionnaireElapsedEventHandler
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 gestionnairesElapsedEventHandler
.
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
.
Programme
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 :
Le temps des tests, le moment de vérité
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.
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; } } }
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 deITimer
. Nous utiliserons ce stub plus tard lors du test du modulePublisher
.
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.
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); } } }
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
Derniers mots
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