Test Edildi, Onaylandı
Testler ne amaçla kullanılır, nasıl yazılmalı, hayatı kolaylaştırmak için hangi özelliklere sahip olmalı?
Yazılım mühendisliğindeki pek çok konuda olduğu gibi, test konusunda da farklı anlayışlar ve uygulamalar var. Bu yüzden tek bir doğrudan bahsedemeyiz. Mühendislik, bir spektrum üzerinde “yeterince iyi” bir noktayı bulmaktır. Testlerde de durum benzer.
Başlamadan önce şunu belirteyim; pentest, load test, usability testing gibi farklı test türlerine girmeyeceğim. Bir yazılımcı olarak günlük rutin işimizi yapabilmek için faydalandığımız ve yazmakla sorumlu olduğumuz türden testleri inceleyeceğiz.

TLDR;
Özet geçmek gerekirse;
Eğer bir projede sağlıklı ve hızlı ilerleme kaydetmek istiyorsanız, test yazmak zorundasınız.
Test yazmaya ne kadar erken başlarsanız, işiniz o kadar kolay.
Eğer test yazmaya uygun bir ortam oluşturulmadıysa, sonradan test yazmak çok zor olacaktır ve kodda büyük değişiklikler gerekecektir.
Testleri otomasyona dahil edin.
Hata ne kadar geç yakalanırsa, getirdiği maliyet o kadar artar.
Testleri mümkün olduğunca gerçek koda karşı yazın, mock ve stub kullanımında aşırıya gitmeyin.
Kaliteli testlerin özellikleri:
Kolay okunurlar ve başka dosyaları incelemeye gerek kalmadan anlaşılabilirler.
Logic içermezler. Mesela döngüler, if’ler else’ler ve aklınıza gelen her türlü cambazlıktan uzak dururlar.
Birbirinden izole çalışır, birbirlerini etkilemez, başka testlere bağımlılıkları olmaz.
Sistemde refactoring, bug fix veya ek geliştirme yapmak gerektiğinde mevcut testlere hiç dokunmadan işinizi görmenize izin verirler. Davranış değişmediği sürece, testler geçerliliğini korur.
Yalnızca bir şeyi test ederler (“SOLID”deki single responsibility gelsin aklınıza).
Hata mesajları bilgilendirici ve yönlendiricidir. Bir test bozulduğunda, hata mesajına bakarak durumu anlayabiliriz.
Test Yazmaktaki Amacımız Ne?
Bazısı test yazmaz. Testleri, vakit kaybı olarak görür. Geliştirme hızını azalttığını söyler. Kodun ömrü birkaç günlükse - bir ödev olabilir veya sadece bir kere çalıştırılacak bir script - test yazmamak mantıklı olabilir. Ama bunun ötesine geçen her türlü geliştirme işinde, testler olmadan ilerlemek gece farlar kapalı araba sürmeye benzer. Test yazmanın maliyeti, yanlış yola sapmanın maliyeti yanında devede kulak kalır. Problemleri ne kadar erken tespit edersek, getirdiği maliyeti o kadar azaltmış oluruz.
Diğer taraftan, kalitesiz yazılan testler faydadan çok zarar verir. Hatırlamaya çalışın; kim bilir kaç kere basit bir değişikliğin, alakasız başka testlerin bozulmasına yol açtığına şahit olmuşuzdur?
Detaylara ve pratiğe girmeden önce, daha felsefî bir açıdan bakarak başlayalım. Amacımız ne? Neden test yazmalıyız?
Amacımız yazılım yapmak değil, yazılım bir araç. Kodun tasarım ve yazım aşamasından, canlı ortama taşınıp gerçek trafik almaya başladığı zamana kadar olan süreci bir üretim bandı gibi düşünelim. Hedefimiz daha esnek ve çevik bir üretim bandına sahip olmak. Bu da beraberinde bizlerin daha üretken ve mutlu olmamızı sağlayacaktır. Nihai amacımız ise, asıl var olma sebebimiz olan müşterilerimizin memnuniyetini sağlamak ve artırmak.
Testler üretim bandımızın kabiliyetini nasıl artıracak?
Hızlı geliştirme döngüsü: Eğer elimizin altında küçük testler (unit test vb. kendi makinanızda kolayca çalıştırabileceğiniz) varsa, bunları kullanarak yaptığımız değişikliğin nasıl davrandığını anında görebiliriz. Bunlar olmasaydı, her geliştirme için tek kullanımlık testler yazıp daha sonra çöpe atacaktık. Ya da değişikliğimizi hiç denemeden bir sonraki aşamaya gönderecektik. Bu da yazılım geliştirme döngümüzü uzatacak, çünkü mutlaka bir yerlerde hata yapmışızdır (ben hep yapıyorum, kendimden biliyorum).
Güven: Geliştirmemizin düşündüğümüz şekilde çalışıp çalışmadığını doğrulayabilmek çok kıymetli. Diyelim ki kodu yazdık ve review için göndereceğiz, öncesinde ilgili testlerin hepsini çalıştırıp bir bakarız ve böylece değişikliklerimizin herhangi bir regresyon veya istenmeyen sonuca sebebiyet vermediğinden emin oluruz. Testler sayesinde, değişiklik yaparken çok daha rahattır kafamız.
Etkileşim: Sistemin diğer bileşenleriyle olan etkileşimini görüp, gerekli tedbirleri canlıya çıkmadan önce alabiliriz. Geliştirme bilgisayarımızdan çıkıp gerçek ortamlara taşındığında, orada nasıl davrandığını gözlemleyebilmek (entegrasyon testlerini düşünün) hata oranını azaltır.
Uçtan uca doğrulama: Client (istemci) ve upstream (uygulamanızı çağıran veya tetikleyen sistemler) gözünden uygulamayı kullanan diğer sistemlerin, değişikliklerimizden etkilenip etkilenmediğini tespit edebiliriz.
Bu faktörlerin hepsi bir araya geldiğinde, hem değişikliklerin canlıya çıkma süresi kısalmış olur hem de daha sık ve güvenilir şekilde canlı ortamlara geliştirmelerimizi taşıyabiliriz. Bunun neticesinde de, iş kolundan gelen isteklere daha kolay cevap verip, daha mutlu müşterilere sahip oluruz.
Test Türleri
Unit test, integration test, end-to-end test gibi farklı test türlerini duymuşsunuzdur. Burada biraz daha farklı bir pencereden bakarak değişik bir kategorizasyon sunacağım. Bunu “Software Engineering at Google” kitabında gördüm ve testlere daha doğru yaklaşmamıza yardımcı olacağını düşünüyorum. Ayrıca bu kitabı okumanızı tavsiye ederim, özellikle testlerle ilgili bölümleri çok faydalı bilgiler içeriyor.
Testleri, büyüklüklerine, çalışma sürelerine ve çalışma maliyetlerine göre değerlendirerek küçükten büyüğe doğru üç kategoride ele alabiliriz:
A Kategorisi (küçük) - Tek başına çalışabilen, hızlı ve küçük testler: Unit testleri düşünebiliriz. Bu kategorideki testler;
veritabanına ihtiyaç duymaz
ağ erişimi istemez
ayrı bir process (işletim sistemindeki processleri düşünelim) yaratmaz
ayak izi küçüktür, uygulamanın ufak birimlerini birbirinden izole şekilde test eder
hızlıdır, kendi makinamızda rahatlıkla çalıştırabilir ve anında neticesini görebiliriz
debug edilmesi kolaydır, problemi arayacağımız yer bellidir
B Kategorisi (orta) - Belirli entegrasyonları ve uygulamanın bağlantı noktalarını test eden biraz daha büyük ve hantal testler: Entegrasyon testleri ile veritabanı, CDN ya da lokal cache gibi yapıların nasıl davrandığını doğrulamak için yazılan testler bu kategoriye girer. Bu testler;
çeşitli bağımlıklıklarla olan etkileşimi doğrular
veritabanı ve diğer bağımlılıkları kendi izole ortamında kullanır (mesela bu kategorideki bir test başka bir uygulamanın preprod ortamına erişmez, onun yerine - mesela docker kullanarak - ilgili uygulamanın bir örneğini kendi test ortamında ayağa kaldırır ve bu örneği çağırarak testi gerçekleştirir)
ayak izi daha büyüktür, tamamen izole değildir
biraz daha yavaştır, çalıştırıp hemen sonucunu görmek mümkün değildir
debug edilmesi daha zordur, çünkü olası problemlerin kaynağı farklı noktalarda olabilir
C Kategorisi (büyük) - Çalıştırmanın en maliyetli olduğu testler: End-to-end testler, performans ve yük testleri ile canlıda yapılan A/B testlerini düşünelim. Bunlar;
gerçek veya gerçeğe yakın veriye ihtiyaç duyar
veritabanı, ağ erişimi, 3rd party uygulamalar ve birden fazla makina gerektirebilir, hatta yerine göre binlerce CPU kullanabilir
ayak izi çok büyüktür, pek çok farklı sisteme ve uygulamaya temas eder
işin içine network ve diğer bileşenler de girdiği için daha da yavaştır, bitinceye kadar bir çay içip gelebiliriz muhtemelen
debug edilmesi en zor testlerdir, sistem uçtan uca test edildiği için hatayı anlamak için birkaç katmanı birlikte incelemek gerekebilir
Test yazarken şöyle bir denge tutturabiliriz: Testlerin %80’i A kategorisi, %15’i B kategorisi ve kalan %5’i de C kategorisi testlerden oluşabilir. Küçük testlerin hem yazma hem de çalıştırma maliyeti düşük olduğu için, tüm testlerin büyük bölümünü bunlar oluşturuyor. Fakat küçük testlerle uygulamadaki tüm davranışları doğrulamak mümkün değil, bu yüzden takviye olarak daha büyük ölçekli B ve C kategorisi testlere de ihtiyacımız var.
Büyük çoğunlukla A kategorisi testlerle uğraşmamız gerekeceğinden, bu tür testlere biraz daha derinlemesine bakalım.
A Kategorisi Küçük Testler
İster büyük olsun ister küçük olsun, tüm testlerimizde bulunması gereken iki temel özellik var; kolay okunabilirlik ve kapsamlı hata mesajları. Okunabilirlik çok önemli, çünkü testler patlamaya başladığında sebebini araştırmak gerekecek.
Yazılımcılar “DRY” (don’t repeat yourself) prensibini çok önemser. Tekrara düşmeyi pek sevmeyiz. Tekrar eden kod bloklarını görünce, “şunu ayrı bir modüle alayım da ortak kullanılsın” diye düşünürüz. Fakat söz konusu testler olduğunda, “DRY” okunabilirliğin önüne geçen en büyük engel.
Bir testi açıp okurken, başka dosyalara bakmak zorunda kalmadan testin
neyi doğrulamaya çalıştığını
hangi varsayımları yaptığını
hangi bağımlılıkları kullandığını
kolayca görebilmeliyiz. Bunları sağlamak için, testleri “given”, “when” ve “then” diye üç farklı bölüme ayırıp testimizin okunabilirliğini artırabiliriz. Basit bir örnek:
Given kısmında test için gereken nesneleri ve veriyi hazırlıyoruz. Burada test etmeye çalıştığımız sınıf Foo ve onun bir bağımlılığı olan Bar’ı hazırlıyoruz.
When bölümünde, test etmek istediğimiz işi gerçekleştiriyoruz, yani metodu çağırıyoruz.
Then bölümünde ise doğrulamalarımızı yapıyoruz. Burada “assert” ifadeleriyle doğru olmasını beklediğimiz koşulları sıralıyoruz.
Testlerin verdiği hata mesajlarını da okunabilirlik kapsamında düşünmeliyiz. Detaylı ve kolay okunabilen hata mesajları, problemleri çabuk çözmemize fayda sağlar. Test frameworkleri bu işi genelde iyi yapıyor, bizim dikkat etmemiz gereken şey assert ifadelerimizi olabildiğince frameworke yüklemek. Örnek vermek gerekirse, eğer assert olarak self.assertEqual(result, "NO")
yazarsak, her iki değeri de görme şansımız var:
Ama eğer şöyle yazsaydık, self.assertTrue(result == "NO")
o zaman aldığımız hata mesajı pek de işe yaramıyor, result’ın aldığı değeri göremiyoruz:
Yukarıdaki örnekte gördüğünüz gibi, “assertEqual” metodunu kullandığımızda framework’e daha fazla bilgi gönderdiğimiz için (hem result hem de “NO” stringini parametre olarak geçiyoruz), hata mesajı da daha bilgilendirici olabiliyor.
Test Doubles (Dublörlerimiz)
Testlerde gerçek implementasyonun yerini tutması için bağımlılıklarımızın yerine kullandığımız tüm yapılara genel olarak test doubles deniyor. Sinema filmlerindeki dublörler gibi düşünebilirsiniz bunları: Gerçekmiş gibi görünen, ama gerçeğinden daha az maliyetli ve davranışlarını belirleyebildiğimiz yapılar. Bu yapılardan bazıları:
fake: Test için bizim veya başka bir ekibin yazdığı, gerçek implementasyon gibi davranan yapılar. Örnek: Gerçek veritabanı yerine “in-memory” bir “key-value store” kullanılması.
stub: Genelde test frameworklerinin sunduğu ve yazdığımız test kapsamında bir fonksiyonun davranışını değiştirmemizi sağlayan yapılar.
mock: Yine test frameworklerinin sağladığı bir yapı olup, bir nesnenin komple yerini alabiliyor ve üzerinde çeşitli “assertion”lar yapabiliyoruz.
Neyi Test Etmeliyiz
Testlerimizde etkileşimleri denetlemek yerine, veriyi denetlemeliyiz. Çünkü etkileşimler değişebilir; bunlar implementasyon detaylarıdır ve testlerimizin bunları bilmesine gerek yoktur. Basit bir örnek üzerinden daha kolay anlatabilirim.
Verilen listenin en büyük elemanını bulan “find_max” diye bir metodumuz olsun. Şöyle bir implementasyona sahip
Buna bir test yazdığımızı düşünelim ve Python’un “unittest” kütüphanesinin sunduğu “MagicMock”tan faydalanalım. Nesnelerin birbiriyle olan etkileşimlerini test etmekten kastım ne göreceksiniz.
Burada “n1” nesnesinin “n2” ile olan etkileşimi üzerine bir assert var:
n1.compare_to.assert_called_once_with(n2)
Test bu haliyle geçiyor, hata vermiyor. Ama test ettiği şey “compare_to”yu çağırıp çağırmaması ve bu bir implementasyon detayı. Metodun işlevi değişmese bile o işi nasıl yaptığı değişebilir. Bu değiştiğinde ise, testi elden geçirmemiz gerekecek.
Nasıl mı? Kodun “compare_to” metodu yerine doğrudan değişkenlerin değerini kullanacak şekilde yeniden yazıldığını düşünelim. Yine listedeki en büyük elemanı buluyor, ama bunu biraz daha farklı bir yoldan yapıyor. Bu kez “compare_to” yerine nesnenin “value” metodunu kullanıyor.
Metodun işlevi değişmedi, aynı input için aynı outputu verecek. Bu yüzden testimizin de geçmesini beklerdik, fakat maalesef implementasyon detayının değişmesi testimizi bozdu.
Testte “compared_to” metodunun çağrılmasını bekliyorduk ama artık çağrılmıyor. En büyük elemanı farklı bir yöntemle buluyor metodumuz.
Bu metodu kırılgan olmayacak şekilde nasıl test edebilirdik? İmplementasyon detayları değişse bile, test güncelliğini korusun istiyoruz. O halde etkileşimleri bir kenara bırakıp değerler üzerinden gitmeliyiz. Mesela aşağıdaki test çok daha etkili ve mantıklı. Dikkat ettiyseniz, “compare_to” ile ilgili tüm test satırlarını sildim.
Bu örneklerde görüldüğü gibi, doğru yazılmayan testler kırılgan olabiliyor. Bu da geliştirme hızını çok yavaşlatan bir etken. Buradan çıkarmamız gereken dersler:
Testlerinizi nesneler veya modüller arası etkileşim üzerine kurmayın, bunun yerine doğrulamalarınızı değerler ve “state” üzerinden yapın.
Mocking frameworklerinin sunduğu kolaylıklar, bizi kalitesiz testler yazmaya itebiliyor.
Eliniz hemen mock veya stub tarzı yapıları kullanmaya gitmesin. Önce gerçek implementasyonu kullanabilir miyim diye düşünün. Bu olmuyorsa kendi kontrolünüzdeki “fake” bir nesne kullanmayı deneyin. Yukarıdaki örnekte gerçek implementasyonlar kullansaydık (n1 ve n2yi “Number” türünden oluşturarak), testimiz çok daha temiz olacaktı:
Kapanış
Biraz uzun bir yazı oldu, ama testler çok kritik bir konu. Eğer kaliteli testlere sahip bir uygulama ile çalıştıysanız, testlerin nasıl bir konfor sunduğunu biliyorsunuzdur. Böyle bir imkanınız olmadıysa, bir yerlerden başlayıp ufak da olsa test yazmaya çalışın. Faydalarını bizzat göreceksiniz.