Birim Testleri
Programcılığın kaçınılmaz uğraşlarından birisi hata ayıklamaktır.
Her kullanıcının yakından tanıdığı gibi, içinde bilgisayar programı çalışan her cihaz yazılım hataları içerir. Yazılım hataları, kol saati gibi basit elektronik aletlerden uzay aracı gibi büyük sistemlere kadar her yerde bulunur.
Hata nedenleri
Yazılım hatalarının çok çeşitli nedenleri vardır. Programın fikir aşamasından başlayarak kodlanmasına doğru kabaca sıralarsak:
- Programdan istenenler açık bir şekilde ortaya konmamış olabilir; hatta belki de programın tam olarak ne yapacağı başından bilinmiyordur
- Programcı, programdan istenenleri yanlış anlamış olabilir
- Programlama dili, programdan istenenleri ifade etmekte yetersizdir; bir insana Türkçe anlatırken bile anlaşmazlıklar yaşandığını göz önüne alırsak, bilgisayar dilinin karmaşık söz dizimleri ve kuralları, istenenlerin tam olarak ifade edilmesi için yeterli olmayabilir
- Programcının varsayımları yanlış çıkabilir; örneğin pi sayısı olarak 3.14 değerinin yeterli olduğu varsayılmış olabilir
- Programcının bilgisi herhangi bir konuda yetersiz veya yanlış olabilir; örneğin kesirli sayıların eşitlik karşılaştırmalarında kullanılmalarının güvensiz olduğunu bilmiyordur
- Program, baştan düşünülmemiş olan bir durumla karşılaşabilir; örneğin bir klasördeki dosyalardan birisi, program o listeyi bir döngüde kullanırken silinmiş veya o dosyanın ismi değiştirilmiş olabilir
- Programcı kodu yazarken dikkatsizlik yapabilir; örneğin bir işlem sırasında toplamFiyat yerine toptanFiyat yazabilir
- vs.
Ne yazık ki, günümüzde henüz tam olarak sağlam kod üreten yazılım geliştirme yöntemleri bulunamamıştır. Bu konu, sürekli olarak çözüm bulunmaya çalışılan ve her beş on yılda bir ümit verici yöntemlerin ortaya çıktığı bir konudur.
Hatanın farkedildiği zaman
Yazılım hatasının ne zaman farkına varıldığı da çeşitlilik gösterir. En erkenden en geçe doğru sıralayarak:
- Kod yazılırken
- Programı yazan kişi tarafından
- Başka bir programcı tarafından; örneğin çiftli programlama (pair programming) yöntemi uygulandığında, yapılan bir yazım hatasını programı yazan kişinin yanındaki programcı farkedebilir
- Derleyici tarafından; derleyicinin verdiği hata mesajları veya uyarılar çoğunlukla programcı hatalarını gösterirler
- Programın programcı tarafından oluşturulması sırasında birim testleri tarafından
- Kod incelenirken
- Kaynak kodu inceleyen araç programlar tarafından; (başka diller için örneğin Coverity)
- Kodu inceleyen başka programcılar tarafından kod incelemesi (code review) sırasında
- Program kullanımdayken
- Programın işleyişini inceleyen araç programlar tarafından (örneğin Linux ortamlarındaki açık kodlu 'valgrind' programı ile)
- Sürümden önce test edilirken; ya
assertifadelerinin başarısızlığından, ya da programın gözlemlenen davranışından - Sürümden önce beta kullanıcıları tarafından test edilirken
- Sürümdeyken son kullanıcılar tarafından
Hata ne kadar erken farkedilirse hem zararı o kadar az olur, hem de o kadar az sayıda insanın zamanını almış olur. Bu yüzden en iyisi, hatanın kodun yazıldığı sırada yakalanmasıdır. Geç farkedilen hata ise başka programcıların, programı test edenlerin, ve çok sayıdaki kullanıcının da zamanını alır.
Son kullanıcıya gidene kadar farkedilmemiş olan bir hatanın kodun hangi noktasından kaynaklandığını bulmak da çoğu durumda oldukça zordur. Bu noktaya kadar farkedilmemiş olan bir hata, bazen aylarca sürebilen uğraşlar sonucunda temizlenebilir.
Hata yakalamada birim testleri
Kodu yazan programcı olmazsa zaten kod olmaz. Ayrıca, derlemeli bir dil olduğu için D programları zaten derleyici kullanmadan oluşturulamazlar. Bunları bir kenara bıraktığımızda, program hatalarını yakalamada en erken ve bu yüzden de en etkin yöntem olarak birim testleri kalır.
Birim testleri, modern programcılığın ayrılmaz araçlarındandır. Kod hatalarını azaltma konusunda en etkili yöntemlerdendir. Birim testleri olmayan kod, hatalı kod olarak kabul edilir.
Ne yazık ki bunun tersi doğru değildir: birim testlerinin olması, kodun hatasız olduğunu kanıtlamaz; ama hata oranını çok büyük ölçüde azaltır.
Birim testleri ayrıca kodun rahatça ve güvenle geliştirilebilmesini de sağlarlar. Kod üzerinde değişiklik yapmak, örneğin yeni olanaklar eklemek, doğal olarak o kodun eski olanaklarının artık hatalı hale gelmelerine neden olabilir. Kodun geliştirilmesi sırasında ortaya çıkan böyle hatalar, ya çok sonraki sürüm testleri sırasında farkedilirler, ya da daha kötüsü, program son kullanıcılar tarafından kullanılırken.
Bu tür hatalar kodun yeniden düzenlenmesinden çekinilmesine ve kodun gittikçe çürümesine (code rot) neden olurlar. Örneğin bazı satırların aslında yeni bir işlev olarak yazılmasının gerektiği bir durumda, yeni hatalardan korkulduğu için koda dokunulmaz ve kod tekrarı gibi zararlı durumlara düşülebilir.
Programcı kültüründe duyulan "bozuk değilse düzeltme" ("if it isn't broken, don't fix it") gibi sözler, hep bu korkunun ürünüdür. Bu gibi sözler, yazılmış olan koda dokunmamayı erdem olarak gösterdikleri için zaman geçtikçe kodun çürümesine ve üzerinde değişiklik yapılamaz hale gelmesine neden olurlar.
Modern programcılıkta bu düşüncelerin yeri yoktur. Tam tersine, kod çürümesinin önüne geçmek için kodun gerektikçe serbestçe geliştirilmesi önerilir: "acımasızca geliştir" ("refactor mercilessly"). İşte bu yararlı yaklaşımın en güçlü silahı birim testleridir.
Birim testi, programı oluşturan en alt birimlerin birbirlerinden olabildiğince bağımsız olarak test edilmeleri anlamına gelir. Alt birimlerin bağımsız olarak testlerden geçmeleri, o birimlerin birlikte çalışmaları sırasında oluşacak hataların olasılığını büyük ölçüde azaltır. Eğer parçalar doğru çalışıyorsa, bütünün de doğru çalışma olasılığı artar.
Birim testleri başka bazı dillerde JUnit, CppUnit, Unittest++, vs. gibi kütüphane olanakları olarak gerçekleştirilmişlerdir. D'de ise birim testleri dilin iç olanakları arasındadır. Her iki yaklaşımın da üstün olduğu yanlar gösterilebilir. Bunun bir örneği olarak, D'de birim testleri konusunu kütüphane olarak halleden Dunit projesine de bakmak isteyebilirsiniz.
D'de birim testleri, önceki derste gördüğümüz assert ifadelerinin unittest blokları içinde kullanılmalarından oluşurlar. Ben burada yalnızca D'nin bu iç olanağını göstereceğim.
Bu blokları anlatmadan önce, birim testlerinin nasıl başlatıldıklarını göstermem gerekiyor.
Birim testlerini başlatmak
Programın asıl işleyişi ile ilgili olmadıkları için, birim testlerinin yalnızca programın geliştirilmesi aşamasında çalıştırılmaları gerekir. Birim testleri derleyici veya geliştirme ortamı tarafından, ve ancak özellikle istendiğinde başlatılır.
Birim testlerinin nasıl başlatıldıkları kullanılan derleyiciye ve geliştirme ortamına göre değişir. Ben burada örnek olarak Digital Mars'ın derleyicisi olan dmd'nin -unittest seçeneğini göstereceğim.
Programın deneme.d isimli bir kaynak dosyaya yazıldığını varsayarsak, komut satırında normalde şu şekilde oluşturabiliriz:
dmd deneme.d -ofdeneme -w
Kısaca: dmd, derleyici; deneme.d, derlenmesi istenen kaynak dosya; -of, oluşturulacak olan programın ismini belirleyen seçenek (bu durumda deneme); ve -w da derleyici uyarılarını etkinleştiren seçenektir.
Programı kodun içindeki birim testlerini de ekleyerek oluşturmak için bu komut satırına -unittest seçeneği eklenir:
dmd deneme.d -ofdeneme -w -unittest
Bu şekilde oluşturulan program çalıştırıldığında önce birim testleri işletilir ve ancak onlar başarıyla tamamlanırsa programın işleyişi main ile devam eder.
unittest blokları
Birim testlerini oluşturan kodlar bu blokların içine yazılır. Bu kodların programın normal işleyişi ile ilgileri yoktur; yalnızca programı ve özellikle işlevleri denemek için kullanılırlar:
unittest
{
işlevi_deneyen_kodlar_ve_assert_ifadeleri
}
unittest bloklarını sanki işlev tanımlıyor gibi kendi başlarına yazabilirsiniz. Ama daha iyisi, bu blokları denetledikleri işlevlerin hemen altına yazmaktır.
Örnek olarak, bir önceki derste gördüğümüz ve kendisine verilen sayıya Türkçe ses uyumuna uygun olarak da eki döndüren işleve bakalım. Bu işlevin doğru çalışmasını denetlemek için, unittest bloğuna bu işlevin döndürmesini beklediğimiz koşullar yazarız:
dstring daEki(in int sayı) { // ... } unittest { assert(daEki(1) == "de"); assert(daEki(5) == "te"); assert(daEki(9) == "da"); }
Oradaki üç koşul; 1, 5, ve 9 sayıları için sırasıyla "de", "te", ve "da" döndürüldüğünü denetler.
Her ne kadar testlerin temeli assert denetimleri olsa da, unittest bloklarının içinde her türlü D olanağını kullanabilirsiniz. Örneğin, bir dizgi içindeki belirli bir harfi o dizginin en başında olacak şekilde döndüren bir işlevin testleri şöyle yazılabilir:
dchar[] harfBaşa(in dchar[] dizgi, in dchar harf) { // ... } unittest { dchar[] dizgi = "merhaba"d.dup; assert(harfBaşa(dizgi, 'm') == "merhaba"); assert(harfBaşa(dizgi, 'e') == "emrhaba"); assert(harfBaşa(dizgi, 'a') == "aamerhb"); }
Oradaki üç assert ifadesi, harfBaşa işlevinin nasıl çalışmasının beklendiğini denetliyorlar.
Bu örneklerde görüldüğü gibi, birim testleri aynı zamanda işlevlerin belgeleri ve örnek kodları olarak da kullanışlıdırlar. Yalnızca birim testine bakarak işlevin kullanılışı hakkında hızlıca fikir edinebiliriz.
Test yönelimli programlama: önce test, sonra kod
Modern programcılık yöntemlerinden olan test yönelimli programlama ("test driven development" - TDD), birim testlerinin kod yazılmadan önce yazılmasını öngörür. Bu yöntemde asıl olan birim testleridir. Kodun yazılması, birim testlerinin başarıya ulaşmalarını sağlayan ikincil bir uğraştır.
Yukarıdaki daEki işlevine bu bakış açısıyla yaklaşarak onu önce birim testleriyle şöyle yazmamız gerekir:
dstring daEki(in int sayı) { return "bilerek hatalı"; } unittest { assert(daEki(1) == "de"); assert(daEki(5) == "te"); assert(daEki(9) == "da"); } void main() {}
Her ne kadar o işlevin hatalı olduğu açık olsa da, önce programın birim testlerinin doğru olarak çalıştıklarını, yani beklendiği gibi hata attıklarını görmek isteriz:
$ dmd deneme.d -ofdeneme -w -O -unittest
$ ./deneme
core.exception.AssertError@deneme.d(7): Assertion failure
İşlev ancak ondan sonra ve bu testleri geçecek şekilde yazılır:
dstring daEki(in int sayı) { dstring ek; const int sonHane = sayı % 10; switch (sonHane) { case 1: case 2: case 7: case 8: ek = "de"; break; case 3: case 4: case 5: ek = "te"; break; case 6: case 9: case 0: ek = "da"; break; default: // Buraya hiçbir durumda gelmemeliyiz assert(false); } return ek; } unittest { assert(daEki(1) == "de"); assert(daEki(5) == "te"); assert(daEki(9) == "da"); } void main() {}
Artık program bu testleri geçer, ve bizim de daEki işlevi konusunda güvenimiz gelişir. Bu işlevde daha sonradan yapılacak olası geliştirmeler, unittest bloğuna yazdığımız koşulları korumak zorundadırlar. Böylelikle kodu geliştirmeye güvenle devam edebiliriz.
Bazen de önce hata, sonra test, ve en sonunda kod
Birim testleri bütün durumları kapsayamazlar. Örneğin yukarıdaki testlerde üç farklı eki üreten üç sayı değeri seçilmiş, ve daEki işlevi bu üç testten geçtiği için başarılı kabul edilmiştir.
Bu yüzden, her ne kadar çok etkili yöntemler olsalar da, birim testleri bütün hataları yakalayamazlar ve bazı hatalar bazen son kullanıcılara kadar saklı kalabilir.
daEki işlevi için bunun örneğini assert dersinin problemlerinde de görmüştük. O problemde olduğu gibi, bu işlev 50 gibi bir değer geldiğinde hatalıdır:
import std.stdio; void main() { writefln("%s'%s", 50, daEki(50)); }
$ ./deneme
50'da
"50'de" olması gerektiği halde, işlev yalnızca son haneye baktığı için bu durumda 50 için hatalı olarak "da" döndürmektedir.
Test yönelimli programlama; hemen işlevi düzeltmek yerine, öncelikle bu hatalı durumu yakalayan bir birim testinin eklenmesini şart koşar. Çünkü hatanın birim testlerinin gözünden kaçarak programın kullanımı sırasında ortaya çıkmış olması, birim testlerinin bir yetersizliği olarak görülür. Buna uygun olarak, bu durumu yakalayan bir test örneğin şöyle yazılabilir:
unittest { assert(daEki(1) == "de"); assert(daEki(5) == "te"); assert(daEki(9) == "da"); assert(daEki(50) == "de"); }
Program bu sefer bu birim testi denetimi nedeniyle sonlanır:
$ ./deneme
core.exception.AssertError@deneme(39): Assertion failure
Artık bu hatalı durumu denetleyen bir test bulunduğu için, işlevde ileride yapılabilecek geliştirmelerin tekrardan böyle bir hataya neden olmasının önüne geçilmiş olur.
Kod ancak bu birim testi yazıldıktan sonra, ve o testi geçirmek için yazılır.
Not: Bu işlev, sonu "bin" ve "milyon" gibi okunarak biten başka sayılarla da sorunlu olduğu için burada kapsamlı bir çözüm bulmaya çalışmayacağım.
Problem
- Yukarıda sözü geçen
harfBaşaişlevini, birim testlerini geçecek şekilde gerçekleştirin:
dchar[] harfBaşa(in dchar[] dizgi, in dchar harf) { dchar[] sonuç; return sonuç; } unittest { dchar[] dizgi = "merhaba"d.dup; assert(harfBaşa(dizgi, 'm') == "merhaba"); assert(harfBaşa(dizgi, 'e') == "emrhaba"); assert(harfBaşa(dizgi, 'a') == "aamerhb"); } void main() {}
O tanımdan başlayın; ilk test yüzünden hata atıldığını görün; ve işlevi hatayı giderecek şekilde yazın.
D.ershane
Forum
Wiki
Projeler
Tanıtım
İletişim
Hakları