Birim Testleri, Test Edilebilir Kod Nasıl Yazılır ve Neden Önemlidir?
Yayınlanan: 2022-03-11Birim testi, herhangi bir ciddi yazılım geliştiricisinin araç kutusundaki önemli bir araçtır. Ancak, belirli bir kod parçası için iyi bir birim testi yazmak bazen oldukça zor olabilir. Kendi kodunun veya bir başkasının kodunu test etmekte zorluk çeken geliştiriciler, genellikle bu sıkıntılarının bazı temel test bilgilerinin veya gizli birim test tekniklerinin eksikliğinden kaynaklandığını düşünür.
Bu birim testi eğitiminde, birim testlerinin oldukça kolay olduğunu göstermek niyetindeyim; birim testini karmaşıklaştıran ve pahalı karmaşıklığa neden olan gerçek sorunlar, kötü tasarlanmış, test edilemez kodun bir sonucudur. Kodu test etmeyi zorlaştıran şeyleri, test edilebilirliği artırmak için hangi anti-kalıplardan ve kötü uygulamalardan kaçınmamız gerektiğini ve test edilebilir kod yazarak başka hangi faydaları elde edebileceğimizi tartışacağız. Birim testleri yazmanın ve test edilebilir kod oluşturmanın yalnızca testi daha az zahmetli hale getirmekle ilgili olmadığını, aynı zamanda kodun kendisini daha sağlam ve bakımı daha kolay hale getirmekle ilgili olduğunu göreceğiz.
Birim Testi Nedir?
Temel olarak, bir birim testi, uygulamamızın küçük bir bölümünü somutlaştıran ve davranışını diğer bölümlerden bağımsız olarak doğrulayan bir yöntemdir. Tipik bir birim testi 3 aşamadan oluşur: İlk olarak, test etmek istediği bir uygulamanın küçük bir parçasını başlatır (test edilen sistem veya SUT olarak da bilinir), ardından test edilen sisteme bazı uyarıcılar uygular (genellikle bir yöntemi) ve son olarak ortaya çıkan davranışı gözlemler. Gözlemlenen davranış beklentilerle tutarlıysa, birim testi geçer, aksi takdirde başarısız olur ve test edilen sistemde bir yerde bir sorun olduğunu gösterir. Bu üç birim test aşaması, Düzenleme, Harekete Geçme ve Onaylama veya basitçe AAA olarak da bilinir.
Bir birim testi, test edilen sistemin farklı davranışsal yönlerini doğrulayabilir, ancak büyük olasılıkla şu iki kategoriden birine girer: durum tabanlı veya etkileşim tabanlı . Test edilen sistemin doğru sonuçlar ürettiğini veya elde edilen durumunun doğru olduğunu doğrulamaya durum tabanlı birim testi denirken, belirli yöntemleri uygun şekilde çağırdığını doğrulamaya etkileşim tabanlı birim testi denir.
Uygun yazılım birimi testi için bir metafor olarak, kurbağa bacakları, ahtapot dokunaçları, kuş kanatları ve bir köpek kafası ile doğaüstü bir kimera inşa etmek isteyen çılgın bir bilim adamı hayal edin. (Bu metafor, programcıların aslında işte yaptıklarına oldukça yakındır). Bu bilim adamı, seçtiği her parçanın (veya birimin) gerçekten çalıştığından nasıl emin olabilir? Diyelim ki tek bir kurbağanın bacağını alıp ona elektriksel bir uyarı uygulayabilir ve uygun kas kasılmasını kontrol edebilir. Yaptığı şey, esasen birim testinin aynı Düzenle-Uygula-Onayla git adımlarıdır; tek fark, bu durumda birim , programlarımızı oluşturduğumuz soyut bir nesneye değil, fiziksel bir nesneye atıfta bulunur.
Bu makaledeki tüm örnekler için C# kullanacağım, ancak açıklanan kavramlar tüm nesne yönelimli programlama dilleri için geçerlidir.
Basit bir birim testi örneği şöyle görünebilir:
[TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }
Birim Testi ve Entegrasyon Testi
Dikkate alınması gereken bir diğer önemli şey, birim testi ile entegrasyon testi arasındaki farktır.
Yazılım mühendisliğinde birim testinin amacı, nispeten küçük bir yazılım parçasının davranışını diğer parçalardan bağımsız olarak doğrulamaktır. Birim testlerinin kapsamı dardır ve her bir parçanın doğru şekilde çalışmasını sağlayarak tüm durumları kapsamamıza olanak tanır.
Öte yandan entegrasyon testleri, bir sistemin farklı bölümlerinin gerçek yaşam ortamında birlikte çalıştığını gösterir . Karmaşık senaryoları doğrularlar (entegrasyon testlerini, sistemimizde bazı üst düzey işlemler gerçekleştiren bir kullanıcı olarak düşünebiliriz) ve genellikle veritabanları veya web sunucuları gibi harici kaynakların mevcut olmasını gerektirir.
Çılgın bilim adamı metaforumuza geri dönelim ve kimeranın tüm parçalarını başarıyla birleştirdiğini varsayalım. Ortaya çıkan yaratığın, diyelim ki farklı arazi türlerinde yürüyebildiğinden emin olarak, bir entegrasyon testi yapmak istiyor. Her şeyden önce, bilim adamı yaratığın üzerinde yürüyebileceği bir ortamı taklit etmelidir. Daha sonra yaratığı o ortama atar ve bir sopayla dürterek, tasarlandığı gibi yürüyüp hareket etmediğini gözlemler. Bir testi bitirdikten sonra, çılgın bilim adamı güzel laboratuvarında dağılmış olan tüm kiri, kumu ve kayaları temizler.
Birim ve entegrasyon testleri arasındaki önemli farka dikkat edin: Birim testi, uygulamanın çevreden ve diğer parçalardan izole edilmiş küçük bir bölümünün davranışını doğrular ve uygulanması oldukça kolaydır, entegrasyon testi ise farklı bileşenler arasındaki etkileşimleri kapsar. gerçek hayata yakın bir ortamdır ve ek kurulum ve sökme aşamaları dahil olmak üzere daha fazla çaba gerektirir.
Birim ve entegrasyon testlerinin makul bir kombinasyonu, her bir birimin diğerlerinden bağımsız olarak doğru bir şekilde çalışmasını ve tüm bu birimlerin entegre edildiğinde güzel bir şekilde çalışmasını sağlayarak, tüm sistemin beklendiği gibi çalıştığına dair bize yüksek düzeyde güven verir.
Ancak, her zaman ne tür bir test uyguladığımızı belirlemeyi unutmamalıyız: bir birim veya entegrasyon testi. Aradaki fark bazen aldatıcı olabilir. Bir iş mantığı sınıfındaki bazı ince uç durumu doğrulamak için bir birim testi yazdığımızı düşünürsek ve bunun web hizmetleri veya veritabanları gibi harici kaynakların bulunması gerektiğini fark edersek, bir şeyler doğru değildir - aslında, bir balyoz kullanıyoruz. fındık kır. Bu da kötü tasarım demektir.
Birim Testini İyi Yapan Nedir?
Bu öğreticinin ana kısmına dalmadan ve birim testleri yazmadan önce, iyi bir birim testinin özelliklerini hızlıca tartışalım. Birim test ilkeleri, iyi bir testin şunları yapmasını gerektirir:
Yazması kolay. Geliştiriciler, uygulamanın davranışının farklı durumlarını ve yönlerini kapsayacak şekilde genellikle çok sayıda birim testi yazarlar; bu nedenle, tüm bu test rutinlerini çok fazla çaba harcamadan kodlamak kolay olmalıdır.
Okunabilir. Birim testinin amacı açık olmalıdır. İyi bir birim testi, uygulamamızın bazı davranışsal yönleri hakkında bir hikaye anlatır, bu nedenle hangi senaryonun test edildiğini anlamak kolay olmalı ve - test başarısız olursa - sorunun nasıl çözüleceğini tespit etmek kolay olmalıdır. İyi bir birim testiyle, kodu gerçekten hata ayıklamadan bir hatayı düzeltebiliriz!
Güvenilir. Birim testleri, yalnızca test edilen sistemde bir hata varsa başarısız olmalıdır. Bu oldukça açık görünüyor, ancak programcılar, hiçbir hata eklenmemiş olsa bile testleri başarısız olduğunda genellikle bir sorunla karşılaşırlar. Örneğin, testler tek tek çalıştırıldığında geçebilir, ancak tüm test paketi çalıştırıldığında başarısız olabilir veya geliştirme makinemizi geçip sürekli entegrasyon sunucusunda başarısız olabilir. Bu durumlar bir tasarım kusurunun göstergesidir. İyi birim testleri tekrarlanabilir olmalı ve ortam veya çalışma düzeni gibi dış etkenlerden bağımsız olmalıdır.
Hızlı. Geliştiriciler, tekrar tekrar çalıştırabilmeleri ve herhangi bir hatanın ortaya çıkıp çıkmadığını kontrol edebilmeleri için birim testleri yazarlar. Birim testleri yavaşsa, geliştiricilerin bunları kendi makinelerinde çalıştırmayı atlamaları daha olasıdır. Yavaş bir test önemli bir fark yaratmaz; bin tane daha ekle ve bir süre beklemek zorunda kalacağız. Yavaş birim testleri, test edilen sistemin veya testin kendisinin harici sistemlerle etkileşime girerek onu çevreye bağımlı hale getirdiğini de gösterebilir.
Gerçekten birim, entegrasyon değil. Daha önce tartıştığımız gibi, birim ve entegrasyon testlerinin farklı amaçları vardır. Hem birim testi hem de test edilen sistem, dış faktörlerin etkisini ortadan kaldırmak için ağ kaynaklarına, veritabanlarına, dosya sistemine vb. erişmemelidir.
İşte bu - birim testleri yazmanın hiçbir sırrı yoktur. Ancak, test edilebilir kod yazmamıza izin veren bazı teknikler var.
Test Edilebilir ve Test Edilemez Kod
Bazı kodlar, bunun için iyi bir birim testi yazmak zor, hatta imkansız olacak şekilde yazılmıştır. Peki, kodu test etmeyi zorlaştıran nedir? Test edilebilir kod yazarken kaçınmamız gereken bazı anti-kalıpları, kod kokularını ve kötü uygulamaları gözden geçirelim.
Deterministik Olmayan Faktörlerle Kod Tabanını Zehirlemek
Basit bir örnekle başlayalım. Akıllı bir ev mikrodenetleyicisi için bir program yazdığımızı ve akşam veya gece bir hareket algılandığında arka bahçedeki ışığın otomatik olarak açılmasının gerekliliklerden biri olduğunu hayal edin. Aşağıdan yukarıya, günün yaklaşık saatinin bir dize temsilini döndüren bir yöntem uygulayarak başladık ("Gece", "Sabah", "Öğleden Sonra" veya "Akşam"):
public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }
Esasen, bu yöntem mevcut sistem saatini okur ve bu değere dayalı bir sonuç döndürür. Peki, bu kodda yanlış olan ne?
Birim testi açısından düşünürsek, bu yöntem için durum tabanlı uygun bir birim testi yazmanın mümkün olmadığını görürüz. DateTime.Now
, esasen, programın yürütülmesi sırasında veya test çalıştırmaları arasında muhtemelen değişecek olan gizli bir girdidir. Bu nedenle, sonraki çağrılar farklı sonuçlar üretecektir.
Bu tür deterministik olmayan davranış, sistem tarihini ve saatini gerçekten değiştirmeden GetTimeOfDay()
yönteminin dahili mantığını test etmeyi imkansız hale getirir. Şimdi böyle bir testin nasıl uygulanması gerektiğine bir göz atalım:
[TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }
Bunun gibi testler daha önce tartışılan birçok kuralı ihlal ederdi. Yazması pahalı (önemsiz kurulum ve sökme mantığı nedeniyle), güvenilmez (örneğin, sistem izin sorunları nedeniyle test edilen sistemde herhangi bir hata olmasa bile başarısız olabilir) ve garanti edilmez. hızlı koş. Ve son olarak, bu test aslında bir birim testi olmayacaktı - bir ünite ile entegrasyon testi arasında bir şey olacaktı, çünkü basit bir uç durumu test ediyormuş gibi görünüyor, ancak belirli bir şekilde kurulması için bir ortam gerektiriyor. Sonuç çabaya değmez, değil mi?
Tüm bu test edilebilirlik sorunlarının düşük kaliteli GetTimeOfDay()
API'sinden kaynaklandığı ortaya çıktı. Mevcut haliyle, bu yöntem birkaç sorundan muzdariptir:
Somut veri kaynağına sıkı bir şekilde bağlıdır. Diğer kaynaklardan alınan veya argüman olarak geçirilen tarih ve saati işlemek için bu yöntemi yeniden kullanmak mümkün değildir; yöntem yalnızca kodu yürüten belirli makinenin tarihi ve saati ile çalışır. Sıkı bağlantı, çoğu test edilebilirlik sorununun birincil köküdür.
Tek Sorumluluk İlkesini (SRP) ihlal eder. Yöntemin birden fazla sorumluluğu vardır; bilgiyi tüketir ve aynı zamanda işler. SRP ihlalinin başka bir göstergesi, tek bir sınıf veya yöntemin birden fazla değişmesi için neden olması durumudur . Bu açıdan
GetTimeOfDay()
yöntemi, dahili mantık ayarlamaları nedeniyle veya tarih ve saat kaynağının değiştirilmesi gerektiği için değiştirilebilir.İşini yapmak için gereken bilgiler hakkında yalan söylüyor. Geliştiriciler, hangi gizli girdilerin kullanıldığını ve nereden geldiklerini anlamak için gerçek kaynak kodunun her satırını okumalıdır. Yöntem imzası tek başına yöntemin davranışını anlamak için yeterli değildir.
Tahmin etmek ve sürdürmek zordur. Değişken bir küresel duruma bağlı olan bir yöntemin davranışı, yalnızca kaynak kodu okuyarak tahmin edilemez; daha önce değiştirmiş olabilecek tüm olaylar dizisiyle birlikte mevcut değerini hesaba katmak gerekir. Gerçek dünyadaki bir uygulamada, tüm bunları çözmeye çalışmak gerçek bir baş ağrısına dönüşür.
API'yi inceledikten sonra nihayet düzeltelim! Neyse ki, bu, tüm kusurlarını tartışmaktan çok daha kolay - sadece birbirine sıkı sıkıya bağlı endişeleri kırmamız gerekiyor.
API'yi Düzeltme: Bir Yöntem Argümanının Tanıtılması
API'yi düzeltmenin en açık ve kolay yolu, bir yöntem argümanı sunmaktır:
public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }
Şimdi yöntem, arayan kişinin bu bilgiyi gizlice kendi başına aramak yerine bir DateTime
argümanı sağlamasını gerektirir. Birim testi açısından bu harika; yöntem artık deterministiktir (yani dönüş değeri tamamen girdiye bağlıdır), bu nedenle durum tabanlı test, bir DateTime
değerini geçmek ve sonucu kontrol etmek kadar kolaydır:
[TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }
Bu basit yeniden düzenleyicinin, daha önce tartışılan tüm API sorunlarını (sıkı bağlantı, SRP ihlali, belirsiz ve anlaşılması zor API) hangi verilerin işlenmesi gerektiği ve nasıl yapılması gerektiği arasında net bir bağlantı sağlayarak çözdüğüne dikkat edin.
Mükemmel — yöntem test edilebilir, peki ya müşterileri ? Artık GetTimeOfDay(DateTime dateTime)
yöntemine tarih ve saat sağlamak arayan kişinin sorumluluğundadır, yani yeterince dikkat etmezsek test edilemez hale gelebilirler. Bununla nasıl başa çıkabileceğimize bir bakalım.
İstemci API'sini Düzeltme: Bağımlılık Enjeksiyonu
Akıllı ev sistemi üzerinde çalışmaya devam ettiğimizi ve GetTimeOfDay(DateTime dateTime)
yönteminin aşağıdaki istemcisini uyguladığımızı varsayalım - günün saatine ve hareketin algılanmasına bağlı olarak ışığı açıp kapatmaktan sorumlu yukarıda bahsedilen akıllı ev mikro denetleyici kodu :
public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }
Ah! Aynı türde gizli DateTime.Now
giriş problemimiz var - tek fark, soyutlama seviyesinin biraz daha üstünde yer almasıdır. Bu sorunu çözmek için, ActuateLights(bool motionDetected, DateTime dateTime)
imzasıyla yeni bir yöntemin çağıranına bir DateTime
değeri sağlama sorumluluğunu yeniden devrederek başka bir argüman sunabiliriz. Ancak, sorunu bir kez daha çağrı yığınında bir üst düzeye taşımak yerine, hem ActuateLights(bool motionDetected)
yöntemini hem de istemcilerini test edilebilir tutmamızı sağlayacak başka bir teknik kullanalım: Inversion of Control veya IoC.
Kontrolün Tersine Çevirilmesi, kodun ayrıştırılması ve özellikle birim testi için basit ama son derece kullanışlı bir tekniktir. (Sonuçta, birbirinden bağımsız olarak analiz edebilmek için, işleri gevşek bir şekilde bağlı tutmak esastır.) IoC'nin kilit noktası, karar verme kodunu (bir şeyin ne zaman yapılacağını) eylem kodundan (bir şey olduğunda ne yapılacağını) ayırmaktır. ). Bu teknik esnekliği artırır, kodumuzu daha modüler hale getirir ve bileşenler arasındaki bağlantıyı azaltır.
Kontrolün Tersine Çevrilmesi çeşitli şekillerde uygulanabilir; belirli bir örneğe bakalım — bir yapıcı kullanarak Bağımlılık Enjeksiyonu — ve bunun test edilebilir bir SmartHomeController
API'si oluşturmaya nasıl yardımcı olabileceğine bakalım.
İlk olarak, bir tarih ve saat elde etmek için bir yöntem imzası içeren bir IDateTimeProvider
arabirimi oluşturalım:
public interface IDateTimeProvider { DateTime GetDateTime(); }
Ardından, SmartHomeController
bir IDateTimeProvider
uygulamasına başvurmasını sağlayın ve tarih ve saati alma sorumluluğunu ona devredin:
public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }
Şimdi, Inversion of Control'ün neden böyle adlandırıldığını görebiliriz: tarih ve saati okumak için hangi mekanizmanın kullanılacağının kontrolü tersine çevrildi ve şimdi SmartHomeController
kendisine değil, SmartHomeController
istemcisine ait. Bu nedenle, ActuateLights(bool motionDetected)
yönteminin yürütülmesi, tamamen dışarıdan kolayca yönetilebilen iki şeye bağlıdır: motionDetected
argümanı ve bir SmartHomeController
yapıcısına aktarılan IDateTimeProvider
somut uygulaması.

