Zamanlayıcı Üzerinde Tam Kontrol Nasıl Sağlanır ve Birim Testleriyle %100 Kapsama Nasıl Ulaşılır?
.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.
Yaklaşım
Çö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.
Örnek
Ö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.
Sorumluluk reddi beyanı
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.
.NET C# Konsol Uygulamasını Birim Testleriyle Tamamen Kapsama .
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.
Kötü Çözüm
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.
Güzel çözüm
İ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.
IZamanlayıcı
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 delegeITimer
tarafından gündeme getirilecek olayı temsil ediyor.
Zaten
System.Timers.Timer
tarafından kullanılan yerelElapsedEventHandler
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 olarakElapsedEventArgs
sağladığını fark edeceksiniz. BuElapsedEventArgs
özel bir yapıcısı vardır ve kendi örneğinizi oluşturamazsınız. AyrıcaElapsedEventArgs
sınıfında tanımlananSignalTime
ö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.
Yayımcı
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.
Zamanlayıcı
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()
vepublic 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 gerekirt.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
veElapsedEventHandler
.
Bu nedenle yapmamız gereken,
TimerIntervalElapsedEventHandler
içindeki gelenleri yeni bir dahiliElapsedEventHandler
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 oTimerIntervalElapsedEventHandler
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şturulanElapsedEventHandler
işleyicisini bir sözlükte takip etmektir. Bu şekilde,TimerIntervalElapsedEventHandler
işleyicisinde aktarılanları bilerek karşılık gelenElapsedEventHandler
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.
programı
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ı:
Test Zamanı, Gerçeğin Anı
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.
Zamanlayıcı Saplaması
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 sonraPublisher
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.
YayıncıTestleri
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.
Son sözler
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ı