.NET C# uygulamanızda System.Timers.Timer'ı kullanırken, onu soyutlama ve modüllerinizi Birim Testleri ile kapsayabilme konusunda sorunlarla karşılaşabilirsiniz.
Bu makalede, bu zorlukların üstesinden nasıl gelebileceğinize dair En İyi Uygulamaları tartışacağız ve sonunda modüllerinizin %100 kapsamını elde edebileceksiniz.
Çözümümüze şu şekilde yaklaşacağız:
Üzerinde çalışmak için çok basit bir örnek bulun.
Basit kötü çözümle başlayın.
Nihai formata ulaşana kadar onu geliştirmeye devam edin.
Yolculuğumuz boyunca öğrenilen dersleri özetliyoruz.
Örneğimizde, yalnızca tek bir basit şeyi yapacak basit bir Konsol Uygulaması oluşturacağız: System.Timers.Timer
kullanarak konsola her saniye tarih ve saati yazın.
Sonunda şunu elde etmelisiniz:
Gördüğünüz gibi gereksinimler açısından basit, süslü bir şey değil.
Bu makalede hedeflenen diğer en iyi uygulamalara odaklanılması amacıyla bazı en iyi uygulamalar göz ardı edilecek/bırakılacaktır.
Bu yazıda System.Timers.Timer kullanan modülü birim testleriyle ele almaya odaklanacağız. Ancak çözümün geri kalanı birim testlerin kapsamına girmez. Bu konuda daha fazla bilgi edinmek istiyorsanız makaleye göz atabilirsiniz.
Neredeyse benzer sonuçlara ulaşmak için kullanılabilecek bazı üçüncü taraf kütüphaneler vardır. Bununla birlikte, mümkün olduğunda, büyük bir üçüncü taraf kütüphanesine bağlı kalmaktansa, yerel, basit bir tasarımı takip etmeyi tercih ederim.
Bu çözümde, bir soyutlama katmanı sağlamadan doğrudan System.Timers.Timer'ı kullanacağız.
Çözümün yapısı şöyle görünmelidir:
Yalnızca bir Konsol TimerApp projesine sahip bir TakingTimer çözümüdür.
Bunun Timer ile ilgili sorunumuzu çözmeyeceğini kanıtlamak için System.Console
IConsole
soyutlamak için kasıtlı olarak biraz zaman ve çaba harcadım.
namespace TimerApp.Abstractions { public interface IConsole { void WriteLine(object? value); } }
Örneğimizde yalnızca System.Console.WriteLine
kullanmamız gerekecek; bu yüzden soyutlanmış tek yöntem budur.
namespace TimerApp.Abstractions { public interface IPublisher { void StartPublishing(); void StopPublishing(); } }
IPublisher
arayüzünde yalnızca iki yöntemimiz var: StartPublishing
ve StopPublishing
.
Şimdi uygulamalara geçelim:
using TimerApp.Abstractions; namespace TimerApp.Implementations { public class Console : IConsole { public void WriteLine(object? value) { System.Console.WriteLine(value); } } }
Console
System.Console
için yalnızca ince bir sarmalayıcıdır.
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
IPublisher
basit bir uygulamasıdır. Bir System.Timers.Timer
kullanıyor ve sadece yapılandırıyor.
Bağımlılık olarak tanımlanan IConsole
sahiptir. Bu benim açımdan en iyi uygulama değil. Ne demek istediğimi anlamak istiyorsanız makaleye göz atabilirsiniz.
Ancak basitlik adına bunu yapıcıya bir bağımlılık olarak enjekte edeceğiz.
Ayrıca Timer aralığını 1000 Milisaniye (1 Saniye) olarak ayarlıyoruz ve işleyiciyi Timer SignalTime
değerini Konsola yazacak şekilde ayarlıyoruz.
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(); } } }
Burada, Program
dersinde pek bir şey yapmıyoruz. Sadece Publisher
sınıfının bir örneğini oluşturuyoruz ve yayınlamaya başlıyoruz.
Bunu çalıştırmak şöyle bir şeyle sonuçlanmalı:
Şimdi soru şu; Publisher
sınıfı için bir birim testi yazacaksanız ne yapabilirsiniz?
Ne yazık ki cevap şu olacaktır: çok fazla değil .
Öncelikle Timer'ın kendisini bir bağımlılık olarak enjekte etmiyorsunuz. Bu, bağımlılığı Publisher
sınıfının içinde sakladığınız anlamına gelir. Bu nedenle Zamanlayıcıyla dalga geçemeyiz veya onu saptıramayız.
İkinci olarak, Timer'ın artık yapıcıya enjekte edilmesini sağlayacak şekilde kodu değiştirdiğimizi varsayalım; yine de soru, bir birim testinin nasıl yazılacağı ve Zamanlayıcının sahte veya saplamayla nasıl değiştirileceği olacaktır.
Birinin "Zamanlayıcıyı bir soyutlamaya saralım ve Zamanlayıcı yerine onu enjekte edelim" diye bağırdığını duyuyorum.
Evet doğru ama bu o kadar basit değil. Bir sonraki bölümde açıklayacağım bazı püf noktaları var.
İyi bir çözümün zamanı geldi. Bu konuda ne yapabileceğimize bir bakalım.
Çözümün yapısı şöyle görünmelidir:
Yeni bir Console BetterTimerApp projesiyle aynı TakingTimer çözümüdür.
IConsole
, IPublisher
ve Console
aynı olacaktır.
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(); } }
Burada neyi fark edebiliriz:
Yeni temsilci TimerIntervalElapsedEventHandler
tanımladık. Bu delege ITimer
tarafından gündeme getirilecek olayı temsil ediyor.
Zaten System.Timers.Timer
tarafından kullanılan yerel ElapsedEventHandler
sahip olduğumuz için bu yeni temsilciye ihtiyacımız olmadığını iddia edebilirsiniz.
Evet bu doğru. Ancak ElapsedEventHandler
olayının, olay bağımsız değişkenleri olarak ElapsedEventArgs
sağladığını fark edeceksiniz. Bu ElapsedEventArgs
özel bir yapıcısı vardır ve kendi örneğinizi oluşturamazsınız. Ayrıca ElapsedEventArgs
sınıfında tanımlanan SignalTime
özelliği salt okunurdur. Bu nedenle, bunu bir alt sınıfta geçersiz kılamazsınız.
Microsoft'un bu sınıfı güncellemesi için açılmış bir değişiklik talebi bildirimi var ancak bu yazının yazıldığı ana kadar herhangi bir değişiklik uygulanmadı.
Ayrıca ITimer
IDisposable
genişlettiğini unutmayın.
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); } } }
Küçük değişiklikler dışında eski Publisher
ile neredeyse aynı. Artık yapıcı aracılığıyla enjekte edilen bir bağımlılık olarak tanımlanan ITimer
var. Kodun geri kalanı aynı olacaktır.
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); } } }
Burası neredeyse tüm sihrin gerçekleştiği yer.
Burada neyi fark edebiliriz:
Dahili olarak System.Timers.Timer
kullanıyoruz.
IDisposable tasarım desenini uyguladık. Bu nedenle private bool m_IsDisposed
, public void Dispose()
, protected virtual void Dispose(bool disposing)
ve ~Timer()
görebilirsiniz.
Yapıcıda, System.Timers.Timer
yeni bir örneğini başlatıyoruz. Geri kalan adımlarda buna Dahili Zamanlayıcı adını vereceğiz.
public bool Enabled
, public double Interval
, public void Start()
ve public void Stop()
için uygulamayı yalnızca Dahili Zamanlayıcıya devrediyoruz.
public event TimerIntervalElapsedEventHandler TimerIntervalElapsed
için bu en önemli kısımdır; o halde hadi adım adım analiz edelim.
Bu etkinlikle ilgili yapmamız gereken, birisinin dışarıdan abone olmasını/abonelikten çıkmasını ele almaktır. Bu durumda bunu Dahili Zamanlayıcıya yansıtmak istiyoruz.
Başka bir deyişle, dışarıdan biri ITimer
örneğinize sahipse, bunun gibi bir şey yapabilmesi gerekir t.TimerIntervalElapsed += (sender, dateTime) => { //do something }
.
Şu anda yapmamız gereken dahili olarak m_Timer.Elapsed += (sender, elapsedEventArgs) => { //do something }
gibi bir şey yapmaktır.
Ancak iki işleyicinin aynı olmadığını, aslında farklı türde olduklarını aklımızda tutmamız gerekiyor; TimerIntervalElapsedEventHandler
ve ElapsedEventHandler
.
Bu nedenle yapmamız gereken, TimerIntervalElapsedEventHandler
içindeki gelenleri yeni bir dahili ElapsedEventHandler
içine sarmaktır. Bu yapabileceğimiz bir şey.
Ancak, bir noktada birisinin bir işleyicinin TimerIntervalElapsedEventHandler
olayına olan aboneliğini iptal etmesi gerekebileceğini de aklımızda tutmamız gerekir.
Bu, şu anda hangi ElapsedEventHandler
işleyicisinin o TimerIntervalElapsedEventHandler
işleyicisine karşılık geldiğini bilmemiz gerektiği anlamına gelir, böylece onu Dahili Zamanlayıcı aboneliğinden çıkarabiliriz.
Bunu başarmanın tek yolu, her TimerIntervalElapsedEventHandler
işleyicisini ve yeni oluşturulan ElapsedEventHandler
işleyicisini bir sözlükte takip etmektir. Bu şekilde, TimerIntervalElapsedEventHandler
işleyicisinde aktarılanları bilerek karşılık gelen ElapsedEventHandler
işleyicisini bilebiliriz.
Ancak dışarıdan birisinin aynı TimerIntervalElapsedEventHandler
işleyicisine birden fazla kez abone olabileceğini de unutmamalıyız.
Evet, bu mantıklı değil ama yine de yapılabilir. Bu nedenle, bütünlüğün sağlanması adına, her TimerIntervalElapsedEventHandler
işleyicisi için, ElapsedEventHandler
işleyicilerinin bir listesini tutacağız.
Çoğu durumda, yinelenen bir abonelik olmadığı sürece bu listede yalnızca bir giriş bulunur.
İşte bu yüzden bu 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); } } }
add
yeni bir ElapsedEventHandler
oluşturuyoruz, m_Handlers
bunu TimerIntervalElapsedEventHandler
ile eşleyen sözlüğe bir kayıt ekliyoruz ve son olarak Internal Timer'a abone oluyoruz.
remove
dosyasında, ElapsedEventHandler
işleyicilerinin ilgili listesini alıyoruz, son işleyiciyi seçiyoruz, Dahili Zamanlayıcı aboneliğinden çıkıyoruz, onu listeden kaldırıyoruz ve liste boşsa tüm girişi kaldırıyoruz.
Ayrıca Dispose
uygulamasından da bahsetmeye değer.
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; }
Geriye kalan tüm işleyicilerin Dahili Zamanlayıcı aboneliğinden çıkıyoruz, Dahili Zamanlayıcıyı atıyoruz ve m_Handlers
sözlüğünü temizliyoruz.
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(); } } }
Burada hâlâ pek bir şey yapmıyoruz. Eski çözümle neredeyse aynı.
Bunu çalıştırmak şöyle bir şeyle sonuçlanmalı:
Artık son tasarımımız var. Ancak bu tasarımın gerçekten Publisher
modülümüzü birim testleriyle ele almamıza yardımcı olup olamayacağını görmemiz gerekiyor.
Çözümün yapısı şöyle görünmelidir:
Test için NUnit ve Moq kullanıyorum. Kesinlikle tercih ettiğiniz kütüphanelerle çalışabilirsiniz.
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; } } }
Burada neyi fark edebiliriz:
Zamanlayıcı saplamamız aracılığıyla gerçekleştirilen eylemleri günlüğe kaydederken kullanılacak Action
numaralandırmasını tanımladık. Bu daha sonra gerçekleştirilen dahili eylemleri onaylamak için kullanılacaktır.
Ayrıca loglama için kullanılacak ActionLog
sınıfını da tanımladık.
TimerStub
sınıfını ITimer
bir saplaması olarak tanımladık. Bu saplamayı daha sonra Publisher
modülünü test ederken kullanacağız.
Uygulama basittir. Bir birim testi içinde saplamayı manuel olarak tetikleyebilmemiz için ekstra bir public void TriggerTimerIntervalElapsed(DateTime dateTime)
yöntemi eklediğimizi belirtmekte fayda var.
Ayrıca iddia edebileceğimiz bilinen bir değere sahip olmak için dateTime
beklenen değerini de iletebiliriz.
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); } } }
Artık gördüğünüz gibi tam kontrol bizde ve Publisher
modülümüzü birim testlerle kolayca kapsayabiliyoruz.
Kapsamı hesaplarsak şunu elde etmeliyiz:
Gördüğünüz gibi Publisher
modülü %100 kaplıdır. Geri kalanı için bu, bu makalenin kapsamı dışındadır, ancak makaledeki yaklaşımı izlerseniz bunu kolayca ele alabilirsiniz.
Bunu yapabilirsin. Bu sadece büyük modülleri daha küçük modüllere bölmek, soyutlamalarınızı tanımlamak, zor parçalarla yaratıcı olmak meselesidir ve sonra işiniz biter.
Kendinizi daha fazla eğitmek istiyorsanız bazı En İyi Uygulamalar hakkındaki diğer yazılarıma göz atabilirsiniz.
İşte bu kadar, umarım bu makaleyi okurken benim yazarken bulduğum kadar ilginç bulmuşsunuzdur.
Burada da yayınlandı