Bu birim testi için neden önemlidir? Bu, üretim kodunda ve birim test kodunda farklı IDateTimeProvider
uygulamalarının kullanılabileceği anlamına gelir. Üretim ortamında, bazı gerçek hayat uygulamaları enjekte edilecektir (örneğin, gerçek sistem zamanını okuyan). Ancak birim testinde, belirli senaryoyu test etmek için uygun sabit veya önceden tanımlanmış bir DateTime
değeri döndüren bir "sahte" uygulama enjekte edebiliriz.
IDateTimeProvider
sahte bir uygulaması şöyle görünebilir:
public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }
Bu sınıfın yardımıyla SmartHomeController
deterministik olmayan faktörlerden izole etmek ve durum tabanlı birim testi yapmak mümkündür. Hareket algılanırsa, bu hareketin zamanının LastMotionTime
özelliğine kaydedildiğini doğrulayalım:
[TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }
Harika! Yeniden düzenlemeden önce böyle bir test mümkün değildi. Artık deterministik olmayan faktörleri ortadan kaldırdığımıza ve durum tabanlı senaryoyu doğruladığımıza göre, SmartHomeController
tamamen test edilebilir olduğunu düşünüyor musunuz?
Codebase'i Yan Etkilerle Zehirlemek
Deterministik olmayan gizli girdinin neden olduğu sorunları çözmemize ve belirli işlevleri test etmemize rağmen, kod (veya en azından bir kısmı) hala test edilemez!
Işığı açıp kapatmaktan sorumlu ActuateLights(bool motionDetected)
yönteminin aşağıdaki bölümünü gözden geçirelim:
// If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }
Gördüğümüz gibi, SmartHomeController
ışığı açma veya kapatma sorumluluğunu bir Singleton modeli uygulayan BackyardLightSwitcher
nesnesine devreder. Bu tasarımın nesi var?
ActuateLights(bool motionDetected)
yöntemini tam olarak birim testi için durum tabanlı teste ek olarak etkileşim tabanlı test yapmalıyız; yani, ışığı açma veya kapatma yöntemlerinin, ancak ve ancak uygun koşullar sağlandığında çağrılmasını sağlamalıyız. Ne yazık ki, mevcut tasarım bunu yapmamıza izin vermiyor: BackyardLightSwitcher
TurnOn()
ve TurnOff( TurnOff()
yöntemleri sistemde bazı durum değişikliklerini tetikliyor veya başka bir deyişle yan etkiler üretiyor. Bu yöntemlerin çağrıldığını doğrulamanın tek yolu, ilgili yan etkilerin gerçekten olup olmadığını kontrol etmektir ki bu acı verici olabilir.
Gerçekten de, hareket sensörünün, arka bahçe fenerinin ve akıllı ev mikro denetleyicisinin bir Nesnelerin İnterneti ağına bağlı olduğunu ve bir kablosuz protokol kullanarak iletişim kurduğunu varsayalım. Bu durumda, bir birim testi, o ağ trafiğini alıp analiz etmeye çalışabilir. Veya donanım bileşenleri bir kablo ile bağlıysa, birim testi, voltajın uygun elektrik devresine uygulanıp uygulanmadığını kontrol edebilir. Veya sonuçta, ek bir ışık sensörü kullanarak ışığın gerçekten açılıp kapanmadığını kontrol edebilir.
Gördüğümüz gibi, birim testi yan etki yöntemleri, deterministik olmayan birim testi kadar zor olabilir ve hatta imkansız olabilir. Herhangi bir girişim, daha önce gördüklerimize benzer sorunlara yol açacaktır. Ortaya çıkan testin uygulanması zor, güvenilmez, potansiyel olarak yavaş ve gerçekten birim olmayan olacaktır. Ve tüm bunlardan sonra, test takımını her çalıştırdığımızda ışığın yanıp sönmesi sonunda bizi deli edecek!
Yine, tüm bu test edilebilirlik sorunlarına, geliştiricinin birim testleri yazma yeteneği değil, hatalı API neden olur. Işık kontrolü tam olarak ne kadar uygulanırsa uygulansın, SmartHomeController
API'si zaten bilinen bu sorunlardan muzdariptir:
Somut uygulamaya sıkı sıkıya bağlıdır. API, sabit kodlanmış, somut
BackyardLightSwitcher
örneğine dayanır. Arka bahçedeki ışığı değiştirmek içinActuateLights(bool motionDetected)
yöntemini yeniden kullanmak mümkün değildir.Tek Sorumluluk İlkesini ihlal eder. API'nin değişmesi için iki neden vardır: Birincisi, dahili mantıkta yapılan değişiklikler (ışığı sadece geceleri açmayı, akşamları açmamayı seçmek gibi) ve ikincisi, eğer ışık değiştirme mekanizması başka bir mekanizma ile değiştirilirse.
Bağımlılıkları hakkında yalan söylüyor. Geliştiricilerin
SmartHomeController
sabit kodlanmışBackyardLightSwitcher
bileşenine bağlı olduğunu bilmelerinin, kaynak kodunu araştırmaktan başka bir yolu yoktur.Anlamak ve sürdürmek zordur. Koşullar uygun olduğunda ışık açılmayı reddederse ne olur?
SmartHomeController
boşuna düzeltmeye çalışmak için çok zaman harcayabilirdik, yalnızca sorununBackyardLightSwitcher
bir hatadan (veya daha komik, yanmış bir ampulden!) kaynaklandığını anlamak için.
Hem test edilebilirlik hem de düşük kaliteli API sorunlarının çözümü, şaşırtıcı olmayan bir şekilde, sıkıca bağlı bileşenleri birbirinden ayırmaktır. Önceki örnekte olduğu gibi, Dependency Injection kullanmak bu sorunları çözecektir; sadece SmartHomeController
öğesine bir ILightSwitcher
bağımlılığı ekleyin, ışık anahtarını çevirme sorumluluğunu ona devredin ve uygun yöntemlerin doğru koşullar altında çağrılıp çağrılmadığını kaydedecek sahte, yalnızca test amaçlı bir ILightSwitcher
uygulamasını iletin. Ancak, Dependency Injection'ı tekrar kullanmak yerine, sorumlulukları ayrıştırmak için ilginç bir alternatif yaklaşımı gözden geçirelim.
API'yi Düzeltme: Üst Düzey İşlevler
Bu yaklaşım, birinci sınıf işlevleri destekleyen herhangi bir nesne yönelimli dilde bir seçenektir. C#'ın işlevsel özelliklerinden yararlanalım ve ActuateLights(bool motionDetected)
yönteminin iki bağımsız değişkeni daha kabul etmesini sağlayalım: ışığı açıp kapatmak için çağrılması gereken yöntemlere işaret eden bir çift Action
temsilcisi. Bu çözüm, yöntemi daha yüksek dereceli bir işleve dönüştürecektir:
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }
Bu, daha önce gördüğümüz klasik nesne yönelimli Dependency Injection yaklaşımından daha işlevsel aromalı bir çözümdür; ancak, Dependency Injection'dan daha az kod ve daha fazla ifade ile aynı sonucu elde etmemizi sağlar. SmartHomeController
gerekli işlevselliği sağlamak için bir arayüze uyan bir sınıfın uygulanması artık gerekli değildir; bunun yerine, sadece bir fonksiyon tanımını iletebiliriz. Daha yüksek dereceli işlevler, Kontrolün Tersine Çevirilmesini uygulamanın başka bir yolu olarak düşünülebilir.
Şimdi, ortaya çıkan yöntemin etkileşim tabanlı birim testini gerçekleştirmek için, kolayca doğrulanabilir sahte eylemleri bu yönteme aktarabiliriz:
[TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }
Son olarak SmartHomeController
API'sini tamamen test edilebilir hale getirdik ve bunun için hem durum tabanlı hem de etkileşim tabanlı birim testleri gerçekleştirebiliyoruz. Yine, iyileştirilmiş test edilebilirliğe ek olarak, karar verme ve eylem kodu arasında bir bağlantı sağlamanın sıkı bağlantı sorununu çözmeye yardımcı olduğuna ve daha temiz, yeniden kullanılabilir bir API'ye yol açtığına dikkat edin.
Şimdi, tam birim testi kapsamı elde etmek için, olası tüm durumları doğrulamak için bir dizi benzer görünümlü testi kolayca uygulayabiliriz - birim testlerinin uygulanması artık oldukça kolay olduğu için çok da önemli değil.
Safsızlık ve Test Edilebilirlik
Kontrolsüz determinizm ve yan etkiler, kod tabanı üzerindeki yıkıcı etkileri bakımından benzerdir. Dikkatsizce kullanıldığında aldatıcı, anlaşılması ve bakımı zor, sıkı bir şekilde birleştirilmiş, yeniden kullanılamaz ve test edilemez kodlara yol açarlar.
Öte yandan, hem deterministik hem de yan etkisi olmayan yöntemlerin test edilmesi, gerekçelendirilmesi ve daha büyük programlar oluşturmak için yeniden kullanılması çok daha kolaydır. İşlevsel programlama açısından, bu tür yöntemlere saf işlevler denir. Nadiren saf bir işlevi test eden bir sorun birimimiz olur; tek yapmamız gereken bazı argümanları iletmek ve sonucun doğruluğunu kontrol etmektir. Kodu gerçekten test edilemez yapan şey, sabit kodlanmış, değiştirilemeyen, geçersiz kılınamayan veya başka bir şekilde soyutlanamayan saf olmayan faktörlerdir.
Kirlilik toksiktir: Foo() yöntemi deterministik olmayan veya yan etkili Bar()
yöntemine bağlıysa, Foo()
Foo()
deterministik olmayan veya yan etkili hale gelir. Sonunda, tüm kod tabanını zehirleyebiliriz. Tüm bu sorunları karmaşık bir gerçek yaşam uygulamasının boyutuyla çarparsanız, kendimizi kokularla, anti-kalıplarla, gizli bağımlılıklarla ve her türlü çirkin ve hoş olmayan şeyle dolu, bakımı zor bir kod tabanıyla tıkanmış bulacağız.
Ancak kirlilik kaçınılmazdır; herhangi bir gerçek hayat uygulaması, bir noktada, ortam, veritabanları, yapılandırma dosyaları, web hizmetleri veya diğer harici sistemler ile etkileşime girerek durumu okumalı ve değiştirmelidir. Bu nedenle, safsızlığı tamamen ortadan kaldırmayı hedeflemek yerine, şeyleri bağımsız olarak analiz edebilmek ve birim test edebilmek için bu faktörleri sınırlamak, kod tabanınızı zehirlemelerine izin vermekten kaçınmak ve sabit kodlanmış bağımlılıkları mümkün olduğunca kırmak iyi bir fikirdir.
Test Edilmesi Zor Kodun Ortak Uyarı İşaretleri
Son olarak, kodumuzun test edilmesinin zor olabileceğini gösteren bazı yaygın uyarı işaretlerini inceleyelim.
Statik Özellikler ve Alanlar
Statik özellikler ve alanlar veya basitçe ifade etmek gerekirse, küresel durum, bir yöntemin işini yapması için gereken bilgileri gizleyerek, non-determinizm getirerek veya yan etkilerin kapsamlı kullanımını teşvik ederek kodun anlaşılmasını ve test edilebilirliğini karmaşıklaştırabilir. Değişken genel durumu okuyan veya değiştiren işlevler doğal olarak saf değildir.
Örneğin, küresel olarak erişilebilir bir özelliğe bağlı olan aşağıdaki kod hakkında akıl yürütmek zordur:
if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }
Ya olması gerektiğinden emin olduğumuz halde HeatWater()
yöntemi çağrılmazsa? Uygulamanın herhangi bir parçası CostSavingEnabled
değerini değiştirmiş olabileceğinden, neyin yanlış olduğunu bulmak için bu değeri değiştiren tüm yerleri bulmalı ve analiz etmeliyiz. Ayrıca, daha önce gördüğümüz gibi, bazı statik özellikleri test amacıyla ayarlamak mümkün değildir (örneğin, DateTime.Now
veya Environment.MachineName
; bunlar salt okunurdur, ancak yine de deterministik değildir).
Öte yandan, değişmez ve determinist küresel devlet tamamen iyidir. Aslında, bunun için daha tanıdık bir isim var - bir sabit. Math.PI
gibi sabit değerler herhangi bir determinizm getirmez ve değerleri değiştirilemediğinden herhangi bir yan etkiye izin vermez:
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!
Singleton'lar
Esasen, Singleton modeli, küresel durumun başka bir biçimidir. Singleton'lar, gerçek bağımlılıklar hakkında yalan söyleyen ve bileşenler arasında gereksiz yere sıkı bağlantı sağlayan belirsiz API'leri teşvik eder. Ayrıca Tek Sorumluluk İlkesini de ihlal ederler çünkü birincil görevlerine ek olarak kendi başlatma ve yaşam döngülerini kontrol ederler.
Singleton'lar, tüm uygulamanın veya birim test takımının ömrü boyunca durum taşıdıkları için, birim testlerini sıraya bağlı hale getirebilir. Aşağıdaki örneğe bir göz atın:
User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }
In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache
after each unit test run.
Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.
The new
Operator
Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.
For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:
using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }
However, sometimes new
is absolutely harmless: for example, it is OK to create simple entity objects:
var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack
methods were called or not — we just check if the end result is correct:
string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }
Static Methods
Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.
For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:
void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }
However, pure static functions are OK: any combination of them will still be a pure function. Örneğin:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }
Benefits of Unit Testing
Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.
As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.