Java ile Nesne Yönelimli Programlama · Ders Notu
Bölüm 7: Test ve Hata Ayıklama
Derlenen bir program doğru çalışan bir program demek değildir. Derleyici yalnız yazım kurallarını denetler; programın istenen işi yapıp yapmadığını bilemez. Bu sorunun cevabı testten gelir. Test, programın beklenen davranışı gösterip göstermediğini sistemli biçimde sınar. Bir test kaldığında da hata ayıklama başlar: arızanın nedeni bulunur ve düzeltilir. Not önce hata, kusur ve arıza kavramlarını ayırır, ardından birimden kabule kadar test seviyelerini tanıtır. Merkezde JUnit 5 ile otomatik birim testi vardır: doğrulama metotları, @BeforeEach, istisna testi, yaşam döngüsü ve parametreli testler. Sınır değerleri, regresyon testi ve test güdümlü geliştirme iyi test tasarımını tamamlar. İkinci yarıda hata ayıklayıcı, kesme noktaları, adım komutları ve jdb anlatılır. Not profil oluşturucu, kod kapsamı, statik analiz, özellik tabanlı test, Selenium ve bellek analiz araçlarıyla biter.
Öğrenme çıktıları
- Test türleri
- Birim, modül, alt sistem, sistem ve kabul testini ayırt etmek; her birinin neyi doğruladığını açıklamak.
- JUnit ile birim testi
@Test,@BeforeEachveassertEquals,assertThrowsgibi doğrulama metotlarıyla otomatik testler yazmak; başarısız testin iletisini okumak.- İyi test tasarımı
- Sınır değerlerini seçmek, parametreli test yazmak; testleri belirleyici ve tekrarlanabilir kurmak; regresyon testinin değerini açıklamak.
- Hata ayıklama
- Kesme noktası koymak, programı adım adım (Step Into / Over / Out) yürütmek, değişkenleri izleyerek hatanın kaynağını bulmak.
- Analiz araçları
- Profil oluşturucu, kod kapsamı, statik analiz (SpotBugs), özellik tabanlı test, Selenium, Memory Analyzer ve VisualVM'in hangi soruyu cevapladığını bilmek.
- Doğru araç, doğru soru
- Bir sorun için test mi, hata ayıklayıcı mı, profil oluşturucu mu gerektiğine karar vermek.
7.1Test ve hata ayıklamanın temelleri
Bir öğrenci ödev programını derler, birkaç sayı dener, sonuçlar doğru görünür ve programı teslim eder. Hoca ise programı negatif sayılarla, boş bir diziyle ya da çok büyük bir girdiyle dener; program çöker ya da yanlış sonuç verir. Öğrencinin denemesi de bir testtir, ama rastgele ve eksiktir. Bu notun konusu, bu denemeyi sistemli, tekrarlanabilir ve otomatik hâle getirmektir.
7.1.1Neden test?
Yazılım dünyasında test üzerine pek çok söz dolaşır. Yazılım test uzmanı Michael Bolton, testin amacının hata avlamaktan çok sistemin beklendiği gibi çalıştığından emin olmak olduğunu vurgular. Programcılar arasındaki eski bir şaka, hatayı “henüz keşfedilmemiş bir özellik” diye anar. Kaliteyi bir eylem değil, alışkanlık olarak gören söz genellikle Aristoteles'e atfedilir; aslında Will Durant'ın Aristoteles yorumudur. Test etmeden yazılım yayımlamak da çoğu zaman Rus ruletine benzetilir.
Bu sözlerin en ünlüsü Edsger W. Dijkstra'ya aittir. Dijkstra'ya göre test, hataların varlığını gösterebilir, ama yokluğunu asla gösteremez. Bu cümle testin sınırını açıkça çizer. Bir test kümesi ne kadar büyük olursa olsun, yalnız denenen girdiler için bir şey söyler. Denenmemiş milyarlarca girdiden biri programı çökertebilir.
Test (testing), programın beklenen davranışı gösterip göstermediğini sistemli biçimde sınamaktır. Programa seçilmiş girdiler verilir, üretilen sonuç beklenen sonuçla karşılaştırılır. Sonuçlar uyuşuyorsa test geçer, uyuşmuyorsa kalır.
Tanımdaki “beklenen sonuç” sözü önemlidir. Bir testin yazılabilmesi için önce programın ne yapması gerektiği bilinmelidir. “İki sayıyı toplayan metot 2 ve 3 için 5 döndürmeli” cümlesi bir beklentidir; test bu beklentiyi koda döker. Beklentisi belirsiz bir program test edilemez. Bu yüzden test yazmak, çoğu zaman programın gereksinimlerini netleştirmeye de yardım eder.
Hiçbir test kümesi hatasızlığı kanıtlamaz; ama iyi seçilmiş testler güveni büyük ölçüde artırır. Bir metot normal girdilerde, sınırlarda ve hatalı girdilerde beklenen davranışı gösteriyorsa, geri kalan girdilerde de doğru çalışma olasılığı yüksektir. Testin değeri, bu güveni ucuz ve hızlı biçimde üretmesindedir.
7.1.2Hata, kusur, arıza
Gündelik dilde “hata” sözcüğü her şey için kullanılır: programcının yanılgısı da, koddaki yanlış satır da, ekranda görünen yanlış sonuç da hata diye anılır. Yazılım mühendisliği bu üç şeyi ayrı adlarla anar. Ayrım, test ile hata ayıklamanın neyi aradığını anlamayı kolaylaştırır.
Hata (error), programcının zihnindeki yanılgıdır: yanlış bir varsayım, unutulmuş bir durum. Kusur (defect, bug), bu yanılgının koda yansıyan yanlış satırıdır. Arıza (failure), kusurlu kod çalışırken ortaya çıkan yanlış davranıştır: yanlış çıktı, çökme, donma.
Şekil 7.1 zinciri soldan sağa gösterir. Zincirin her halkası farklı bir yerde yaşar. Hata programcının kafasındadır, kusur kaynak koddadır, arıza ise çalışan programın davranışındadır. Her kusur arızaya yol açmaz. Kusurlu satır hiç çalışmıyorsa ya da yalnız nadir girdilerde yanlış sonuç veriyorsa program uzun süre sorunsuz görünebilir.
Bir programcı dizinin en büyük öğesini bulan bir metot yazar. En büyük değeri tutan değişkene başlangıç değeri olarak 0 verir: int enb = 0;. Bu programdaki üç halka şöyledir.
Hata: programcı “dizide en az bir pozitif sayı vardır” diye düşünmüştür. Sıcaklık ölçümlerinde bu varsayım kışın geçersizdir.
Kusur: koddaki int enb = 0; satırı. Tüm öğeler negatifse hiçbir öğe 0'ı aşamaz.
Arıza: sıcaklıklar {-5, -2, -8} iken ekranda En yüksek: 0 görünür. 0 dizide yoktur bile.
Aynı metot {3, 9, 4} için doğru sonuç (9) verir. Kusur yalnız tüm öğeler negatif olduğunda arızaya dönüşür. Bu yüzden yalnız pozitif sayılarla yapılan bir deneme kusuru hiçbir zaman ortaya çıkarmaz.
7.1.3Test ile hata ayıklamanın farkı
Test ile hata ayıklama sıkça karıştırılır. İkisi aynı amaca hizmet eder, ama farklı soruları cevaplar. Test “program çalışıyor mu?” diye sorar. Hata ayıklama ise bir arızanın varlığı bilindikten sonra “neden çalışmıyor?” sorusunu sorar.
Hata ayıklama (debugging), bilinen bir arızanın nedenini, yani kusurlu satırı bulup düzeltme sürecidir. Bu işte kullanılan araca hata ayıklayıcı (debugger) denir.
| Test | Hata ayıklama | |
|---|---|---|
| Amaç | Programın beklendiği gibi çalışıp çalışmadığını sınamak | Bilinen bir arızanın nedenini bulup düzeltmek |
| Soru | “Çalışıyor mu?” | “Neden çalışmıyor?” |
| Sonuç | Geçti / kaldı (✔ / ✘) | Hatalı satır ve düzeltme |
| Araçlar | JUnit, Selenium, kod kapsamı | Hata ayıklayıcı, izleme listesi, println |
| Ne zaman | Sürekli, her değişiklikten sonra | Bir test kaldığında ya da kullanıcı hata bildirdiğinde |
Tablo 7.1 iki etkinliğin birbirini nasıl izlediğini de gösterir. Test sürekli çalışır ve arızayı haber verir. Arıza görülünce hata ayıklama devreye girer ve kusuru bulur. Kusur düzeltildikten sonra yine test çalışır: düzeltmenin işe yaradığını ve başka bir şeyi bozmadığını doğrular. İyi bir yazılım süreci bu döngüyü defalarca tekrarlar.
Tablodaki “izleme listesi”, bir istisna yakalanmadığında JVM'in yazdırdığı çağrı listesidir (stack trace). Hangi metodun hangi satırda istisna fırlattığını ve o metodu kimin çağırdığını gösterir. Hata ayıklamada ilk bakılacak bilgi çoğunlukla budur.
7.2Test türleri
Bir yazılım tek bir metottan değil, birbirine bağlı yüzlerce sınıftan oluşur. Bu yüzden test de tek bir adımda yapılmaz. Önce en küçük parçalar tek tek sınanır, sonra parçaların birlikte çalışması, en sonda da bütün sistem kullanıcının gözünden denetlenir. Her adım farklı bir test seviyesidir.
7.2.1Test seviyeleri
Şekil 7.2 beş seviyeyi bir piramit olarak gösterir. Piramidin biçimi bilinçlidir. Taban geniştir, çünkü birim testleri ucuzdur ve çok sayıda yazılır. Tepe dardır, çünkü kabul testleri pahalıdır, yavaştır ve az sayıda yapılır. Aşağıdan yukarı çıkıldıkça sınanan parça büyür.
Birim testi (unit testing) tek tek, bağımsız bileşenlere odaklanır: bir sınıf ya da bir metot. Her bileşenin tek başına doğru çalıştığından emin olunur. Örneğin bir BankaHesabi sınıfının cek() metodu, yetersiz bakiyede istisna fırlatıyor mu? Birim testi en küçük parçayı sınar; en çok sayıda yazılan ve en hızlı çalışan testtir.
Modül testi (module testing) birbiriyle ilgili bileşenlerden oluşan bir modülü sınar. Modül içindeki bileşenlerin uyumu ve birlikte işlevi doğrulanır. Örneğin Hesap, Islem ve Dekont sınıfları birlikte para transferini doğru yapıyor mu? Her sınıf tek başına doğru olsa bile, aralarındaki işbirliği yanlış olabilir.
Alt sistem testi (sub-system testing) alt sistemler arasındaki arayüz uyumsuzluklarını arar. Bileşenler arasındaki iletişimi ve veri akışını denetler. Örneğin hesap alt sistemi ile veri tabanı alt sistemi aynı tarih biçimini mi kullanıyor? Biri tarihi gün/ay/yıl, öteki yıl-ay-gün diye bekliyorsa iki alt sistem tek başına kusursuz çalışır, ama birlikte yanlış kayıt üretir.
Sistem testi (system testing) bütün sistemi bir bütün olarak sınar. Hem işlevsel gereksinimler (sistem ne yapıyor) hem de işlevsel olmayan gereksinimler (hız, güvenlik, kullanılabilirlik) denetlenir. “1000 eş zamanlı kullanıcıyla havale 2 saniyenin altında tamamlanıyor mu?” sorusu bir sistem testi sorusudur. Bu soruyu hiçbir birim testi cevaplayamaz.
Kabul testi (acceptance testing) sistemi benzetim verisi yerine gerçek verilerle sınar. Sistemin gerçek dünyadaki kullanılabilirliği ve başarımı doğrulanır. Kabul testi kullanıcılarla birlikte yapılır. Alfa testi geliştiricinin ortamında, beta testi kullanıcının kendi ortamında yürür. Sonunda tek bir soru cevaplanır: müşteri “bu sistemi kabul ediyorum” diyebiliyor mu?
7.2.2Seviyeler bir bakışta
| Seviye | Neyi test eder? | Kim yapar? | Veri |
|---|---|---|---|
| Birim | Tek sınıf / metot | Geliştirici | Küçük, elle seçilmiş |
| Modül | Birlikte çalışan birkaç sınıf | Geliştirici | Senaryo verisi |
| Alt sistem | Alt sistemler arası arayüzler | Geliştirici, test ekibi | Senaryo verisi |
| Sistem | Bütün sistem; hız, güvenlik | Test ekibi | Gerçeğe yakın |
| Kabul (alfa / beta) | Müşteri gereksinimleri | Kullanıcı ile birlikte | Gerçek veri |
Tablo 7.2 seviyelerin kimin elinde olduğunu da gösterir. Alt seviyeleri geliştirici kendisi yazar; üst seviyelerde ayrı bir test ekibi ve sonunda müşteri devreye girer. Veri de yukarı çıkıldıkça gerçeğe yaklaşır.
Seviyeler arasındaki en önemli fark maliyettir. Aşağı seviyedeki bir hata ucuz ve hızlı bulunur. Birim testi kaldığında kusurun yeri neredeyse bellidir: tek bir metot. Aynı hata kabul testinde ya da kullanıcının elinde ortaya çıkarsa önce hangi parçadan kaynaklandığı aranır, sonra düzeltilir, sonra bütün sistem yeniden denenir. Düzeltmenin maliyeti katlanarak büyür. Bu yüzden hataları olabildiğince aşağıda yakalamak hedeflenir.
Aşağıdaki sorunlardan her birini en doğrudan hangi seviye yakalar?
(a) faizHesapla() metodu yüzdeyi 100'e bölmeyi unutuyor. Tek bir metodun mantık hatasıdır: birim testi.
(b) Hesap sınıfı tutarı kuruş cinsinden, Dekont sınıfı lira cinsinden bekliyor. İki sınıfın birlikte çalışmasıyla ilgilidir: modül testi.
(c) Ödeme alt sistemi tarihleri gün/ay/yıl, raporlama alt sistemi yıl-ay-gün biçiminde gönderiyor. Alt sistemlerin birleştiği arayüzdedir: alt sistem testi.
(d) Sistem 500 kullanıcıda yanıt vermez hâle geliyor. İşlevsel olmayan bir gereksinimdir: sistem testi.
(e) Müşteri, raporun kendi muhasebe düzenine uymadığını söylüyor. Gerçek kullanıcı gereksinimidir: kabul testi.
7.2.3Kara kutu ve beyaz kutu testi
Testler seviyelerinin yanında, kodun içine bakıp bakmamalarına göre de ikiye ayrılır.
Kara kutu testi (black-box testing), kodun içine bakmadan yalnız girdi ve beklenen çıktı ile yapılan testtir. Program dışarıdan kapalı bir kutu gibi görülür. Beyaz kutu testi (white-box testing) ise kodun yapısı bilinerek yapılır; amaç her dalı ve her döngüyü en az bir kez çalıştırmaktır.
Kabul ve sistem testleri çoğunlukla kara kutu testidir. Müşteri kodu okumaz; yalnız sistemin istediğini yapıp yapmadığına bakar. Birim testleri ve kod kapsamı ise beyaz kutu bakışını kullanır. Bir metodun içinde if varsa, iyi bir birim test kümesi o if'in hem doğru hem yanlış dalını çalıştırır. İki bakış birbirini tamamlar. Kara kutu testi gereksinimden yola çıkar ve “ne yapmalı?” sorusunu sorar. Beyaz kutu testi koddan yola çıkar ve “hangi yolu hiç denemedim?” sorusunu sorar.
7.3Birim testi ve JUnit
Test seviyelerinin tabanı birim testidir ve programcının en sık yazdığı test de budur. Java'da birim testleri neredeyse her zaman JUnit çerçevesiyle yazılır. JUnit'in neden gerektiğini görmek için önce onsuz test etmenin nasıl bir şey olduğuna bakalım.
7.3.1Elle test etmek
Test edilecek sınıf iki metotlu basit bir hesap makinesidir.
public class HesapMakinesi {
public int topla(int a, int b) {
return a + b;
}
public int carp(int a, int b) {
return a * b;
}
}Programlamaya yeni başlayan biri bu sınıfı denemek için bir main metodu yazar, metotları çağırır ve sonuçları ekrana basar.
public class ElleTest {
public static void main(String[] args) {
HesapMakinesi hm = new HesapMakinesi();
System.out.println("topla(2, 3) = " + hm.topla(2, 3));
System.out.println("carp(2, -3) = " + hm.carp(2, -3));
// Doğru mu? Ekrana bakıp kendimiz karar veriyoruz.
}
}topla(2, 3) = 5
carp(2, -3) = -6Çıktı doğru görünüyor: 2 + 3 = 5 ve 2 × (−3) = −6. Ama “doğru görünüyor” kararını program değil, ekrana bakan insan verir. Koddaki yorum satırı da bunu itiraf eder. Bu yöntemin dört temel sorunu vardır.
- Sonucun doğru olduğuna insan gözü karar verir. Göz yorulur ve yanılır; 0,30000000000000004 ile 0,3 arasındaki farkı gözden kaçırmak çok kolaydır.
- Kod değiştiğinde her şeyin yeniden elle denenmesi gerekir. Bunu her seferinde kimse yapmaz.
- Test kodu
mainiçinde kalır, iş bitince silinir. Harcanan emek kaybolur. - Yüz metot için yüz çıktı satırı okunmalıdır. Büyük bir projede bu pratikte imkânsızdır.
Çözüm basittir: beklenen değer de kodun içine yazılır ve karşılaştırma bilgisayara bırakılır. Program “5 bekliyordum, 5 geldi” ya da “5 bekliyordum, 6 geldi” diye kendisi karar verir. Buna otomatik birim testi denir.
Birim testi (unit test), bir metodu ya da sınıfı tek başına sınayan otomatik testtir. Test, metodu bilinen bir girdiyle çağırır, sonucu beklenen değerle karşılaştırır ve kararı kendisi verir: geçti ya da kaldı.
7.3.2JUnit çerçevesi
JUnit, Java için en yaygın birim testi çerçevesidir (framework). Çerçeve, belirli bir işi kolaylaştıran ve programcının koduna kendisi çağrı yapan hazır bir kütüphanedir. Testler sıradan Java metotları olarak yazılır; JUnit onları bulur, çalıştırır ve sonuçlarını raporlar. Programcı main yazmaz, test metotlarını kimin hangi sırayla çağıracağıyla uğraşmaz.
Java'da bir birim genellikle bir sınıf anlamına gelir. Testler o sınıfın metotlarını tek tek sınar. JUnit temelde birim testi için tasarlanmıştır, ama esnektir. Birden fazla sınıfı içeren senaryolarda modül testi, sistem seviyesindeki etkileşimlerde sistem testi için de kullanılabilir. IntelliJ IDEA, Eclipse, NetBeans, VS Code ve Maven / Gradle gibi derleme araçları JUnit testlerini doğrudan çalıştırır.
| Sürüm | Test nasıl yazılır? |
|---|---|
| JUnit 3 | TestCase sınıfından kalıtım, adı test ile başlayan metotlar |
| JUnit 4 | @Test etiketi, org.junit paketi |
| JUnit 5 | @Test, org.junit.jupiter.api paketi; lambda, parametreli test |
Tablo 7.3 JUnit'in gelişimini özetler. JUnit 3 testleri kalıtımla ve adlandırma kuralıyla tanırdı. JUnit 4 bunun yerine etiket (annotation) kullanmaya başladı. Etiket, @ ile başlayan ve derleyiciye ya da bir araca bilgi veren bir nottur; @Override da bir etikettir. Bu notta JUnit 5 kullanılır. JUnit 5'in test yazılan kısmının adı Jupiter'dir; paket adlarındaki jupiter sözcüğü buradan gelir. Gradle kullanan bir projeye JUnit 5 şu satırla eklenir:
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")Maven ve Gradle projelerinde dosyalar belirli bir düzende durur. Test sınıfları src/test/java klasörüne, test edilen sınıflar src/main/java klasörüne konur. Böylece test kodu programla birlikte derlenir ve çalıştırılır, ama son kullanıcıya giden pakete girmez.
7.3.3İlk JUnit 5 testi
HesapMakinesi için elle yazılan denemeyi şimdi bir JUnit testine çevirelim.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class HesapMakinesiTest {
@Test
void ikiSayiyiToplar() {
HesapMakinesi hm = new HesapMakinesi();
assertEquals(5, hm.topla(2, 3));
}
@Test
void negatifSayiylaCarpar() {
HesapMakinesi hm = new HesapMakinesi();
assertEquals(-6, hm.carp(2, -3));
}
}Kodu satır satır inceleyelim. İlk satır @Test etiketini içe aktarır. İkinci satırdaki import static, Assertions sınıfının static metodu assertEquals'ı içe aktarır. Bu sayede her seferinde Assertions.assertEquals(…) yazmak gerekmez. Test sınıfının adı, test edilen sınıfın adına Test eklenerek verilir: HesapMakinesiTest. Bu bir kural değil, ama yaygın bir alışkanlıktır.
Sınıftaki her test metodu @Test ile işaretlenir. Bu etiket JUnit'e “bu metot bir testtir” der. Test metodu void ve parametresizdir. main gerekmez. JUnit 5'te test sınıfının ve metotlarının public olması da gerekmez; paket erişimi yeterlidir.
Her test metodu bir nesne oluşturur, metodu çağırır ve sonucu assertEquals(beklenen, gerçek) ile denetler. Parametrelerin sırası önemlidir: önce beklenen değer, sonra gerçek sonuç yazılır. Değerler eşitse metot sessizce döner ve test geçer (✔). Eşit değilse AssertionFailedError fırlatır ve test kalır (✘). Bu iki test çalıştırıldığında rapor şöyledir:
✔ ikiSayiyiToplar()
✔ negatifSayiylaCarpar()
2 test · 2 başarılı · 0 başarısızTest metotlarının adları birer cümle gibidir: ikiSayiyiToplar, negatifSayiylaCarpar. Bu bilinçli bir seçimdir. Bir test kaldığında rapordaki ad, neyin bozulduğunu hemen söyler. test1 gibi bir ad ise yalnız “bir şey bozuldu” der.
import static satırını unutmak. Bu satır olmadan assertEquals çağrısı derlenmez:
import org.junit.jupiter.api.Test;
class ImpTest {
@Test void t() { int x = 5; assertEquals(5, x); }
}ImpTest.java:3: error: cannot find symbol
@Test void t() { int x = 5; assertEquals(5, x); }
^
symbol: method assertEquals(int,int)
location: class ImpTest
1 errorDerleyici assertEquals adlı bir metodu test sınıfında arar ve bulamaz. Çözüm, import static org.junit.jupiter.api.Assertions.*; satırını eklemektir.
7.3.4Doğrulama metotları
Doğrulama (assertion), beklenen ile gerçek sonucu karşılaştıran ya da bir varsayımı denetleyen ifadedir. JUnit 5'te doğrulama metotları org.junit.jupiter.api.Assertions sınıfının static metotlarıdır. Doğrulama başarısız olursa metot AssertionFailedError fırlatır ve test kalır.
assertEquals en sık kullanılan doğrulamadır, ama tek değildir. Tablo 7.4 en yaygın doğrulama metotlarını listeler. Tablonun son sütunundaki örneklerin hepsi geçer; her biri JUnit 5.11 ile çalıştırılarak denendi.
| Metot | Ne zaman geçer? | Örnek (hepsi geçer) |
|---|---|---|
assertEquals(b, g) | b ve g eşit (equals) | assertEquals(5, 2 + 3) |
assertEquals(b, g, delta) | Ondalık sayılarda fark ≤ delta | assertEquals(0.3, 0.1 + 0.2, 1e-9) |
assertNotEquals(a, g) | Eşit değil | assertNotEquals(4, 2 + 3) |
assertTrue / assertFalse | Koşul doğru / yanlış | assertFalse("abc".isEmpty()) |
assertNull / assertNotNull | Değer null / null değil | assertNotNull("x") |
assertSame(a, b) | Aynı nesne (==) | assertSame(s, s) |
assertArrayEquals | Diziler öğe öğe eşit | assertArrayEquals(new int[] {1, 2}, new int[] {1, 2}) |
assertThrows(T.class, kod) | Kod o tipte istisna fırlatır | assertThrows(ArithmeticException.class, () -> …) |
assertAll(…) | Tüm doğrulamalar (hepsi denenir) | assertAll(() -> …, () -> …) |
fail("mesaj") | Asla; testi bilerek düşürür | fail("henüz yazılmadı") |
Tablodaki her satırın arkasında bir ayrıntı vardır. assertEquals nesneler için equals metodunu kullanır; bu yüzden içerikleri aynı iki String eşit sayılır. assertSame ise == ile karşılaştırır ve yalnız aynı nesneyi gösteren iki referansta geçer. Diziler için assertEquals yanıltıcıdır. Dizilerin equals metodu Object'ten gelir ve yalnız referansları karşılaştırır. İçeriği aynı iki ayrı dizi bu yüzden eşit sayılmaz:
assertEquals(new int[] {1, 2}, new int[] {1, 2}); // kalırexpected: [I@4a22f9e2<[1, 2]> but was: [I@3c419631<[1, 2]>İletideki [I@4a22f9e2, dizinin tipini ([I: int dizisi) ve kimlik kodunu gösterir; köşeli parantezlerin içindeki [1, 2] ise içeriğidir. JUnit, iki değerin metin hâli aynı olduğu için bu ayrıntıyı ekler. İçerik aynıdır, nesneler farklıdır. Dizileri öğe öğe karşılaştırmak için assertArrayEquals kullanılır. Kimlik kodları her çalıştırmada değişir.
Ondalık sayılar ayrı bir dikkat ister. 0,1 ve 0,2 ikili sistemde tam gösterilemez; toplamları 0,3 değil 0,30000000000000004 olur. Bu yüzden ondalık sayılarda üçüncü parametre olarak bir tolerans verilir. assertEquals(0.3, 0.1 + 0.2, 1e-9) çağrısı, iki değerin farkı 10−9'dan küçük olduğu için geçer. Tolerans unutulursa test kalır:
expected: <0.3> but was: <0.30000000000000004>Doğrulama metotlarının çoğu son parametre olarak isteğe bağlı bir ileti de alır. İleti, test kaldığında raporun başına eklenir ve neyin yanlış gittiğini açıklar. assertEquals(4, 2 + 3, "toplam yanlış") çağrısı şu iletiyle kalır:
toplam yanlış ==> expected: <4> but was: <5>assertAll birden çok doğrulamayı bir araya toplar. Sıradan doğrulamalar art arda yazıldığında ilk başarısız doğrulama testi bitirir; sonrakiler hiç çalışmaz. assertAll ise bütün doğrulamaları çalıştırır ve başarısız olanların hepsini birlikte raporlar:
@Test
void ikiDogrulama() {
assertAll(
() -> assertEquals(4, 2 + 3),
() -> assertEquals(7, 3 + 3));
}org.opentest4j.MultipleFailuresError: Multiple Failures (2 failures)
org.opentest4j.AssertionFailedError: expected: <4> but was: <5>
org.opentest4j.AssertionFailedError: expected: <7> but was: <6>İki doğrulama da kalmıştır ve rapor ikisini birden gösterir. Bir nesnenin birkaç alanını aynı anda denetlerken bu özellik zaman kazandırır: hangi alanların yanlış olduğu tek bir çalıştırmada görülür. assertAll'un parametreleri birer lambda ifadesidir. Lambda ifadesi, () -> … biçiminde yazılan adsız küçük bir fonksiyondur; burada “çalıştırılacak kod parçası” anlamına gelir.
Beklenen ile gerçeği ters yazmak. assertEquals(hm.topla(2, 3), 6) da derlenir ve çalışır, ama kalınca ileti ters okunur: expected: <5> but was: <6>. Rapor “metot 6 döndürdü” der gibi görünür; oysa metot 5 döndürmüştür. Yanlış iletiyle yapılan hata ayıklama yanlış yere gider. Kural: önce beklenen, sonra gerçek.
7.3.5Test edilecek sınıf: BankaHesabi
Gerçekçi bir test örneği için biraz daha zengin bir sınıf gerekir. BankaHesabi sınıfı bir bakiye tutar; para yatırma ve çekme işlemlerinde girdiyi denetler.
public class BankaHesabi {
private double bakiye;
public BankaHesabi(double bakiye) {
this.bakiye = bakiye;
}
public void yatir(double miktar) {
if (miktar <= 0) {
throw new IllegalArgumentException("Geçersiz miktar");
}
bakiye += miktar;
}
public void cek(double miktar) {
if (miktar > bakiye) {
throw new IllegalStateException("Yetersiz bakiye");
}
bakiye -= miktar;
}
public double getBakiye() {
return bakiye;
}
}yatir metodu sıfır ya da negatif miktarı IllegalArgumentException ile reddeder. cek metodu bakiyeden büyük bir miktarı IllegalStateException ile reddeder. İki istisnanın seçimi de anlamlıdır. Negatif miktar geçersiz bir argümandır. Yetersiz bakiye ise argümanın değil, nesnenin o anki durumunun işleme izin vermemesidir.
Test yazmadan önce sınıfın hangi davranışlarının sınanacağı düşünülür. Tablo 7.5 başlangıç bakiyesi 100 olan bir hesap için bu davranışları listeler.
| Çağrı | Beklenen | Tür |
|---|---|---|
yatir(50) | bakiye 150 | normal durum |
cek(30) | bakiye 70 | normal durum |
cek(500) | IllegalStateException; bakiye 100 kalır | hatalı girdi |
yatir(-10) | IllegalArgumentException | hatalı girdi |
yatir(0) | IllegalArgumentException | sınır |
cek(100) | bakiye 0, istisna yok | sınır |
Tablonun her satırı ayrı bir test metodu olur. Satırlar üç gruba ayrılır: normal durumlar, hatalı girdiler ve sınırlar. Yalnız normal durumları sınamak kolaydır ve çok yaygındır; ama hatalar çoğunlukla öteki iki gruptadır. cek(100) satırı buna iyi bir örnektir. Koşul miktar > bakiye yerine yanlışlıkla miktar >= bakiye yazılsaydı, bütün parayı çekmek istisna fırlatırdı. Bu kusuru yalnız tam sınırdaki girdi yakalar.
7.3.6Bir testin yapısı: hazırla, çalıştır, doğrula
İyi yazılmış bir test metodu üç bölümden oluşur. Bu düzen İngilizce baş harfleriyle AAA (Arrange, Act, Assert) diye anılır.
class AaaTest {
@Test
void cekmeBakiyeyiAzaltir() {
// Hazırla (Arrange)
BankaHesabi hesap = new BankaHesabi(100);
// Çalıştır (Act)
hesap.cek(30);
// Doğrula (Assert)
assertEquals(70.0, hesap.getBakiye(), 0.001);
}
}Hazırla (arrange) bölümünde test için gereken nesne ve veriler kurulur: bakiyesi 100 olan bir hesap. Çalıştır (act) bölümünde test edilen metot bir kez çağrılır: cek(30). Doğrula (assert) bölümünde sonuç beklenenle karşılaştırılır: bakiye 70 olmalıdır. Bakiye double olduğu için üçüncü parametre olarak 0.001 toleransı verilir.
AAA düzeni testi okunur kılar. Okuyan kişi testin neyi kurduğunu, neyi yaptığını ve neyi beklediğini üç bakışta görür. Düzenin bir sonucu da şudur: her test tek bir davranışı sınar. Bir testte birden çok “çalıştır” adımı varsa, o test aslında birkaç testtir ve bölünmelidir. Böylece test kaldığında hangi davranışın bozulduğu hemen anlaşılır.
7.3.7Ortak hazırlık: @BeforeEach
BankaHesabi için yazılan testlerin hepsi aynı hazırlıkla başlar: bakiyesi 100 olan bir hesap. Bu satırı her teste ayrı ayrı yazmak hem tekrar hem de hata kaynağıdır. JUnit ortak hazırlık için @BeforeEach etiketini sunar.
class BankaHesabiTest {
private BankaHesabi hesap;
@BeforeEach
void hazirla() {
hesap = new BankaHesabi(100);
}
@Test
void yatirmaBakiyeyiArtirir() {
hesap.yatir(50);
assertEquals(150.0, hesap.getBakiye(), 0.001);
}
@Test
void fazlaCekmeIstisnaFirlatir() {
assertThrows(IllegalStateException.class,
() -> hesap.cek(500));
assertEquals(100.0, hesap.getBakiye(), 0.001);
}
}hesap artık test sınıfının bir alanıdır. @BeforeEach ile işaretli hazirla metodu her testten önce çalışır ve bu alana yeni bir hesap atar. Böylece her test yeni ve temiz bir hesapla, bakiye 100 ile başlar.
Burada çok önemli bir ayrıntı vardır. JUnit her test metodu için test sınıfından yeni bir nesne oluşturur. yatirmaBakiyeyiArtirir testi bakiyeyi 150'ye çıkarır; ama bu değişiklik yalnız o testin nesnesindeki hesabı etkiler. fazlaCekmeIstisnaFirlatir testi kendi test nesnesinde, kendi hesabıyla çalışır. Testler birbirini etkilemez.
İkinci test assertThrows kullanır. Bu metot ikinci parametresindeki kodu çalıştırır ve o kodun birinci parametrede verilen tipte bir istisna fırlatmasını bekler. () -> hesap.cek(500) bir lambda ifadesidir: hemen çalışmaz, assertThrows'a “çalıştırılacak kod parçası” olarak verilir. assertThrows kodu kendi içinde çalıştırır ve istisnayı yakalar. İstisna yakalandığı için test devam eder ve ikinci doğrulama, bakiyenin değişmediğini denetler. Hatalı bir işlemin nesneyi bozmadığını görmek, istisnanın fırlatıldığını görmek kadar önemlidir.
Bu iki test çalıştırıldığında ikisi de geçer:
✔ fazlaCekmeIstisnaFirlatir()
✔ yatirmaBakiyeyiArtirir()
2 test · 2 başarılıRapordaki sıra kaynak koddaki sıra değildir. JUnit 5, test metotlarını varsayılan olarak belirli ama açıkça belirtilmeyen bir algoritmayla sıralar. Bu sıra her çalıştırmada aynıdır, ama kod sırasıyla aynı olmak zorunda değildir.
7.3.8Kod izleme: JUnit testleri nasıl çalıştırır?
BankaHesabiTest sınıfının çalıştırılmasını adım adım izleyelim. Her adımda heap'teki nesnelere bakacağız: test nesnesi ve onun gösterdiği BankaHesabi nesnesi.
@Test metotlarını bulur ve ilk test için yeni bir test nesnesi oluşturur. hesap alanı henüz null'dır.İlk adımda JUnit, yansıma (reflection) denen düzenekle sınıfı inceler ve @Test ile işaretli metotları bulur. Ardından test sınıfının parametresiz yapıcısını çağırarak bir test nesnesi oluşturur. Bu nesnenin hesap alanı varsayılan değeri olan null'ı taşır (Şekil 7.3).
@BeforeEach çalışır: yeni bir hesap oluşur, bakiye 100.İkinci adımda hazirla çalışır ve hesap alanı yeni bir BankaHesabi nesnesini gösterir. Sıra JUnit'tedir: önce fazlaCekmeIstisnaFirlatir çalışır. assertThrows lambdayı çağırır; cek(500), 500 > 100 olduğu için IllegalStateException fırlatır. İstisna beklenen tiptedir, assertThrows geçer. Bakiye hâlâ 100'dür; ikinci doğrulama da geçer (Şekil 7.5).
assertThrows geçer. Bakiye 100'de kalmıştır, ikinci doğrulama da geçer.İkinci test için JUnit yepyeni bir test nesnesi (#2) oluşturur. Önceki test nesnesi ve onun hesabı artık kullanılmaz; çöp toplayıcı onları zamanı gelince temizler. @BeforeEach yine çalışır ve bu test de bakiyesi 100 olan bir hesapla başlar. yatir(50) çağrısıyla bakiye 150 olur. Doğrulamada 150,0 ile 150,0 karşılaştırılır; fark 0,001'den küçüktür ve test geçer (Şekil 7.6).
| Adım | Olay | Test nesnesi | hesap | Bakiye |
|---|---|---|---|---|
| 1 | Test nesnesi oluşur | #1 | null | — |
| 2 | @BeforeEach | #1 | yeni hesap | 100 |
| 3 | cek(500), istisna | #1 | aynı | 100 |
| 4 | iki doğrulama geçer ✔ | #1 | aynı | 100 |
| 5 | Yeni test nesnesi | #2 | null | — |
| 6 | @BeforeEach | #2 | yeni hesap | 100 |
| 7 | yatir(50) | #2 | aynı | 150 |
| 8 | doğrulama geçer ✔ | #2 | aynı | 150 |
Tablo 7.6 izlemenin özetidir. Tablonun en önemli satırı 5. adımdır: JUnit ikinci test için önceki nesneyi yeniden kullanmaz. Bu tasarım, testlerin birbirinden bağımsız olmasını garanti eder. Bir testin bıraktığı durum bir sonrakine sızmaz. Bu garanti yalnız örnek alanları için geçerlidir; static alanlar sınıfa aittir ve bütün test nesneleri aynı alanı paylaşır. Bir testin static bir sayacı artırması, sonraki testin gördüğü değeri de değiştirir.
7.3.9İstisnaları test etmek
Hatalı girdinin reddedildiğini sınamak, doğru girdiyi sınamak kadar önemlidir. Bir metodun negatif miktarı kabul etmesi, yanlış sonuç vermesinden çok daha tehlikeli olabilir. assertThrows bu iş için yapılmıştır.
class BankaIstisnaTest {
@Test
void fazlaCekmeMesajiDogru() {
BankaHesabi hesap = new BankaHesabi(100);
IllegalStateException e = assertThrows(
IllegalStateException.class, () -> hesap.cek(500));
assertEquals("Yetersiz bakiye", e.getMessage());
}
@Test
void negatifYatirmaReddedilir() {
BankaHesabi hesap = new BankaHesabi(100);
assertThrows(IllegalArgumentException.class,
() -> hesap.yatir(-10));
}
@Test
void yanlisBeklenti() {
BankaHesabi hesap = new BankaHesabi(100);
assertThrows(IllegalStateException.class,
() -> hesap.cek(50));
}
}assertThrows yalnız istisnanın fırlatıldığını denetlemez; fırlatılan istisna nesnesini de döndürür. İlk test bu nesneyi e değişkenine alır ve iletisinin "Yetersiz bakiye" olduğunu ayrıca doğrular. İkinci test negatif yatırmanın reddedildiğini sınar. Üçüncü test ise bilerek yanlış yazılmıştır: 50 TL çekmek geçerli bir işlemdir ve istisna fırlatmaz. Sonuç şöyledir:
✔ fazlaCekmeMesajiDogru()
✔ negatifYatirmaReddedilir()
✘ yanlisBeklenti()
Expected java.lang.IllegalStateException to be thrown, but nothing was thrown.
3 test · 2 başarılı · 1 başarısızİleti açıktır: IllegalStateException beklendi, ama hiçbir istisna fırlatılmadı. assertThrows iki durumda kalır. Birincisi buradaki gibi hiç istisna fırlatılmamasıdır. İkincisi beklenenden farklı tipte bir istisna fırlatılmasıdır. Örneğin assertThrows(IllegalStateException.class, () -> hesap.yatir(-10)) şu iletiyle kalır:
Unexpected exception type thrown, expected: <java.lang.IllegalStateException> but was: <java.lang.IllegalArgumentException>assertThrows alt tipleri de kabul eder. Integer.parseInt("abc") bir NumberFormatException fırlatır; bu sınıf IllegalArgumentException'ın alt sınıfıdır. Bu yüzden assertThrows(IllegalArgumentException.class, () -> Integer.parseInt("abc")) geçer. Tam tip isteniyorsa assertThrowsExactly kullanılır; bu metot aynı çağrıda kalır.
JUnit 4'te istisna testi farklı yazılırdı: @Test(expected = IllegalStateException.class). Bu yazımda test metodunun herhangi bir satırı istisnayı fırlatırsa test geçer. assertThrows ise istisnanın tam olarak hangi çağrıdan beklendiğini belirtir ve istisna nesnesine erişim sağlar.
7.3.10Test yaşam döngüsü
@BeforeEach, JUnit'in yaşam döngüsü etiketlerinden yalnız biridir. Dördü birlikte, testlerin çevresinde hangi kodun ne zaman çalışacağını belirler.
class YasamDongusuTest {
@BeforeAll
static void basla() { System.out.println("BeforeAll"); }
@BeforeEach
void hazirla() { System.out.println(" BeforeEach"); }
@Test
void testA() { System.out.println(" testA"); }
@Test
void testB() { System.out.println(" testB"); }
@AfterEach
void temizle() { System.out.println(" AfterEach"); }
@AfterAll
static void bitir() { System.out.println("AfterAll"); }
}Bu sınıfın tek amacı, metotların çalışma sırasını ekrana yazdırmaktır. Çıktı şöyledir:
BeforeAll
BeforeEach
testA
AfterEach
BeforeEach
testB
AfterEach
AfterAll| Etiket | Ne zaman çalışır? | Tipik kullanım |
|---|---|---|
@BeforeAll | Tüm testlerden önce bir kez (static) | Pahalı ortak kaynağı açmak |
@BeforeEach | Her testten önce | Test nesnelerini yeniden kurmak |
@AfterEach | Her testten sonra (test kalsa bile) | Geçici dosyayı silmek |
@AfterAll | Tüm testlerden sonra bir kez (static) | Ortak kaynağı kapatmak |
Çıktı Tablo 7.7'deki kuralları doğrular. BeforeAll ve AfterAll birer kez, en başta ve en sonda yazılır. BeforeEach ve AfterEach her testin çevresinde birer kez çalışır; iki test olduğu için ikişer kez görünür. @AfterEach'in test kalsa bile çalışması önemlidir: temizlik işi bu yüzden oraya konur. Testin ortasında fırlatılan bir istisna geçici dosyayı ortada bırakmaz.
@BeforeAll ve @AfterAll metotları static olmalıdır. Nedeni Tablo 7.6'da görülür: JUnit her test için ayrı bir nesne oluşturur. “Tüm testlerden önce bir kez” çalışacak metodu çağırmak için henüz hiçbir test nesnesi yoktur; metot sınıfa ait olmalıdır. Bu kural unutulursa JUnit sınıftaki hiçbir testi çalıştırmaz ve şu hatayı verir:
class StaticsizTest {
@BeforeAll void basla() { }
@Test void t() { }
}org.junit.platform.commons.JUnitException: @BeforeAll method 'void StaticsizTest.basla()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).İletinin önerdiği @TestInstance(Lifecycle.PER_CLASS) etiketi, JUnit'e bütün testler için tek bir test nesnesi kullanmasını söyler. O zaman @BeforeAll örnek metodu olabilir; ama testlerin bağımsızlığı da zayıflar. Varsayılan davranış genellikle daha güvenlidir.
Testlerin kendi aralarındaki sırası belirlidir, ama kod sırası değildir. Bu yüzden testler hiçbir zaman birbirine bağımlı yazılmaz. “Önce testA hesabı açar, sonra testB o hesaptan para çeker” gibi bir düzen, sıra değiştiğinde kırılır.
7.3.11Kırmızıdan yeşile: başarısız test
Testin asıl değeri bir hata yakaladığında ortaya çıkar. Hata zinciri örneğindeki enBuyuk metodunun ilk sürümü şudur:
public class DiziIslem {
public static int enBuyuk(int[] dizi) {
int enb = 0;
for (int i = 0; i < dizi.length; i++) {
if (dizi[i] > enb) {
enb = dizi[i];
}
}
return enb;
}
}Kod derlenir ve ilk bakışta doğru görünür. Döngü her öğeyi enb ile karşılaştırır, daha büyüğünü bulunca enb'yi günceller. Bu metot için iki test yazalım: biri pozitif sayılardan, biri tamamen negatif sayılardan oluşan bir diziyle.
class DiziIslemTest {
@Test
void pozitifDizi() {
int[] dizi = {3, 9, 4};
assertEquals(9, DiziIslem.enBuyuk(dizi));
}
@Test
void tumuNegatifDizi() {
int[] dizi = {-5, -2, -8};
assertEquals(-2, DiziIslem.enBuyuk(dizi));
}
}✔ pozitifDizi()
✘ tumuNegatifDizi()
org.opentest4j.AssertionFailedError: expected: <-2> but was: <0>
2 test · 1 başarılı · 1 başarısızBaşarısız testin iletisi iki parçadan oluşur. expected beklenen değeri (−2), but was metodun gerçekte döndürdüğü değeri (0) gösterir. 0 dizide yoktur bile. Bu ipucu kusurun yerini neredeyse ele verir: 0 değeri dizinin içinden değil, başka bir yerden gelmiştir. Tek aday enb = 0 başlangıcıdır. Tüm öğeler negatif olduğunda hiçbir öğe 0'ı aşamaz ve enb hiç güncellenmez.
Test dünyasında başarısız teste kırmızı, başarılı teste yeşil denir; IDE'ler sonuçları bu renklerle gösterir. Kusurun düzeltilmiş sürümü şudur:
public class DiziIslem {
public static int enBuyuk(int[] dizi) {
int enb = dizi[0]; // ilk eleman
for (int i = 1; i < dizi.length; i++) {
if (dizi[i] > enb) {
enb = dizi[i];
}
}
return enb;
}
}En büyük değer artık dizinin ilk öğesiyle başlatılır. Döngü de 1. indisten başlar, çünkü 0. öğe zaten enb'dedir. Aynı iki test yeniden çalıştırılır ve ikisi de geçer:
✔ pozitifDizi()
✔ tumuNegatifDizi()
2 test · 2 başarılıi | dizi[i] | İlk sürüm: koşul | İlk sürüm: enb | Düzeltilmiş: koşul | Düzeltilmiş: enb |
|---|---|---|---|---|---|
| başlangıç | — | — | 0 | — | −5 |
| 0 | −5 | −5 > 0 yanlış | 0 | (döngü 1'den başlar) | −5 |
| 1 | −2 | −2 > 0 yanlış | 0 | −2 > −5 doğru | −2 |
| 2 | −8 | −8 > 0 yanlış | 0 | −8 > −2 yanlış | −2 |
| dönüş | 0 ✘ | −2 ✔ |
Tablo 7.8 iki sürümün farkını adım adım gösterir. İlk sürümde koşul hiçbir turda doğru olmaz. Düzeltilmiş sürümde ikinci turda −2, −5'ten büyük çıkar ve enb güncellenir. Döngü her iki sürümde de dizinin uzunluğu kadar döner; zaman maliyeti O(n), ek bellek O(1)'dir.
Bu testin bir değeri daha vardır: artık kalıcıdır. Kod bir gün yeniden değiştirilir ve aynı kusur geri gelirse, tumuNegatifDizi testi onu hemen yakalar. Düzeltmeden sonra aynı testlerin yeniden çalıştırılıp yeşile dönmesi, düzeltmenin işe yaradığının kanıtıdır.
Düzeltilmiş sürüm boş dizi için de bir soru doğurur. dizi[0] boş dizide ArrayIndexOutOfBoundsException fırlatır. İlk sürüm aynı girdi için sessizce 0 döndürürdü; bu da yanlış bir cevaptır, çünkü boş dizinin en büyük öğesi yoktur. Bir metodun boş girdide ne yapacağı açıkça kararlaştırılmalı ve bu karar da bir testle sınanmalıdır, örneğin assertThrows(ArrayIndexOutOfBoundsException.class, () -> DiziIslem.enBuyuk(new int[0])).
7.4İyi test tasarımı
JUnit testleri yazmayı kolaylaştırır, ama hangi testlerin yazılacağına karar vermez. Bir metodun girdi uzayı çoğu zaman çok büyüktür: int tipinde tek bir parametre bile dört milyardan fazla değer alabilir. Hepsini denemek imkânsızdır. İyi test tasarımı, az sayıda ama doğru seçilmiş girdiyle hataları yakalama sanatıdır.
7.4.1Sınır değerleri ve denklik sınıfları
Örnek metot, 0–100 arasındaki bir puanı harf notuna çevirir.
public class Not {
public static String harfNotu(int puan) {
if (puan < 0 || puan > 100) {
throw new IllegalArgumentException("Geçersiz puan: " + puan);
}
if (puan >= 90) return "AA";
if (puan >= 80) return "BA";
if (puan >= 70) return "BB";
if (puan >= 60) return "CB";
if (puan >= 50) return "CC";
return "FF";
}
}Metot önce aralık dışı puanları reddeder, sonra yukarıdan aşağıya eşikleri dener. Hangi puanlarla test edilmeli? 101 farklı geçerli puan vardır; hepsini yazmak hem sıkıcıdır hem de gereksizdir. 61, 65 ve 68 puanları aynı yoldan geçer ve aynı sonucu (CB) verir. Biri doğruysa ötekilerin de doğru olması beklenir.
Denklik sınıfı (equivalence class), program için aynı davranışa yol açan girdilerin kümesidir. Bir denklik sınıfından tek bir temsilci seçmek yeterlidir. Sınır değer analizi (boundary value analysis) ise her denklik sınıfının uç değerlerini ve geçersiz bölgenin ilk değerlerini test girdisi olarak seçer.
Sınır değerlerinin neden bu kadar önemli olduğu bir gözlemle açıklanır: hatalar en çok sınırlarda olur. Programcılar > yerine >=, < 100 yerine <= 100 yazmayı sık sık karıştırır. Bu tür bir kusur, aralığın ortasındaki girdilerde hiç görünmez. 65 puanı puan >= 60 ile de puan > 60 ile de CB verir. Fark yalnız tam sınırda, 60'ta ortaya çıkar.
Şekil 7.7 seçilen değerleri gösterir. Her aralığın iki ucu sınanır: 49 ve 50, FF ile CC arasındaki sınırı iki yandan kuşatır; 89 ve 90, BA ile AA arasındakini. Geçersiz bölgenin ilk değerleri (−1 ve 101) de seçilir, çünkü aralık denetimindeki bir kusur ancak onlarla görülür. Geçerli bölgenin uçları (0 ve 100) da unutulmaz.
harfNotu için eksiksiz bir sınır değer kümesi nasıl kurulur?
Denklik sınıfları: geçersiz alt (< 0), FF (0–49), CC (50–59), CB (60–69), BB (70–79), BA (80–89), AA (90–100), geçersiz üst (> 100). Toplam sekiz sınıf vardır.
Her geçerli sınıfın iki ucu alınır: 0, 49, 50, 59, 60, 69, 70, 79, 80, 89, 90, 100. Geçersiz bölgenin ilk değerleri eklenir: −1 ve 101. Toplam 14 girdi çıkar.
Bu küme 101 olası geçerli puanın yalnız 12'sini dener, ama her eşiği iki yandan sınar. Herhangi bir >= yanlışlıkla > yazılsaydı, o eşiğin alt ucu yanlış harfi verirdi ve test kalırdı.
7.4.2Parametreli testler
Sınır değerleri seçildikten sonra her biri için ayrı bir test metodu yazmak gerekir mi? Sekiz neredeyse aynı metot yazmak hem uzun hem de kopyala-yapıştır hatalarına açıktır. JUnit 5 bunun yerine parametreli testi sunar.
class NotTest {
@ParameterizedTest
@CsvSource({"0, FF", "49, FF", "50, CC", "59, CC",
"60, CB", "89, BA", "90, AA", "100, AA"})
void sinirDegerleri(int puan, String beklenen) {
assertEquals(beklenen, Not.harfNotu(puan));
}
@Test
void aralikDisiPuanReddedilir() {
assertThrows(IllegalArgumentException.class,
() -> Not.harfNotu(-1));
assertThrows(IllegalArgumentException.class,
() -> Not.harfNotu(101));
}
}@ParameterizedTest etiketi, aynı test metodunun farklı verilerle tekrar tekrar çalışacağını belirtir. Veriler @CsvSource ile verilir. Her dizgi bir test çağrısıdır. Virgülle ayrılan değerler sırayla metodun parametrelerine dönüştürülür: "50, CC" dizgisinde "50" bir int'e çevrilip puan'a, "CC" ise beklenen'e verilir. Ad, CSV (comma-separated values, virgülle ayrılmış değerler) dosya biçiminden gelir.
Parametreli testler junit-jupiter-params modülündedir. Gradle'a eklenen junit-jupiter paketi bu modülü de içerir; ayrıca bir şey eklemek gerekmez. Sınıf çalıştırıldığında rapor her veri satırını ayrı bir test olarak gösterir:
sinirDegerleri(int, String)
✔ [1] 0, FF
✔ [2] 49, FF
✔ [3] 50, CC
✔ [4] 59, CC
✔ [5] 60, CB
✔ [6] 89, BA
✔ [7] 90, AA
✔ [8] 100, AA
✔ aralikDisiPuanReddedilir()
9 test · 9 başarılıRaporda 9 test vardır: parametreli testin 8 çağrısı ve aralık dışı puanları sınayan sıradan test. Kalan bir satır olsaydı, köşeli parantez içindeki sıra numarası ve değerler hangi verinin sorun çıkardığını hemen gösterirdi. Aralık dışı test, assertThrows'u iki kez çağırır: biri −1, biri 101 için.
Örnek Örnek 7.3'teki eksiksiz kümeyle karşılaştırıldığında bu test birkaç sınırı atlar: 69/70 (CB/BB) ve 79/80 (BB/BA) çiftleri listede yoktur. BB aralığı hiç sınanmaz. @CsvSource'a dört dizgi daha eklemek bu boşluğu kapatır; test metodunun kendisi hiç değişmez. Parametreli testin asıl gücü budur: yeni bir durum eklemek tek satırlık iştir.
7.4.3Kod izleme: parametreli test
Parametreli testin çalışmasını puan ekseni üzerinde izleyelim. JUnit önce @CsvSource'taki 8 dizgiyi okur; her biri ayrı bir test çağrısı olacaktır.
"0, FF" virgülden bölünür; "0" int'e çevrilip puan'a, "FF" beklenen'e verilir. Sonuç "FF", test geçer.| Çağrı | puan | beklenen | İlk doğru koşul | Dönen | Rolü | Sonuç |
|---|---|---|---|---|---|---|
| [1] | 0 | FF | hiçbiri | FF | geçerli bölgenin ilk değeri | ✔ |
| [2] | 49 | FF | hiçbiri | FF | FF'nin üst ucu | ✔ |
| [3] | 50 | CC | puan >= 50 | CC | CC'nin alt ucu | ✔ |
| [4] | 59 | CC | puan >= 50 | CC | CC'nin üst ucu | ✔ |
| [5] | 60 | CB | puan >= 60 | CB | CB'nin alt ucu | ✔ |
| [6] | 89 | BA | puan >= 80 | BA | BA'nın üst ucu | ✔ |
| [7] | 90 | AA | puan >= 90 | AA | AA'nın alt ucu | ✔ |
| [8] | 100 | AA | puan >= 90 | AA | geçerli bölgenin son değeri | ✔ |
Tablo 7.9 her çağrının metodun içinde hangi yoldan geçtiğini gösterir. 49 satırı ilginçtir: koşullardan hiçbiri doğru çıkmaz ve metot en alttaki return "FF"'ye ulaşır. Koşul puan >= 50 yerine yanlışlıkla puan > 48 yazılsaydı, 49 CC verirdi ve [2] kalırdı. 100 satırı ise puan > 100 denetiminin doğru yazıldığını kanıtlar. Denetim yanlışlıkla puan >= 100 olsaydı, 100 puan istisna fırlatırdı.
harfNotu içinde puan >= 50 yerine yanlışlıkla puan > 50 yazılsın. Parametreli testin hangi satırı kalır?
49: hiçbir koşul doğru değil, FF. Beklenen FF; geçer.
50: 50 > 50 yanlış; metot FF döndürür. Beklenen CC; kalır: expected: <CC> but was: <FF>.
59: 59 > 50 doğru, CC. Beklenen CC; geçer.
Yalnız [3] kalır. 49 ve 59 bu kusuru göremez; hatayı yalnız tam sınırdaki değer yakalar.
7.4.4İyi bir birim testinin özellikleri: FIRST
İyi bir birim testinin özellikleri, İngilizce baş harflerinden oluşan FIRST kısaltmasıyla özetlenir.
- F · Hızlı (fast)
- Milisaniyeler içinde biter; binlercesi her değişiklikten sonra çalıştırılabilir. Birim testinde dosya, ağ ve veri tabanı kullanılmaz. Bir test kümesi beş dakika sürerse programcılar onu çalıştırmayı bırakır.
- I · Bağımsız (independent)
- Başka bir teste, test sırasına ya da önceki testin bıraktığı duruma bağlı değildir. JUnit'in her test için yeni nesne oluşturması bu özelliği destekler.
- R · Tekrarlanabilir (repeatable)
- Her makinede, her zaman aynı sonucu verir. Rastgelelik ve saat gibi değişken kaynaklar kontrol altına alınır.
- S · Kendini doğrulayan (self-validating)
- Sonuç ✔ ya da ✘'dir. Çıktıyı bir insanın okuyup yorumlamasına gerek yoktur.
- T · Zamanında (timely)
- Kodla birlikte, hatta koddan önce yazılır. Aylar sonraya bırakılan test genellikle hiç yazılmaz.
Bu beşine bir özellik daha eklenir: okunabilirlik. Açıklayıcı bir ad, AAA düzeni ve test başına tek davranış testi okunur kılar. İyi yazılmış bir test, kodun nasıl kullanılacağını gösteren bir belgedir. Yeni gelen bir programcı BankaHesabiTest'i okuyarak cek metodunun yetersiz bakiyede ne yaptığını öğrenir.
7.4.5Birim testi mi, tümleştirme testi mi?
Gerçek sınıflar çoğu zaman tek başına çalışmaz. Bir sipariş sınıfı fiyatları veri tabanından okur, sonucu bir dosyaya yazar, e-posta gönderir. Böyle bir sınıfı gerçek veri tabanıyla birlikte sınayan test artık birim testi değildir.
| Birim testi | Tümleştirme (entegrasyon) testi | |
|---|---|---|
| Kapsam | Tek sınıf / metot, yalıtılmış | Birlikte çalışan birkaç bileşen |
| Hız | Milisaniyeler | Saniyeler (veri tabanı, dosya, ağ) |
| Dış bağımlılık | Yerine basit bir sahte nesne (test dublörü) konur | Gerçek veri tabanı, dosya, servis |
| Bulduğu hata | Yerel mantık hatası; yeri hemen belli | Uyumsuz arayüz, yanlış yapılandırma |
Tümleştirme testi (integration test), birlikte çalışan bileşenleri gerçek bağımlılıklarıyla sınar. Modül ve alt sistem testleri bu türdendir. Birim testinde ise dış bağımlılığın yerine onu taklit eden basit bir nesne konur. Bu nesneye test dublörü (test double) denir; adını, tehlikeli sahnelerde oyuncunun yerini alan dublörden alır. Örneğin fiyatı veri tabanından okuyan bir arayüzün yerine, her ürün için sabit fiyat döndüren küçük bir sınıf yazılır. Test hem hızlanır hem de veri tabanının o anki içeriğinden bağımsız olur.
Tablo 7.10 iki test türünün birbirinin yerini tutmadığını da gösterir. Birim testleri mantık hatalarını hızlı ve kesin yerle bulur. Tümleştirme testleri ise parçaların birbirine doğru bağlandığını doğrular. Test piramidindeki gibi birim testleri çok, tümleştirme testleri daha az sayıda yazılır.
Test adları da davranışı anlatmalıdır. Aşağıdaki üç addan ilki hiçbir şey söylemez; öteki ikisi test kaldığında hangi kuralın bozulduğunu açıklar.
@Test void test1() { … }
@Test void altiUrundeIndirimVar() { … }
@Test void besUrundeIndirimYok() { … }Birim testinde gerçeklemenin iç ayrıntısı değil, dışarıdan görünen davranış sınanır. Bir metodun içinde hangi yardımcı metodu çağırdığı değil, ne döndürdüğü önemlidir. İç ayrıntıya bağlı testler, davranış değişmese bile her yeniden düzenlemede kırılır. Birim testinde Thread.sleep, gerçek ağ ve dosya kullanılmaz; test hem yavaşlar hem de rastgele kalmaya başlar. Testler arasında static bir liste gibi değişen bir durum da paylaşılmaz.
7.5Regresyon ve otomasyon
Bir testin değeri yazıldığı gün bitmez. Kod değiştikçe test yeniden çalışır ve eski davranışın korunup korunmadığını denetler. Testleri bu kadar değerli kılan, otomatik olmaları ve her değişiklikten sonra yeniden çalıştırılabilmeleridir.
7.5.1Regresyon testi
Regresyon testi (regression testing), yapılan değişikliklerden sonra önceden çalışan her şeyin hâlâ çalıştığını doğrulamaktır. Regresyon “geriye gidiş” demektir: düzelmiş bir şeyin yeniden bozulması. Yöntem basittir: daha önce yazılmış bütün testler her değişiklikten sonra yeniden çalıştırılır.
cek testi kalıyor: değişiklik eski bir davranışı bozmuştur.Şekil 7.10 tipik bir regresyonu gösterir. Programcı yeni bir özellik eklerken cek metoduna dokunmuş ve farkında olmadan onu bozmuştur. Testler olmasaydı bu kusur ancak bir müşteri parasını çekemediğinde fark edilirdi. Regresyon testi kusuru değişikliğin hemen ardından, değişikliği yapan kişinin aklı hâlâ o koddayken yakalar.
Regresyon testinin işe yaraması için testlerin iki özelliği olmalıdır.
- Belirleyici (deterministic): aynı koşullarda her seferinde aynı sonucu vermelidir. Bazen geçip bazen kalan teste kararsız (flaky) test denir. Kararsız test güveni yok eder: kaldığında “yine o test” denip geçilir ve gerçek bir hata da gözden kaçar.
- Tekrarlanabilir (repeatable): farklı zamanlarda, farklı makinelerde ve farklı durumlarda aynı biçimde tekrar edilebilmelidir. Yalnız programcının bilgisayarında geçen bir test, sunucuda kaldığında bir şey söylemez.
7.5.2Rastgelelik içeren kodu belirleyici test etmek
Rastgele sayı üreten bir kod nasıl test edilir? Bir zar sınıfı her atışta 1–6 arasında farklı bir sayı döndürür; beklenen sonucu önceden yazmak imkânsız gibi görünür. Çözüm, rastgeleliğin kaynağını sınıfın dışına çıkarmaktır.
import java.util.Random;
public class Zar {
private final Random rastgele;
public Zar(Random rastgele) { // dışarıdan verilir
this.rastgele = rastgele;
}
public int at() {
return rastgele.nextInt(6) + 1;
}
}Zar sınıfı kendi Random nesnesini oluşturmaz; onu yapıcısından alır. Bu tasarıma bağımlılık enjeksiyonu (dependency injection) denir: bir nesnenin ihtiyaç duyduğu başka bir nesne dışarıdan verilir. Program gerçek kullanımda new Zar(new Random()) yazar ve her seferinde farklı sayılar alır. Test ise sabit bir tohumla oluşturulmuş bir Random verir.
Random sınıfı gerçekte rastgele değil, sözde rastgele (pseudo-random) sayılar üretir. Sayılar bir formülle, başlangıç değerinden türetilir. Bu başlangıç değerine tohum (seed) denir. Aynı tohumla oluşturulan iki Random nesnesi aynı sayı dizisini üretir. 42 tohumuyla yapılan beş atış her makinede ve her çalıştırmada şu sonucu verir:
3 4 1 3 1class ZarTest {
@Test
void ayniTohumAyniSonuclar() {
Zar z1 = new Zar(new Random(42));
Zar z2 = new Zar(new Random(42));
for (int i = 0; i < 100; i++) {
assertEquals(z1.at(), z2.at());
}
}
}Test aynı tohumla iki zar oluşturur ve 100 atış boyunca ikisinin hep aynı sayıyı verdiğini doğrular. Test her çalıştırmada aynı sonucu verir; yani belirleyicidir. Zar sınıfının içinde new Random() yazılmış olsaydı, testin hiçbir şekilde tohumu kontrol etme imkânı kalmazdı.
Belirleyicilik başka testler de yazmayı mümkün kılar. assertEquals(3, new Zar(new Random(42)).at()) her zaman geçer, çünkü 42 tohumunun ilk atışı her zaman 3'tür. Aynı doğrulama tohumsuz new Random() ile yazılsaydı test ancak 1/6 olasılıkla geçerdi: tam bir kararsız test. Tohumsuz zarla yazılabilecek anlamlı bir test ise sonucun aralığını denetler: int x = z.at(); assertTrue(x >= 1 && x <= 6); türünden bir doğrulama her zarda doğrudur.
Saate bağlı kodu doğrudan test etmek. LocalDate.now() çağıran bir “doğum günü indirimi” metodu, testin hangi gün çalıştırıldığına göre farklı sonuç verir. Test yazıldığı gün geçer, ertesi gün kalır. Çözüm zar örneğindekiyle aynıdır: tarih ya da saat kaynağı dışarıdan verilir ve test sabit bir tarih kullanır.
7.5.3Otomatik testler ve sürekli entegrasyon
Kod elle test edilmek yerine otomatik testlerle sınanmalıdır. Harcanan çaba aşağı yukarı aynıdır: elle deneme için de girdiler seçilir, çıktılar incelenir. Fark sonunda ortaya çıkar. Elle denemeden geriye hiçbir şey kalmaz; otomatik denemeden ise istenildiği zaman yeniden çalıştırılabilen bir test kalır. Bu test her derlemede, her değişiklikte tekrar çalışabilir.
Yazılım bir kez doğru çalıştı diye bir daha test etmenin gereksiz olduğunu düşünmek yanlıştır. Kod değişir, kullanılan kütüphaneler yeni sürümlere geçer, programın çalıştığı ortam değişir. Dün doğru çalışan program bugün bozulabilir.
| Elle | Otomatik | |
|---|---|---|
| İlk maliyet | Düşük | Biraz yüksek |
| Tekrar maliyeti | Her seferinde aynı | ≈ 0 |
| Hız | Dakikalar | Saniyeler |
| Güvenilirlik | İnsan hatası | Her seferinde aynı |
| Regresyon | Pratikte yapılmaz | Her değişiklikte |
| Belge değeri | Yok | Kullanım örneği |
Tablo 7.11 iki yaklaşımı karşılaştırır. Otomatik testin ilk maliyeti biraz yüksektir: test kodu yazılmalıdır. Ama her tekrarın maliyeti neredeyse sıfırdır. Bir test 100 kez çalıştırılacaksa, otomatik olanı açık ara daha ucuzdur. Regresyon testi pratikte yalnız otomatik testlerle yapılabilir; hiçbir programcı her değişiklikten sonra yüz durumu elle denemez.
Otomasyonun son adımı testlerin programcıya hiç sorulmadan çalışmasıdır. Sürekli entegrasyon (continuous integration, CI) sistemleri bunu yapar. GitHub Actions ve Jenkins bu sistemlerin yaygın örnekleridir.
Şekil 7.11 tipik bir hattı gösterir. Programcı değişikliğini ortak depoya gönderir (git push). CI sunucusu kodu indirir, derler ve bütün testleri çalıştırır. Testlerden biri kalırsa değişiklik ana koda birleştirilmez ve programcıya haber verilir. Böylece hiçbir regresyon fark edilmeden ana koda giremez.
7.5.4Test güdümlü geliştirme
Testler genellikle koddan sonra yazılır. Bazı programcılar sırayı tersine çevirir: önce test, sonra kod.
Test güdümlü geliştirme (test-driven development, TDD), testi koddan önce yazan geliştirme yöntemidir. Yöntem kısa döngülerle ilerler; her döngü üç adımdan oluşur: kırmızı, yeşil, yeniden düzenleme.
Şekil 7.12 döngüyü gösterir. Kırmızı adımında henüz var olmayan bir davranış için test yazılır. Test kalır, hatta metot henüz yazılmadığı için derlenmez bile. Yeşil adımında testi geçirecek en basit kod yazılır. Bu adımda kodun güzel olması gerekmez; testin geçmesi yeterlidir. Yeniden düzenleme (refactoring) adımında kod temizlenir: tekrarlar kaldırılır, adlar düzeltilir. Testler yeşil kaldığı sürece davranış korunur.
harfNotu metodu TDD ile nasıl geliştirilir?
Döngü 1, kırmızı: assertEquals("CC", Not.harfNotu(50)) testi yazılır. Not sınıfı henüz yoktur; test derlenmez.
Döngü 1, yeşil: en basit kod yazılır: return "CC";. Test geçer. Kod açıkça eksiktir, ama elimizdeki tek test bu kadarını ister.
Döngü 2, kırmızı: assertEquals("FF", Not.harfNotu(49)) eklenir. Metot hep CC döndürdüğü için yeni test kalır.
Döngü 2, yeşil: if (puan >= 50) return "CC"; return "FF";. İki test de geçer.
Her yeni not aralığı yeni bir döngüdür. Sonunda harfNotu'nun her satırı bir testle doğmuş olur ve regresyon testleri kendiliğinden birikir.
TDD'nin en büyük kazancı, test edilmemiş kod bırakmamasıdır. Bir yan kazancı da tasarımı iyileştirmesidir. Test önce yazıldığı için programcı sınıfı önce bir kullanıcı gözüyle düşünür: metodun adı ne olmalı, hangi parametreleri almalı, hatalı girdide ne yapmalı? Test edilmesi zor bir sınıf genellikle kötü tasarlanmış bir sınıftır; TDD bunu kod yazılmadan fark ettirir.
7.6Hata ayıklama
Test bir arızayı haber verir: expected: <-2> but was: <0>. Ama kusurun hangi satırda olduğunu her zaman söylemez. Küçük bir metotta kusur kodu okuyarak bulunabilir; yüzlerce satırlık bir programda ise program çalışırken içine bakmak gerekir. Bu işin aracı hata ayıklayıcıdır.
7.6.1Hata ayıklayıcı
Hata ayıklayıcı (debugger), çalışan bir programın durumunu incelemeyi sağlayan araçtır. Programı istenen satırda durdurur, satır satır ilerletir ve her an değişkenlerin değerlerini gösterir. Programın durdurulacağı satıra kesme noktası (breakpoint) denir.
Bir hata ayıklayıcıyla yapılabilecekler şunlardır:
- programı komut komut (satır satır) çalıştırmak;
- çalışan programın kaynak kodunu o anki satırla birlikte görmek;
- değişkenlerin değerlerini görüntülemek ve gerekirse değiştirmek;
- programa kesme noktaları eklemek ve programı bir kesme noktasına kadar çalıştırmak;
- çökmüş ya da durdurulmuş bir programın durumunu bir döküm dosyası üzerinden incelemek.
Son madde biraz açıklama ister. Program çöktüğünde ya da donduğunda o anki durum bir dosyaya yazılabilir. C dilinde bu dosyaya core file denir. Java'da iki tür döküm vardır. Heap dökümü (.hprof dosyası) heap'teki bütün nesneleri içerir. İş parçacığı dökümü ise her iş parçacığının o anda hangi satırda olduğunu gösterir. Döküm, programın o anki fotoğrafıdır; program yeniden çalıştırılmadan incelenebilir.
i = 2, toplam = 1.Şekil 7.13 bir IDE'nin hata ayıklama görünümünü sadeleştirerek gösterir. Program toplam += i; satırında durmuştur. Satır henüz çalışmamıştır: döngünün ilk turunda toplam 1 olmuş, şimdi ikinci tur başlamıştır. Kesme noktasında duran program istenildiği kadar bekler. Programcı değişkenlere bakar, bir sonraki adıma geçer ya da programı devam ettirir.
IntelliJ IDEA, Eclipse, NetBeans ve VS Code'un hepsinde hata ayıklayıcı yerleşiktir. Kesme noktası satır numarasının yanına tıklanarak konur, program da Debug düğmesiyle başlatılır. Sıradan Run düğmesiyle başlatılan program kesme noktalarında durmaz.
Hata ayıklayıcının yaygın bir alternatifi println ile ayıklamadır: şüpheli yerlere System.out.println("i = " + i) gibi satırlar eklenir. Bu yöntem hızlıdır ve her yerde çalışır. Ama kodu kirletir, her değişiklikte yeniden derleme ister ve eklenen satırlar sonradan silinmeyi unutulur. Hata ayıklayıcı ise kodu hiç değiştirmez; hangi değişkenin izleneceğine program çalışırken karar verilir.
7.6.2Kesme noktaları ve adım komutları
Program bir kesme noktasında durduktan sonra adım komutlarıyla ilerletilir. Komutların adları IDE'lerde İngilizcedir; kısayolları IDE'ye göre değişir.
| Komut | Ne yapar? | IntelliJ | Eclipse |
|---|---|---|---|
| Kesme noktası (breakpoint) | Program bu satıra gelince, satır çalışmadan önce durur | Ctrl+F8 | Ctrl+Shift+B |
| Step Over (üzerinden geç) | Satırı çalıştırır; metot çağrısı varsa içine girmeden tamamlar | F8 | F6 |
| Step Into (içine gir) | Satırdaki metot çağrısının içine girer | F7 | F5 |
| Step Out (dışarı çık) | Bulunulan metodu bitirir, çağıran metoda döner | Shift+F8 | F7 |
| Resume (devam) | Bir sonraki kesme noktasına kadar çalışır | F9 | F8 |
| Evaluate / Watch | Bir ifadenin (ör. dizi[i] > enb) değerini anlık hesaplar, sürekli izler | Alt+F8 | Ctrl+Shift+I |
Tablo 7.12'teki komutların en çok karıştırılanları Step Over ile Step Into'dur. İkisi de bulunulan satırı çalıştırır. Fark, satırda bir metot çağrısı olduğunda ortaya çıkar. Step Into o metodun içine girer ve ilk satırında durur. Step Over ise metodu tek bir adımda çalıştırır ve bir sonraki satırda durur. Çağrılan metodun doğru çalıştığından emin olunuyorsa Step Over zaman kazandırır. Kusurun o metodun içinde olduğundan şüpheleniliyorsa Step Into kullanılır.
Kısayollar varsayılan tuş eşlemesine göredir; menüden de (Run / Debug) aynı komutlara ulaşılır. Tablodaki kısayolları ezberlemek gerekmez. Komutların ne yaptığını bilmek yeterlidir.
Sıradan kesme noktasının iki güçlü çeşidi vardır. Koşullu kesme noktası yalnız verilen koşul doğruysa durur. Bir döngü 10.000 kez dönüyorsa ve kusur yalnız 999. turda görünüyorsa, kesme noktasına i == 999 koşulu yazılır. Program 998 tur boyunca durmadan çalışır ve tam aranan anda durur. İstisna kesme noktası ise belirli bir istisna (ör. NullPointerException) fırlatıldığı anda durur. İstisnanın yakalandığı yerde değil, fırlatıldığı yerde durulduğu için o anki değişkenler henüz kaybolmamıştır.
7.6.3Kod izleme: hata ayıklayıcıyla kusuru bulmak
Hata zincirindeki arızaya bu kez hata ayıklayıcıyla yaklaşalım. Kodu okumadan, yalnız ekrandaki En yüksek: 0 arızasından yola çıkarak kusuru bulmak istiyoruz.
public class HataAyikla {
public static int enBuyuk(int[] dizi) {
int enb = 0;
for (int i = 0; i < dizi.length; i++) {
if (dizi[i] > enb) {
enb = dizi[i];
}
}
return enb;
}
public static void main(String[] args) {
int[] sicaklik = {-5, -2, -8};
int sonuc = enBuyuk(sicaklik);
System.out.println("En yüksek: " + sonuc);
}
}Kesme noktası 5. satıra, if (dizi[i] > enb) koşuluna konur. main'in ilk satırına (13) da bir kesme noktası konur ve program Debug ile başlatılır. Program 13. satırda durur; bir Step Over ile 14. satırdaki enBuyuk(sicaklik) çağrısına gelinir. Step Into ile enBuyuk'un içine girilir. Yığına yeni bir çerçeve eklenir; bu çerçevedeki dizi parametresi, main'deki sicaklik dizisinin aynısını gösterir. Dizi kopyalanmamıştır, yalnız referansı aktarılmıştır. Resume ile program kesme noktasına kadar ilerler.
i = 0, dizi[0] = -5, enb = 0. İzlenen ifade dizi[i] > enb yanlış (false) çıkar.İzleme listesine dizi[i] > enb ifadesi eklenir. Hata ayıklayıcı bu ifadeyi her durmada yeniden hesaplar. İlk durmada ifade yanlıştır: −5, 0'dan büyük değildir (Şekil 7.14). Resume ile döngünün sonraki turlarına geçilir. i = 1 için −2 > 0 yine yanlıştır; enb değişmez. i = 2 için −8 > 0 da yanlıştır. Bu noktada şüphe belirginleşir: enb hiç güncellenmiyor.
enb hâlâ 0'dır ve 0 döndürülür. Kusur bulundu: başlangıç değeri 0, tüm öğeler negatifse yanlış.| Adım | Komut | Satır | i | dizi[i] | enb | dizi[i] > enb |
|---|---|---|---|---|---|---|
| 1 | Debug | 13 (main) | — | — | — | — |
| 2 | Step Over | 14 (main) | — | — | — | — |
| 3 | Step Into | 3 | — | — | henüz yok | — |
| 4 | Resume | 5 | 0 | −5 | 0 | false |
| 5 | Resume | 5 | 1 | −2 | 0 | false |
| 6 | Resume | 5 | 2 | −8 | 0 | false |
| 7 | Step Over (birkaç kez) | 9 (return) | — | — | 0 | — |
Tablo 7.13 oturumu özetler. Üçüncü adımda yeni çerçeve açılır; enb henüz atanmamıştır. Son adımda döngü biter ve return enb; satırına gelinir; dönecek değer 0'dır. Kusurun yeri artık açıktır: int enb = 0;. Doğrusu int enb = dizi[0];'dır. Program devam ettirildiğinde arıza ekranda görünür: En yüksek: 0.
Düzeltmeden sonra bu durum bir birim testi olarak eklenir. Hata ayıklayıcıyla bulunan her kusur, onu yakalayan bir testle kalıcı hâle getirilir. Böylece aynı kusur geri gelirse hata ayıklayıcıya bir daha gerek kalmaz; test onu hemen haber verir.
7.6.4Step Into, Step Over, Step Out
Üç adım komutunun farkını iç içe metot çağrıları olan bir programda izleyelim.
public class AdimOrnek {
static int kare(int x) {
return x * x;
}
static int kareToplam(int a, int b) {
int k1 = kare(a);
int k2 = kare(b);
return k1 + k2;
}
public static void main(String[] args) {
int s = kareToplam(3, 4);
System.out.println("s = " + s);
}
}Satır numaraları şöyledir: 1. satır sınıf başlığı, 3. satır return x * x;, 7. satır int k1 = kare(a);, 8. satır int k2 = kare(b);, 9. satır return k1 + k2;, 13. satır int s = kareToplam(3, 4);, 14. satır println, 15. satır main'in kapanış ayracıdır. Kesme noktası 13. satırdadır ve program orada durmuştur. Satır henüz çalışmamıştır.
| Komut | Geçilen satır | Yığındaki çerçeveler | Görünen değişkenler | Açıklama |
|---|---|---|---|---|
| (kesme noktası) | 13 | main | — | satır henüz çalışmadı |
| Step Into | 7 | main → kareToplam | a = 3, b = 4 | kareToplam'ın içine girildi |
| Step Over | 8 | main → kareToplam | a, b, k1 = 9 | kare(3) çalıştı ama içine girilmedi |
| Step Into | 3 | main → kareToplam → kare | x = 4 | bu kez kare(4)'ün içindeyiz |
| Step Out | 8 | main → kareToplam | a, b, k1 = 9 | kare 16 döndürdü; atama tamamlanmak üzere |
| Step Over | 9 | main → kareToplam | k1 = 9, k2 = 16 | atama tamamlandı |
| Step Out | 13 | main | — | kareToplam 25 döndürdü |
| Step Over | 14 | main | s = 25 | atama tamamlandı |
| Step Over | 15 | main | s = 25 | çıktı yazıldı: s = 25 |
Tablo 7.14 üç komutun farkını açıkça gösterir. İlk Step Into, kareToplam çağrısının içine girer ve yığına yeni bir çerçeve ekler. Üçüncü sütundaki çerçeve zinciri, sözlükteki adıyla çağrı yığınıdır (call stack). Ardından gelen Step Over, 7. satırdaki kare(3) çağrısının içine girmez; çağrıyı bir bütün olarak çalıştırır ve 8. satıra geçer. k1 artık 9'dur.
8. satırda bu kez Step Into seçilir ve kare(4)'ün içine girilir. Yığında üç çerçeve vardır. kare çerçevesinde yalnız x görünür; a, b ve k1 başka bir çerçevededir. Step Out, kare'nin kalanını tek adımda çalıştırır ve çağıran satıra döner. Dikkat edilecek ince nokta şudur: Step Out'tan sonra hâlâ 8. satırdayız. Metot 16 döndürmüştür, ama bu değer henüz k2'ye atanmamıştır. Atama ancak bir sonraki Step Over'da tamamlanır.
İkinci Step Out, kareToplam'ı bitirir ve main'deki 13. satıra döner; dönüş değeri 25'tir. Son iki Step Over önce atamayı (s = 25), sonra çıktıyı tamamlar. Bundan sonra Resume ile program sonlanır.
Program 7. satırda (int k1 = kare(a);) duruyor ve a = 3. Art arda iki Step Over yapılırsa nerede olunur ve değişkenler ne olur?
Birinci Step Over kare(3)'ü içine girmeden çalıştırır ve atamayı tamamlar: 8. satır, k1 = 9.
İkinci Step Over kare(4)'ü çalıştırır: 9. satır, k1 = 9, k2 = 16.
Hiçbir zaman kare'nin içine girilmez. Yığında hep iki çerçeve vardır: main ve kareToplam.
7.6.5Komut satırında hata ayıklama: jdb
IDE'lerin hata ayıklayıcıları arka planda aynı düzeneği kullanır: JVM'in hata ayıklama arayüzü. JDK bu arayüzü komut satırından kullanan bir araçla gelir: jdb (Java Debugger). jdb, IDE'lerin düğmelerle yaptığını komutlarla yapar. IDE kurulu olmayan bir sunucuda ya da yalnız terminal erişimi olan bir makinede işe yarar.
public class Jdb {
static int kare(int x) {
int sonuc = x * x;
return sonuc;
}
public static void main(String[] args) {
int toplam = 0;
for (int i = 1; i <= 3; i++) {
toplam += kare(i);
}
System.out.println("Toplam: " + toplam);
}
}Program 1'den 3'e kadar sayıların karelerini toplar: 1 + 4 + 9 = 14. Hata ayıklayıcıyla çalıştırmak için önce -g seçeneğiyle derlenir, sonra java yerine jdb ile başlatılır:
javac -g Jdb.java
jdb Jdb-g seçeneği yerel değişkenlerin adlarını da sınıf dosyasına yazar. Bu seçenek olmadan derlenen bir sınıfta locals komutu değişken adlarını gösteremez; çünkü sınıf dosyasında yalnız sıra numaraları vardır.
| Komut | Anlamı |
|---|---|
stop at Sınıf:satır | kesme noktası koyar |
clear Sınıf:satır | kesme noktasını kaldırır |
run / cont | başlatır / devam eder |
step / next / step up | Step Into / Over / Out |
where | çağrı yığınını gösterir |
locals, print x | yerel değişkenleri, bir değeri gösterir |
Tablo 7.15'deki komutlar IDE komutlarıyla birebir eşleşir. step Step Into'ya, next Step Over'a, step up Step Out'a, cont da Resume'a karşılık gelir.
jdb çalışan bir uygulamaya da bağlanabilir. Bunun için uygulama özel bir seçenekle başlatılır: java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 Uygulama. Bu seçenek JVM'e hata ayıklama ajanını yükletir ve 5005 numaralı ağ kapısını dinletir. suspend=y, bir hata ayıklayıcı bağlanana kadar programın beklemesini sağlar. Ardından jdb -attach 5005 komutu ya da bir IDE bu kapıya bağlanır. Uzak bir sunucudaki programda hata ayıklamanın yolu budur.
7.6.6Bir jdb oturumu
Aşağıdaki oturum JDK 21 ile çalıştırılarak alınmıştır; > ve main[1] ile başlayan satırlar jdb'nin komut istemidir.
> stop at Jdb:4
Deferring breakpoint Jdb:4.
It will be set after the class is loaded.
> run
VM Started: Set deferred breakpoint Jdb:4
Breakpoint hit: "thread=main", Jdb.kare(), line=4 bci=4
main[1] where
[1] Jdb.kare (Jdb.java:4)
[2] Jdb.main (Jdb.java:10)
main[1] locals
Method arguments:
x = 1
Local variables:
sonuc = 1stop at Jdb:4 komutu 4. satıra, return sonuc;'a kesme noktası koyar. Sınıf henüz yüklenmediği için jdb kesme noktasını erteler; sınıf yüklenince yerleştirir. run programı başlatır. Program 4. satıra gelince durur. İletideki bci=4, metodun bayt kodunda kaçıncı baytta durulduğunu gösterir; kaynak düzeyinde çalışırken bu sayı önemli değildir.
where komutu çağrı yığınını gösterir: en üstte kare (4. satır), altında onu çağıran main (10. satır). locals ise o anki çerçevedeki değişkenleri listeler. x = 1 metot argümanıdır, sonuc = 1 yerel değişkendir. Bu ilk turdur: i = 1 için kare(1) çağrılmıştır.
main[1] cont
Breakpoint hit: "thread=main", Jdb.kare(), line=4 bci=4
main[1] print sonuc
sonuc = 4
main[1] clear Jdb:4
Removed: breakpoint Jdb:4
main[1] cont
> Toplam: 14cont programı devam ettirir. Döngünün ikinci turunda kare(2) çağrılır ve program aynı kesme noktasında yeniden durur. print sonuc bu kez 4 gösterir. clear Jdb:4 kesme noktasını kaldırır; son cont ile program bir daha durmadan sonuna kadar çalışır ve Toplam: 14 yazar.
| Komut | Durulan yer | i (main) | x | sonuc | toplam |
|---|---|---|---|---|---|
run | kare, 4. satır | 1 | 1 | 1 | 0 |
cont | kare, 4. satır | 2 | 2 | 4 | 1 |
clear + cont | program biter | — | — | — | 14 |
Tablo 7.16'deki toplam sütunu, kesme noktasında durulduğu anda main'deki değerdir. İkinci durmada toplam 1'dir, çünkü ilk turun sonucu eklenmiş, ikinci turunki henüz eklenmemiştir. Kesme noktası 3. satıra (int sonuc = x * x;) konsaydı, locals komutu sonuc'u göstermezdi: satır henüz çalışmadığı için değişken o anda yoktur.
7.6.7Sistematik hata ayıklama
Hata ayıklayıcı güçlü bir araçtır, ama tek başına yöntem değildir. Rastgele kesme noktaları koyup kodu rastgele değiştirmek saatler sürebilir. Deneyimli programcılar beş adımlı bir yol izler.
- Yeniden üretmek. Arızayı her seferinde ortaya çıkaran en küçük girdi bulunur, örneğin
{-5, -2, -8}. Yeniden üretilemeyen bir hata ayıklanamaz. - Test olarak yazmak. Bu girdiyle kalan bir birim testi yazılır. Kırmızı test hatanın kanıtıdır ve düzeltmenin işe yaradığını da gösterecektir.
- Daraltmak. İzleme listesindeki ilk satırdan başlanır. Kesme noktasında değişkenler izlenir, sorunlu bölge ikiye bölünerek küçültülür. Aranan yer, bir değerin ilk kez yanlış olduğu satırdır.
- Hipotez kurmak ve sınamak. “
enb0 ile başladığı için güncellenmiyor” gibi bir açıklama önerilir ve hata ayıklayıcıda doğrulanır. Tahminle kod değiştirmek yeni hatalar doğurur. - Düzeltmek ve tüm testleri çalıştırmak. Yeni test yeşile dönmeli, eskiler yeşil kalmalıdır; yani regresyon olmamalıdır. Test kalıcı olarak test kümesinde kalır.
Üçüncü adımdaki “ikiye bölme” fikri ikili aramaya benzer. 200 satırlık bir hesabın sonucu yanlışsa önce ortadaki, 100. satıra kesme noktası konur. Değerler orada doğruysa kusur ikinci yarıdadır; yanlışsa ilk yarıdadır. Her adımda aranan bölge yarıya iner. 200 satır en çok sekiz adımda tek satıra iner, çünkü 28 = 256 ≥ 200'dür.
Daraltmaya yardım eden bir başka araç Java'nın assert deyimidir. assert, programcının “bu noktada şu koşul her zaman doğrudur” varsayımını koda yazmasını sağlar. Koşul yanlışsa program AssertionError fırlatır ve sorun ortaya çıktığı yerde yakalanır.
static double ortalama(int[] d) {
assert d.length > 0 : "boş dizi";
int t = 0;
for (int v : d) {
t += v;
}
return (double) t / d.length;
}public static void main(String[] a) {
System.out.println(ortalama(new int[] {1, 2}));
System.out.println(ortalama(new int[0]));
}$ java As
1.5
NaN
$ java -ea As
1.5
Exception in thread "main" java.lang.AssertionError: boş dizi
at As.ortalama(As.java:3)
at As.main(As.java:12)Çıktı önemli bir ayrıntıyı gösterir: assert deyimleri varsayılan olarak kapalıdır. Düz java As ile çalıştırılan program boş dizi için hiçbir uyarı vermez. 0 / 0 bölmesi double'da NaN (not a number, sayı değil) üretir; program sessizce yanlış bir değer yazar. -ea (enable assertions) seçeneğiyle çalıştırılınca varsayım denetlenir. Program boş diziyle çağrıldığı anda, 3. satırda AssertionError: boş dizi ile durur. Bu yüzden assert geliştirme ve test sırasında açılır, kullanıcıya giden programda ise kapalı kalır. Kullanıcı girdisini denetlemek için assert değil, istisna kullanılır; kapalı bir assert hiçbir şeyi engellemez.
7.7Analiz araçları
Birim testi ve hata ayıklayıcı programcının en sık kullandığı iki araçtır, ama her soruyu cevaplamazlar. “Program neden yavaş?”, “Testlerim kodun ne kadarını deniyor?”, “Bellek neden doluyor?” gibi sorular için başka araçlar gerekir. Bu kesimdeki araçların her biri tek bir soruya odaklanır.
7.7.1Profil oluşturucu
Profil oluşturucu (profiler), bir programın zamanını ve belleğini nerede harcadığını ölçen araçtır. Hangi metodun kaç kez çağrıldığını, ne kadar sürdüğünü ve ne kadar bellek ayırdığını gösterir.
Profil oluşturucu, kimin kimi çağırdığını gösteren çağrı grafiğini (call graph) çeşitli biçimlerde sunar: düz liste, ağaç ya da alev grafiği (flame graph). Alev grafiğinde her metot bir kutudur; kutunun genişliği o metotta harcanan zamanla orantılıdır. Geniş kutular hemen göze çarpar. Ölçümün teknik ayrıntıları kullanılan derleyiciye ve JVM sürümüne göre değişir.
Profil oluşturmanın temel kuralı kısadır: önce ölç, sonra iyileştir. Bir programın zamanının çoğu genellikle küçük bir bölgede, bir sıcak noktada (hot spot) harcanır. Programcının tahmini bu noktayı çoğu zaman ıskalar. Tahminle yapılan optimizasyon kodu karmaşıklaştırır ve yanlış yeri hızlandırır.
Eski JVM'lerde yerleşik bir profil oluşturucu vardı ve java -Xprof seçeneğiyle açılırdı. Bu seçenek Java 10'da kaldırıldı. Java 21'de aynı seçenek yalnız bir uyarı üretir ve yok sayılır:
OpenJDK 64-Bit Server VM warning: Ignoring option -Xprof; support was removed in 10.0Bugün Java'da başka araçlar kullanılır. JDK ile gelen JDK Flight Recorder (JFR) olayları kaydeder, JDK Mission Control bu kayıtları görselleştirir. Bunlara ücretsiz VisualVM ve IDE'lerin yerleşik profil oluşturucuları eklenir. JFR, programı çok az yavaşlatarak çalışma sırasında olayları kaydeder. Program JFR kaydıyla şöyle çalıştırılır ve kaydın özeti şöyle alınır:
java -XX:StartFlightRecording=filename=kayit.jfr Profil
jfr summary kayit.jfrİlk komut programı çalıştırır ve olayları kayit.jfr dosyasına yazar. İkinci komut kayıttaki olay türlerini ve sayılarını listeler. Ayrıntılı inceleme genellikle bu dosya JDK Mission Control'de açılarak yapılır.
7.7.2Sıcak noktayı ölçmek
Profil oluşturmanın değerini görmek için aynı işi iki farklı yolla yapan iki metot karşılaştırılır. İkisi de 0'dan n − 1'e kadar sayıları art arda yazarak bir metin oluşturur.
public class Profil {
static String birlestirArti(int n) {
String s = "";
for (int i = 0; i < n; i++) {
s += i; // her turda yeni String
}
return s;
}
static String birlestirBuilder(int n) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i);
}
return sb.toString();
}
// main: yandaki kutuda
}public static void main(String[] args) {
long t0 = System.nanoTime();
String a = birlestirArti(50_000);
long t1 = System.nanoTime();
String b = birlestirBuilder(50_000);
long t2 = System.nanoTime();
long ms1 = (t1 - t0) / 1_000_000;
long ms2 = (t2 - t1) / 1_000_000;
System.out.println("+= : " + ms1 + " ms");
System.out.println("SB : " + ms2 + " ms");
System.out.println(a.equals(b));
}main metodu iki metodun süresini System.nanoTime() ile ölçer. Bu metot nanosaniye cinsinden bir zaman damgası döndürür; iki damganın farkı 1.000.000'a bölünerek milisaniyeye çevrilir. Son satır iki metnin aynı olduğunu doğrular: hız farkı sonucu değiştirmemelidir. Bir çalıştırmada çıktı şöyledir; sayılar makineye ve çalıştırmaya göre değişir:
+= : 873 ms
SB : 2 ms
true+= ile yaklaşık 900 milisaniye, StringBuilder ile yaklaşık 2 milisaniye.Fark yüzlerce kattır (Şekil 7.16). Bu not hazırlanırken JDK 21 ile üç çalıştırmada += sürümü 862–922 ms, StringBuilder sürümü 2–4 ms sürdü. Nedeni String'in değişmez (immutable) olmasıdır. s += i eski metni değiştiremez; her turda yeni bir String oluşturur. Eski metnin bütün karakterleri bu yeni nesneye kopyalanır. k. turda kopyalanan karakter sayısı aşağı yukarı k ile orantılıdır. Toplam iş 1 + 2 + … + n ≈ n²/2 ile büyür: O(n²). StringBuilder ise tek bir tampona ekler. Tampon dolunca daha büyüğüne taşınır, ama boyut her seferinde katlandığı için toplam kopyalama O(n)'de kalır.
Bu programda sıcak nokta açıktır. Gerçek bir programda ise ilk bakışta görünmez: s += i satırı yüzlerce satırın arasında masum bir döngünün içinde durur. Profil oluşturucu zamanın %99'unun bu satırda harcandığını gösterir ve iyileştirmenin nereye yapılacağını söyler.
7.7.3Kod kapsamı
Kod kapsamı (code coverage), testler çalışırken kodun hangi bölümlerinin çalıştırıldığını ölçer. Sonuç genellikle bir oran olarak verilir: çalışan satırların ya da dalların toplama oranı.
Kod kapsamı aracı, hiç çalıştırılmayan ya da kullanılmayan kod parçalarını ortaya çıkarır ve test edilmemiş bölgeleri bulmaya yardım eder. Bu yüzden yazılımın güvenilirliğini artırmada önemli bir araçtır. Java'da en yaygın araç Maven ve Gradle ile çalışan JaCoCo'dur. IntelliJ'deki “Run with Coverage” komutu ve Eclipse'teki EclEmma eklentisi de aynı işi yapar. Sonuç, kod satırları renklendirilerek gösterilir: çalışan satırlar yeşil, hiç çalışmayanlar kırmızı.
| Ölçüt | Ne sayılır? |
|---|---|
| Satır (line) | En az bir kez çalışan satırlar |
| Dal (branch) | Her if / döngü koşulunun hem doğru hem yanlış sonucu |
| Metot | En az bir kez çağrılan metotlar |
Tablo 7.17'taki üç ölçütün en sıkısı dal kapsamıdır. Bir if satırı çalıştığında satır kapsamı onu “çalıştı” sayar. Dal kapsamı ise koşulun hem doğru hem yanlış çıktığını görmek ister. Koşul hep doğru çıkmışsa, yanlış dalda saklanan bir kusur hiç denenmemiştir.
Kapsamın sınırını iyi bilmek gerekir: %100 kapsam hatasız kod demek değildir. Kapsam yalnız satırın çalıştığını söyler, sonucun doğru olduğunu değil. Hiç doğrulama içermeyen, yalnız metodu çağıran bir test de kapsamı artırır. Kapsam raporu “burası hiç denenmedi” uyarısında çok değerlidir; “burası doğru” anlamında ise hiçbir şey söylemez.
7.7.4Kod izleme: kod kapsamı
Örnek metot, bir sayının işaretini metin olarak döndürür.
public class Isaret {
public static String isaret(int x) {
if (x > 0) {
return "pozitif";
} else if (x < 0) {
return "negatif";
}
return "sıfır";
}
}Metodun çalıştırılabilir satırları 3, 4, 5, 6 ve 8'dir. 1., 2. ve 7. satırlar bildirim ya da kapanış ayracıdır; kapsam araçları onları saymaz. İki koşul vardır: x > 0 ve x < 0. Her koşulun iki dalı olduğu için toplam 4 dal sayılır. Metot için iki test yazılmıştır:
class IsaretTest {
@Test
void pozitif() {
assertEquals("pozitif", Isaret.isaret(5));
}
@Test
void negatif() {
assertEquals("negatif", Isaret.isaret(-3));
}
}isaret(5) çağrısında 3. satır çalışır ve x > 0 doğru çıkar; 4. satır "pozitif" döndürür. Test geçer. Kapsam: 5 satırdan 2'si, 4 daldan 1'i. isaret(-3) çağrısında 3. satırdaki koşul yanlış çıkar (yeni dal), 5. satırdaki x < 0 doğru çıkar (yeni dal) ve 6. satır "negatif" döndürür. İki test de yeşildir.
x < 0'ın yanlış dalı hiç denenmedi.| Test | Çalışan satırlar | Denenen dallar | Satır kapsamı | Dal kapsamı |
|---|---|---|---|---|
| (yok) | — | — | 0 / 5 | 0 / 4 |
isaret(5) | 3, 4 | x > 0 doğru | 2 / 5 = %40 | 1 / 4 = %25 |
+ isaret(-3) | 3, 4, 5, 6 | + x > 0 yanlış, x < 0 doğru | 4 / 5 = %80 | 3 / 4 = %75 |
+ isaret(0) | 3, 4, 5, 6, 8 | + x < 0 yanlış | 5 / 5 = %100 | 4 / 4 = %100 |
Tablo 7.18 eksik testi açıkça gösterir. İki yeşil teste rağmen 8. satır ve x < 0 koşulunun yanlış dalı hiç denenmemiştir. Bu dala ancak iki koşulun da yanlış olduğu girdi, yani 0 ulaşır. Kapsam raporu böylece unutulan sınır değerini ortaya çıkarır: isaret(0). Bu test eklendiğinde satır ve dal kapsamı %100 olur ve test "sıfır" döndüğünü doğrular.
%100 kapsamda bile doğruluğu kapsam değil, assertEquals sınar. 8. satır yanlışlıkla return "pozitif"; olsaydı, isaret(0)'ı çağırıp sonucuna bakmayan bir test kapsamı yine %100 yapardı.
7.7.5Statik analiz: FindBugs ve SpotBugs
Statik analiz (static analysis), kodu çalıştırmadan hata kalıplarını arayan incelemedir. “Statik” sözcüğü programın çalışmadığını anlatır; testler ve hata ayıklayıcı ise programı çalıştırır ve dinamik araçlardır.
FindBugs, Java kodunun statik analizi için geliştirilmiş bir araçtı. Programı çalıştırmadan derlenmiş kodu inceler ve bilinen hata kalıplarını arardı: null erişimi, yanlış karşılaştırma, kapatılmayan kaynak gibi. FindBugs artık geliştirilmiyor; devamı SpotBugs'tır. SpotBugs Maven, Gradle ve IDE eklentileriyle kullanılır. Benzer araçlar arasında PMD, Checkstyle ve SonarQube sayılabilir.
import java.util.Scanner;
public class SpotOrnek {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
String cevap = sc.nextLine();
// sonuç kullanılmıyor:
cevap.trim();
// String == ile karşılaştırılıyor:
if (cevap == "evet") {
System.out.println("Onaylandı");
} else {
System.out.println("Reddedildi");
}
}
}Program kullanıcıdan bir cevap okur ve "evet" ise onay verir. Kullanıcı evet yazdığı hâlde çıktı Reddedildi olur. Programda iki kusur vardır ve derleyici ikisi için de sessiz kalır.
| SpotBugs kalıbı | Uyarı |
|---|---|
ES_COMPARING_STRINGS_WITH_EQ | String nesneleri == ile karşılaştırılıyor; doğrusu equals |
RV_RETURN_VALUE_IGNORED | trim() yeni bir String döndürür; sonuç atılıyor |
Tablo 7.19'taki ilk kalıp, =='nin iki referansın aynı nesneyi gösterip göstermediğine baktığını hatırlatır. nextLine() klavyeden okunan metin için yeni bir String nesnesi oluşturur. Bu nesne, koddaki "evet" sabitiyle aynı nesne değildir; içerikleri aynı olsa bile == yanlış döner. Doğrusu "evet".equals(cevap)'tır. İkinci kalıp String'in değişmezliğiyle ilgilidir. cevap.trim(), cevap'ı değiştirmez; baştaki ve sondaki boşlukları silinmiş yeni bir metin döndürür. Sonuç bir değişkene atanmadığı için satır hiçbir iş yapmaz. Doğrusu cevap = cevap.trim();'dir.
Bu iki kusuru bir test de bulabilirdi, ama ancak doğru girdiyle yazılmışsa. Statik analiz ise girdiye ihtiyaç duymaz: kalıbı gördüğü her yerde uyarır. Bu yüzden testi tamamlar, onun yerini tutmaz. Statik analiz mantık hatalarını bilemez; “toplam yerine fark hesaplanıyor” gibi bir kusur hiçbir kalıba uymaz.
7.7.6İlk statik analizci: derleyici
Her Java programcısının elinde zaten bir statik analizci vardır: javac. Derleyici kodu çalıştırmadan inceler ve derleme hatalarını bildirir. -Xlint seçeneğiyle derleme hatası olmayan, ama şüpheli görünen yapıları da bildirir.
public class Lint {
public static void main(String[] args) {
int gun = 2;
switch (gun) {
case 1:
System.out.println("Pazartesi");
case 2:
System.out.println("Salı");
case 3:
System.out.println("Çarşamba");
break;
}
int x = 10 / 0;
}
}Program derlenir ve çalışır, ama iki kusur içerir. case 1 ile case 2'nin sonunda break unutulmuştur. 13. satırda da sabit bir sıfıra bölme vardır. Çalıştırıldığında çıktı şöyledir:
Salı
Çarşamba
Exception in thread "main" java.lang.ArithmeticException: / by zero
at Lint.main(Lint.java:13)gun = 2 için case 2'den girilir. break olmadığı için akış case 3'e düşer (fall-through) ve Çarşamba da yazılır. Ardından 13. satırdaki bölme ArithmeticException fırlatır. Düz javac Lint.java bu programı hiç uyarı vermeden derler. -Xlint:all ile derlendiğinde ise iki kusur da çalıştırmadan görünür:
Lint.java:13: warning: [divzero] division by zero
int x = 10 / 0;
^
Lint.java:7: warning: [fallthrough] possible fall-through into case
case 2:
^
Lint.java:9: warning: [fallthrough] possible fall-through into case
case 3:
^
3 warnings[divzero] uyarısı sabit sıfıra bölmeyi, iki [fallthrough] uyarısı da bir önceki case'in sonunda break olmadığını bildirir. Uyarıların sırası derleyicinin iç aşamalarına göredir, satır sırasına göre değil. case 1 için uyarı yoktur, çünkü ondan önce bir case yoktur. Uyarıları okumak, hatayı çalıştırmadan önce bulmanın en ucuz yoludur.
7.7.7Özellik tabanlı test
Birim testlerinde girdileri programcı seçer. Ne kadar dikkatli seçerse seçsin, aklına gelmeyen bir durum olabilir. Özellik tabanlı test bu sorunu girdileri bilgisayara seçtirerek çözer.
Özellik tabanlı test (property-based testing), rastgele üretilen çok sayıda girdiyle bir özelliğin her zaman sağlandığını sınayan testtir. Programcı tek tek örnek yerine bir özellik (property) yazar: “her girdi için doğru olmalı”. Özelliği bozan bir girdi bulunursa karşı örnek olarak raporlanır.
Yöntemin öncüsü QuickCheck'tir. QuickCheck testleri otomatik olarak üretir: girdileri programcı değil, araç rastgele seçer. Karşı örnek bulunduğunda araç onu mümkünse küçültür; böylece 50 öğelik bir dizi yerine 2 öğelik bir karşı örnek raporlanır. İlk QuickCheck Haskell dili için yazılmıştır. Java dahil birçok dil için sürümleri vardır: jqwik ve junit-quickcheck. Aşağıdaki test aynı fikri düz JUnit ile uygular.
class OzellikTest {
static String rastgeleMetin(Random r) {
int uzunluk = r.nextInt(20); // 0..19
StringBuilder sb = new StringBuilder();
for (int i = 0; i < uzunluk; i++) {
sb.append((char) ('a' + r.nextInt(26)));
}
return sb.toString();
}
@Test
void ikiKezTersCevirmekAyniMetniVerir() {
Random r = new Random(42); // sabit tohum
for (int k = 0; k < 1000; k++) {
String s = rastgeleMetin(r);
String t = MetinIslem.ters(s);
assertEquals(s, MetinIslem.ters(t));
}
}
}Test edilen metot, bir metni ters çeviren basit bir yardımcıdır:
public class MetinIslem {
public static String ters(String s) {
return new StringBuilder(s).reverse().toString();
}
}Sınanan özellik şudur: bir metni iki kez ters çevirmek, metnin kendisini verir. rastgeleMetin 0–19 uzunlukta, küçük harflerden oluşan rastgele bir metin üretir. r.nextInt(26) 0–25 arasında bir sayı verir; 'a''ya eklenip char'a çevrilince a–z arasında bir harf olur. Test bu metinlerden 1000 tane üretir ve her biri için özelliği doğrular. Sabit tohum (42) testi belirleyici yapar: her çalıştırmada aynı 1000 metin denenir. Test çalıştırıldığında rapor tek satırdır: 1 test, 1000 girdi, başarılı.
Bu test elle seçilmiş örneklerden farklı bir şey dener. Boş metin (uzunluk 0), tek harfli metin, aynı harflerin tekrarı gibi durumlar rastgele üretimde kendiliğinden ortaya çıkar. Programcının bunları tek tek düşünmesi gerekmez.
7.7.8Özellik testi ilk sürümdeki hatayı buluyor
Özellik testi, enBuyuk metodunun ilk, hatalı sürümüne de uygulanabilir. En büyük öğenin iki özelliği vardır: sonuç dizinin hiçbir öğesinden küçük değildir ve sonuç dizinin bir öğesidir.
class OzellikTest2 {
@Test
void enBuyukDizininEnBuyukElemanidir() {
Random r = new Random(7);
for (int k = 0; k < 1000; k++) {
int[] dizi = new int[1 + r.nextInt(5)];
for (int i = 0; i < dizi.length; i++) {
dizi[i] = r.nextInt(201) - 100; // -100..100
}
int enb = DiziIslem.enBuyuk(dizi);
boolean dizideVar = false;
for (int x : dizi) {
assertTrue(enb >= x); // özellik 1
if (x == enb) dizideVar = true;
}
assertTrue(dizideVar, // özellik 2
"Karşı örnek: " + Arrays.toString(dizi));
}
}
}Test 7 tohumuyla 1000 rastgele dizi üretir. Her dizinin uzunluğu 1–5, öğeleri −100 ile 100 arasındadır: r.nextInt(201) 0–200 verir, 100 çıkarılınca aralık −100..100 olur. Her dizi için önce özellik 1 (enb >= x), sonra özellik 2 (dizideVar) denetlenir. İlk hatalı sürümle çalıştırıldığında sonuç şudur:
✘ enBuyukDizininEnBuyukElemanidir()
Karşı örnek: [-60, -69, -39, -65, -13] ==> expected: <true> but was: <false>
1 test · 1 başarısızKarşı örnek ikinci rastgele dizidir; ilk dizide negatif olmayan bir öğe vardır ve hatalı sürüm doğru sonucu verir. İkinci dizinin bütün öğeleri negatiftir; hatalı sürüm 0 döndürür. Özellik 1 hatalı sürümde de geçer: 0 bütün negatif sayılardan büyüktür. Özellik 2 ise hatayı yakalar: 0 dizide yoktur. assertTrue'nun ikinci parametresi olan ileti, karşı örneği rapora yazar.
Düzeltilmiş sürümle aynı test çalıştırıldığında 1000 girdinin tamamı geçer. Buradan çıkan ders önemlidir: özellikler eksiksiz yazılmalıdır. Yalnız özellik 1 yazılsaydı test hatalı kodu da onaylardı. Zayıf bir özellik, yanlış bir güven duygusu verir.
Bir sıralama metodu için “sonuç dizisinde her öğe bir sonrakinden büyük değildir” özelliği yazılsın. Bu özellik tek başına yeterli mi?
Yeterli değildir. Girdiyi hiç kullanmayıp hep boş dizi döndüren bir metot da bu özelliği sağlar. Hep sıfırlardan oluşan bir dizi döndüren metot da öyle. İkisi de sıralı bir dizi üretir, ama girdiyle ilgisi yoktur.
Eksik özellik şudur: sonuç, girdinin bir permütasyonu olmalıdır; aynı öğeler, aynı sayıda. İki özellik birlikte “sıralamak” kavramını tam olarak tanımlar.
7.7.9Selenium
Selenium, web uygulamalarını test etmek için kullanılan bir çerçevedir. Tarayıcı tabanlı testleri kolaylaştırır: gerçek bir tarayıcıyı programla açar, bağlantıya tıklar, forma yazar, düğmeye basar ve sayfada beklenen metnin çıktığını doğrular. Java, Python, C# ve JavaScript gibi dillerle kullanılır. Java'da genellikle bir JUnit testinin içinden çağrılır.
Şekil 7.19 komutların izlediği yolu gösterir. Test kodu WebDriver adlı ortak arayüzü kullanır. Her tarayıcının kendi sürücüsü vardır (Chrome için ChromeDriver, Firefox için GeckoDriver). Aynı test kodu sürücü değiştirilerek farklı tarayıcılarda çalıştırılabilir. Tipik bir senaryo şöyledir: giriş sayfası açılır, kullanıcı adı ve parola yazılır, “Giriş” düğmesine basılır ve başlıkta “Hoş geldiniz” metninin çıkıp çıkmadığına bakılır.
Selenium testleri kullanıcının gördüğü bütünü sınar; sistem ve kabul testleri seviyesindedir. Bu güçlerinin bedeli vardır. Birim testlerinden çok daha yavaştırlar: bir tarayıcı açmak bile saniyeler sürer. Ayrıca kırılgandırlar: bir düğmenin adının değişmesi bile testi bozabilir. Bu yüzden sayıları az tutulur; test piramidinin tepesinde dururlar.
7.7.10Bellek sızıntısı
Java'da bellek, çöp toplayıcı sayesinde kendiliğinden yönetilir. Programcı new ile nesne oluşturur, ama onu hiçbir zaman açıkça silmez. Buna rağmen Java programları da bellek sızdırabilir.
Çöp toplayıcı (garbage collector, GC), hiçbir referansın göstermediği nesnelerin belleğini geri alan JVM bileşenidir. Bellek sızıntısı (memory leak) ise artık gerekmeyen nesnelere hâlâ bir referans kaldığı için belleğin boşalmamasıdır.
Tanımdaki kilit sözcük “erişilebilir”dir. Çöp toplayıcı yalnız erişilemeyen nesneleri siler. Bir nesneye programın herhangi bir yerinden, örneğin bir static alandan bir referans zinciri ulaşıyorsa, o nesne program için gereksiz olsa bile silinmez.
import java.util.ArrayList;
import java.util.List;
public class Sizinti {
static List<byte[]> onbellek = new ArrayList<>();
static void istekIsle(int no) {
byte[] veri = new byte[1024 * 1024]; // 1 MB
onbellek.add(veri); // hiç silinmiyor!
}
public static void main(String[] args) {
for (int i = 1; ; i++) {
istekIsle(i);
if (i % 20 == 0) {
System.out.println(i + " istek işlendi");
}
}
}
}Program sonsuz bir döngüde istekleri işler. Her istek 1 MB'lık bir dizi oluşturur ve onu static bir listeye ekler. Liste hiçbir zaman temizlenmez.
static onbellek listesi her istekte eklenen 1 MB'lık dizileri tutar. Dizilerin hepsine hâlâ erişilebilir; çöp toplayıcı onları silemez.Program java -Xmx256m Sizinti komutuyla çalıştırıldığında çıktı şöyledir; aradaki satırlar kısaltılmıştır:
20 istek işlendi
…
120 istek işlendi
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at Sizinti.istekIsle(Sizinti.java:8)
at Sizinti.main(Sizinti.java:14)-Xmx256m seçeneği heap'i en çok 256 MB ile sınırlar. Program 120 isteği işledikten bir süre sonra OutOfMemoryError: Java heap space ile çöker. İzleme listesi sorunun yerini doğru gösterir: 8. satırdaki new byte[1024 * 1024]. Ama asıl kusur bu satırda değildir. Bellek o satırda biter, çünkü önceki dizilerin hiçbiri serbest bırakılmamıştır. Kusur 9. satırdaki onbellek.add(veri)'dır: eklenen veri bir daha hiç çıkarılmaz.
256 MB'lık bir heap'te yalnız 120 civarında 1 MB'lık dizinin sığması şaşırtıcı görünebilir. Nedeni JVM'in varsayılan çöp toplayıcısı G1'in belleği bölgelere ayırmasıdır. Bu heap boyutunda bölgeler 1 MB'tır. 1 MB'lık bir dizi, nesne başlığıyla birlikte bir bölgeye sığmaz ve iki bölge kaplar. Bu yüzden heap yarı dolulukta tükenir. Kesin sayı JVM'e ve ayarlara göre değişir.
7.7.11Eclipse Memory Analyzer ve VisualVM
Sızıntıyı bulmanın yolu heap'e bakmaktır: hangi nesneler var, ne kadar yer tutuyorlar ve onları kim tutuyor? Eclipse Memory Analyzer (MAT) bu soruları cevaplar. Java heap'ini analiz ederek bellek sızıntılarını bulur ve bellek iyileştirmesine yardım eder.
MAT'ın girdisi bir heap dökümüdür (heap dump, .hprof dosyası): belirli bir andaki bütün nesnelerin fotoğrafı. Döküm iki yolla alınır:
// bellek dolunca otomatik
java -XX:+HeapDumpOnOutOfMemoryError Sizinti
// çalışan programdan
jps
jcmd <pid> GC.heap_dump yigin.hprofİlk yol -XX:+HeapDumpOnOutOfMemoryError seçeneğidir. Bu seçenekle başlatılan JVM, bellek tükendiği anda heap'i kendiliğinden bir dosyaya döker. İkinci yol çalışan bir programdan döküm almaktır. jps çalışan JVM'leri ve süreç numaralarını (pid) listeler. jcmd <pid> GC.heap_dump yigin.hprof o JVM'in heap'ini dosyaya yazar.
MAT'ın en önemli üç raporu şunlardır. Leak Suspects (sızıntı şüphelileri), heap'in büyük bölümünü tutan nesneleri otomatik olarak bulur. Dominator Tree (baskın nesne ağacı), hangi nesnenin en çok belleği tuttuğunu gösterir; bir nesne silinirse onunla birlikte silinecek bütün nesneler onun altında listelenir. Histogram ise sınıf başına nesne sayısını ve kapladığı alanı verir.
Sizinti örneğinde Leak Suspects raporu, heap'in neredeyse tamamını Sizinti.onbellek listesinin ve içindeki byte[] dizilerinin tuttuğunu gösterir. Çözüm iki yoldan biridir: işi biten veri listeden çıkarılır ya da boyutu sınırlı bir önbellek kullanılır. Sınırlı önbellek dolunca en eski öğeyi atar ve bellek kullanımı sabit kalır.
VisualVM ise çalışan programı canlı izler. Bellek tüketimini, iş parçacıklarının kullanımını, işlemci yükünü ve daha fazlasını görselleştirir. Çalışan JVM'lere bağlanır, profil çıkarır, heap ve iş parçacığı dökümü alır. VisualVM ücretsiz ve açık kaynaktır (visualvm.github.io); eskiden JDK ile birlikte jvisualvm adıyla gelirdi. Komut satırında benzer bilgiyi veren araçlar jps, jstat, jcmd ve jconsole'dur.
Şekil 7.21 VisualVM'deki heap grafiğinin iki tipik görünümünü gösterir. Sağlıklı bir programda bellek kullanımı yükselir, çöp toplama devreye girer ve kullanım hep aynı seviyeye iner; grafik testere dişine benzer. Sızıntıda da testere dişi görülür, ama her çöp toplamadan sonraki dip bir öncekinden yüksektir. Dipler sürekli yükseliyorsa nesneler serbest bırakılmıyordur. Bu görüntü bir sızıntının ilk işaretidir. Sonra heap dökümü alınır ve MAT ile kimin neyi tuttuğu incelenir.
7.7.12Hangi soruya hangi araç?
Bu notta anlatılan araçların her biri tek bir soruyu cevaplar. Doğru aracı seçmek, sorunun ne olduğunu doğru koymakla başlar. Tablo 7.20 soruları ve araçları eşleştirir.
| Soru | Teknik / araç |
|---|---|
| Bu metot doğru sonuç veriyor mu? | Birim testi: JUnit, @Test, assertEquals, assertThrows |
| Parçalar birlikte, bütün sistem ve kullanıcı gözünden çalışıyor mu? | Modül, alt sistem, sistem, kabul (alfa / beta) testi; web için Selenium |
| Değişiklik eski bir şeyi bozdu mu? | Regresyon testi: tüm otomatik testler her değişiklikte (CI) çalıştırılır |
| Hangi girdiler seçilmeli? | Sınır değerleri, denklik sınıfları, @ParameterizedTest |
| Seçilmeyen girdilerde de doğru mu? | Özellik tabanlı test (QuickCheck, jqwik) |
| Testler kodun ne kadarını çalıştırıyor? | Kod kapsamı: JaCoCo, EclEmma |
| Kodda çalıştırmadan görülebilen hata var mı? | Statik analiz: javac -Xlint, SpotBugs (FindBugs) |
| Neden yanlış çalışıyor? | Hata ayıklayıcı: kesme noktası, Step Into / Over / Out, izleme; jdb |
| Zaman nerede harcanıyor? | Profil oluşturucu: JFR, VisualVM |
| Bellek neden doluyor? | VisualVM ile izleme, heap dökümünü Eclipse Memory Analyzer ile inceleme |
Tablodaki araçlar birbirinin rakibi değil, bir zincirin halkalarıdır. Regresyon testi bir arızayı haber verir. Hata ayıklayıcı kusuru bulur. Düzeltmeden sonra birim testi kusurun geri gelmesini önler. Kapsam raporu hangi testlerin eksik olduğunu, statik analiz testlerin kaçırabileceği kalıpları gösterir. Profil oluşturucu ve bellek araçları ise doğru çalışan bir programın neden yavaş ya da obur olduğunu açıklar. Test edilebilir kod genellikle iyi tasarlanmış koddur. Bağımlılıkları dışarıdan almak, zar örneğindeki Random gibi, hem testi hem de tasarımı kolaylaştırır.
7.8Çözümlü problemler
Bu kesimdeki problemler notta anlatılan konuları birleştirir. Her problemin çözümü ara adımlarıyla verilir; JUnit sonuçları ve iletiler JUnit 5.11 ile çalıştırılarak doğrulanmıştır.
Hangi test geçer, hangisi kalır?
class SoruTest {
@Test void a() { assertEquals(7, 3 + 4); }
@Test void b() { assertEquals(0.3, 0.1 + 0.2); }
@Test void c() { assertTrue("Java".length() == 4); }
@Test void d() { assertThrows(ArithmeticException.class,
() -> System.out.println(10 / 2)); }
}(a) 3 + 4 = 7; beklenen 7. Geçer.
(b) 0,1 + 0,2 ikili gösterimde 0,30000000000000004 olur. Tolerans verilmemiştir, karşılaştırma birebirdir. Kalır: expected: <0.3> but was: <0.30000000000000004>.
(c) "Java".length() 4'tür, koşul doğrudur. Geçer.
(d) 10 / 2 = 5; bölme sorunsuzdur ve ekrana 5 yazılır. ArithmeticException fırlatılmaz. Kalır: Expected java.lang.ArithmeticException to be thrown, but nothing was thrown.
Mutlak değer metodunda dal kapsamı. Aşağıdaki metot için dal kapsamının %100 olması için en az kaç test gerekir?
public class Mutlak {
static int mutlak(int x) {
if (x < 0) {
return -x;
}
return x;
}
}Metotta tek bir koşul (x < 0) ve dolayısıyla iki dal vardır. Doğru dal için negatif bir girdi (ör. −3 → 3), yanlış dal için sıfır ya da pozitif bir girdi (ör. 5 → 5) gerekir. En az 2 test yeterlidir. Sınır olarak −1 ve 0'ın da eklenmesi iyi olur; bu iki değer eşiği iki yandan kuşatır. Koşul yanlışlıkla x < -1 yazılsaydı −1 için −1 dönerdi; −3 ve 5 testleri bunu göremez, yalnız −1 testi yakalar.
Bu test sınıfının sorunu nedir?
class SayacTest {
static int sayac = 0;
@Test void artir() {
sayac++;
assertEquals(1, sayac);
}
@Test void ikiKezArtir() {
sayac += 2;
assertEquals(2, sayac);
}
}sayac alanı static'tir; sınıfa aittir ve iki testin test nesneleri aynı alanı paylaşır. Sonuç test sırasına bağlıdır. JUnit bu sınıfta önce artir'ı çalıştırır: sayac 1 olur ve test geçer. Ardından ikiKezArtir çalışır: sayac 1'den 3'e çıkar. Kalır: expected: <2> but was: <3>. Sıra ters olsaydı bu kez artir kalırdı (expected: <1> but was: <3>).
Çözüm iki yoldan biridir. static sözcüğü kaldırılır; JUnit her test için yeni nesne oluşturduğu için her test 0'dan başlar. Ya da alan @BeforeEach metodunda sıfırlanır.
İki kez Step Over. AdimOrnek programında hata ayıklayıcı int s = kareToplam(3, 4); satırında (13) durmuşken art arda iki kez Step Over yapılırsa hangi satıra gelinir, ekranda ne vardır ve s kaçtır?
Birinci Step Over, kareToplam'ı içine girmeden çalıştırır ve atamayı tamamlar: s = 25. Program 14. satırda, println'dadır; ekran henüz boştur.
İkinci Step Over println'ı çalıştırır. Ekranda s = 25 yazar ve program main'in kapanış satırına (15) gelir. s 25'tir.
Hangi araç? Her durum için hangi teknik ya da araç kullanılmalıdır?
(a) Program 10 dakika sonra OutOfMemoryError ile çöküyor. VisualVM ile heap grafiği izlenir; dipler yükseliyorsa sızıntı vardır. Heap dökümü (-XX:+HeapDumpOnOutOfMemoryError) Eclipse Memory Analyzer ile incelenir.
(b) Rapor oluşturma 30 saniye sürüyor; hangi metot yavaş? Profil oluşturucu: JDK Flight Recorder ya da VisualVM.
(c) Yeni eklenen indirim kodu eski fatura hesabını bozdu mu? Regresyon testi: tüm otomatik testler yeniden çalıştırılır, tercihen CI ile her değişiklikte.
(d) Web sitesindeki kayıt formu tüm tarayıcılarda çalışıyor mu? Selenium ile tarayıcı tabanlı testler, farklı tarayıcı sürücüleriyle.
(e) switch'te unutulmuş break'ler var mı? Statik analiz: javac -Xlint:all ([fallthrough] uyarısı) ya da SpotBugs.
(f) Bir döngü 10.000. turda yanlış değer üretiyor; o anda değişkenler ne? Hata ayıklayıcıda koşullu kesme noktası: i == 9999. Döngü sayacı 0'dan başladığı için 10.000. tur i = 9999'dur.
Asal sayı sınamasının sınırları. static boolean asalMi(int n) için @ParameterizedTest ile sınır değerleri 0, 1, 2, 3, 4, 97 ve 100 nasıl test edilir?
static boolean asalMi(int n) {
if (n < 2) return false;
for (int d = 2; d * d <= n; d++) {
if (n % d == 0) return false;
}
return true;
}
@ParameterizedTest
@CsvSource({"0, false", "1, false", "2, true", "3, true",
"4, false", "97, true", "100, false"})
void asalSinirlari(int n, boolean beklenen) {
assertEquals(beklenen, asalMi(n));
}Yedi çağrının yedisi de geçer. 0 ve 1 asal değildir; metodun ilk satırı bu iki durumu ayırır. 2 en küçük asaldır ve döngüye hiç girmez, çünkü 2 × 2 > 2'dir. 4 en küçük bileşik sayıdır; döngü d = 2'de bölen bulur. 0, 1 ve 2 en çok hata yapılan sınırlardır: n < 2 yerine n < 1 yazılırsa 1 asal sanılır. Döngü koşulundaki d * d <= n de bir sınırdır: < yazılsaydı 4 asal sanılırdı, çünkü d = 2 için 4 < 4 yanlış çıkar ve döngü hiç çalışmazdı.
Dikdörtgen sınıfının testi. Dikdortgen sınıfı (en, boy, alan(), cevre()) için bir JUnit 5 test sınıfı yazılsın; negatif kenar IllegalArgumentException fırlatmalıdır.
class DikdortgenTest {
@Test
void alanDogru() {
assertEquals(12.0, new Dikdortgen(3, 4).alan(), 1e-9);
}
@Test
void cevreDogru() {
assertEquals(14.0, new Dikdortgen(3, 4).cevre(), 1e-9);
}
@Test
void negatifKenarReddedilir() {
assertThrows(IllegalArgumentException.class,
() -> new Dikdortgen(-1, 2));
}
}3 × 4'lük dikdörtgenin alanı 12, çevresi 2 × (3 + 4) = 14'tür. Kenarlar double olduğu için doğrulamalarda tolerans verilir. Sınır olarak sıfır kenarlı bir dikdörtgen de denenmelidir: alanı 0'dır ve istisna fırlatmamalıdır.
Havale metodu için TDD. BankaHesabi'na havale(BankaHesabi hedef, double miktar) eklenecektir. Kod yazılmadan önce hangi testler yazılır?
(1) Başarılı havale: iki bakiye de değişir; gönderenin bakiyesi azalır, alıcınınki artar. (2) Yetersiz bakiye: istisna fırlatılır ve iki bakiye de değişmez. (3) Negatif miktar: reddedilir. (4) Kendine havale: kararlaştırılan davranış (ret ya da etkisiz işlem) sınanır.
İkinci test özellikle önemlidir. Havale önce gönderenden çekip sonra alıcıya yatırıyorsa ve yatırma adımı istisna fırlatıyorsa, para havada kaybolabilir. Her test önce kırmızıdır, sonra yazılan kodla yeşile döner.
Ortalamada tam sayı bölmesi. Bir dizinin ortalamasını hesaplayan metot {1, 2} için 1,5 yerine 1,0 döndürüyor. Kusur hata ayıklayıcıyla nasıl bulunur?
Önce kalan bir test yazılır: {1, 2} için beklenen 1,5. Kesme noktası return satırına konur. toplam değişkeni ve bölme ifadesi izlenir: toplam 3'tür, doğrudur. toplam / dizi.length ise 1 verir: iki int bölündüğü için bu bir tam sayı bölmesidir (3 / 2 = 1). Sonuç double'a ancak bölmeden sonra çevrilir ve 1,0 olur. Düzeltme (double) toplam / dizi.length'tir: dönüşüm bölmeden önce yapılır, bölme ondalıklı olur ve 1,5 verir. Ardından bütün testler çalıştırılır.
7.9Sık yapılan hatalar
| Hata | Sonuç | Doğrusu |
|---|---|---|
Beklenen ile gerçeği ters yazmak: assertEquals(hm.topla(2, 3), 5) | Çalışır, ama kalınca ileti ters okunur: expected gerçek sonucu gösterir | Önce beklenen, sonra gerçek |
Ondalıkta toleransı unutmak: assertEquals(0.3, 0.1 + 0.2) | expected: <0.3> but was: <0.30000000000000004> | assertEquals(0.3, 0.1 + 0.2, 1e-9) |
import static eksik | Derleme hatası: cannot find symbol, method assertEquals(int,int) | import static org.junit.jupiter.api.Assertions.*; |
| JUnit 4 ile 5'i karıştırmak | org.junit.Test ile org.junit.jupiter.api.Test farklıdır; yanlış içe aktarma testlerin hiç çalışmamasına yol açabilir | JUnit 5 projesinde yalnız org.junit.jupiter.api |
@BeforeAll metodu static değil | JUnit sınıftaki testleri çalıştırmaz: @BeforeAll method '…' must be static … | static void basla() |
| Testler arası bağımlılık | Sıra değişince testler rastgele kalır | Her test kendi verisini kurar |
| Doğrulaması olmayan test | Test her zaman ✔ olur, hiçbir şey kanıtlamaz; kapsamı yine de artırır | Her testte en az bir doğrulama |
| Yalnız “mutlu yol”u test etmek | Sınırlar (0, boş dizi, negatif), geçersiz girdiler ve istisnalar unutulur; hatalar tam oradadır | Normal durum + sınır + hatalı girdi |
Dizileri assertEquals ile karşılaştırmak | İçerik aynı olsa da kalır; referanslar karşılaştırılır | assertArrayEquals |
Tohumsuz Random ya da LocalDate.now() | Kararsız test: bazen geçer, bazen kalır | Bağımlılığı dışarıdan vermek, sabit tohum |
Tablo 7.21'deki hataların çoğu derleyiciyi geçer. Ters yazılmış bir assertEquals, doğrulaması olmayan bir test ya da birbirine bağlı iki test hiçbir uyarı üretmez. Bu yüzden test kodu da üretim kodu kadar dikkatle okunmalıdır. Testin kendisinin doğru olduğunu görmenin basit bir yolu, onu bir kez bilerek kırmızıya düşürmektir: beklenen değer kasıtlı olarak yanlış yazılır ve testin gerçekten kaldığı görülür. Hiç kalmayan bir test bir şey sınamıyor olabilir.
7.10Özet
- Test, programın beklenen davranışı gösterip göstermediğini sistemli biçimde sınar. Test hataların varlığını gösterebilir, yokluğunu gösteremez; iyi seçilmiş testler güveni artırır.
- Hata programcının yanılgısı, kusur bu yanılgının koddaki karşılığı, arıza ise çalışırken görülen yanlış davranıştır. Test arızayı ortaya çıkarır, hata ayıklama kusuru bulur.
- Test seviyeleri birim, modül, alt sistem, sistem ve kabul testidir. Aşağıda yakalanan hata ucuz, yukarıda yakalanan pahalıdır. Kara kutu testi gereksinimden, beyaz kutu testi koddan yola çıkar.
- JUnit 5'te test metotları
@Testile işaretlenir; sonuçassertEquals(beklenen, gerçek)gibi doğrulamalarla denetlenir. Ondalık sayılarda tolerans, dizilerdeassertArrayEqualskullanılır. - İyi bir test hazırla, çalıştır, doğrula (AAA) düzenindedir ve tek bir davranışı sınar. Test adı davranışı anlatır.
- JUnit her test için yeni bir test nesnesi oluşturur.
@BeforeEachher testten önce,@AfterEachher testten sonra (test kalsa bile) çalışır.@BeforeAllve@AfterAllbir kez çalışır vestaticolmalıdır. assertThrowskodun beklenen tipte (ya da alt tipinde) istisna fırlattığını doğrular ve istisna nesnesini döndürür.- Hatalar en çok sınırlarda olur. Sınır değer analizi her aralığın iki ucunu ve geçersiz bölgenin ilk değerlerini seçer; denklik sınıfından tek temsilci yeterlidir.
@ParameterizedTestve@CsvSourceaynı testi birçok veriyle çalıştırır. - İyi birim testi hızlı, bağımsız, tekrarlanabilir, kendini doğrulayan ve zamanında yazılmış testtir (FIRST). Dış bağımlılıkların yerine test dublörü konur.
- Regresyon testi, her değişiklikten sonra bütün testlerin yeniden çalıştırılmasıdır. Testler belirleyici olmalıdır; rastgelelik sabit tohumla, bağımlılık enjeksiyonu yoluyla kontrol edilir. CI sistemleri testleri her gönderimde çalıştırır.
- TDD'de önce kalan bir test yazılır (kırmızı), sonra onu geçiren en basit kod (yeşil), sonra kod temizlenir (yeniden düzenleme).
- Hata ayıklayıcı programı kesme noktasında, satır çalışmadan önce durdurur. Step Into metodun içine girer, Step Over çağrıyı tek adımda geçer, Step Out bulunulan metodu bitirir. Koşullu ve istisna kesme noktaları aranan anı yakalar.
jdbaynı işi komut satırından yapar. - Sistematik hata ayıklama: yeniden üret, kalan test yaz, daralt, hipotez kur ve sına, düzelt ve bütün testleri çalıştır.
assertvarsayımları-eaile açıkken denetlenir. - Profil oluşturucu (JFR, VisualVM) zamanın harcandığı sıcak noktayı ölçer: önce ölç, sonra iyileştir. Kod kapsamı (JaCoCo) hiç çalışmayan satırları ve dalları gösterir; %100 kapsam hatasızlık demek değildir.
- Statik analiz (
javac -Xlint, SpotBugs) kodu çalıştırmadan şüpheli kalıpları bulur. Özellik tabanlı test rastgele girdilerle bir özelliği sınar; özellik zayıfsa hatalı kodu da onaylar. - Selenium web uygulamasını gerçek tarayıcıda sınar. Bellek sızıntısı, gereksiz nesnelere hâlâ referans kalmasıdır; VisualVM'de yükselen diplerle görülür, heap dökümü Eclipse Memory Analyzer ile incelenir.
7.11Kavram listesi
- test testing
- hata / kusur / arıza error / defect / failure
- hata ayıklama debugging
- hata ayıklayıcı debugger
- birim testi unit test
- modül, alt sistem, sistem, kabul testi module, sub-system, system, acceptance testing
- kara kutu / beyaz kutu testi black-box / white-box testing
- çerçeve framework
- etiket annotation
- doğrulama assertion
- lambda ifadesi lambda expression
- test yaşam döngüsü test lifecycle
- hazırla, çalıştır, doğrula arrange, act, assert
- sınır değer analizi boundary value analysis
- denklik sınıfı equivalence class
- parametreli test parameterized test
- tümleştirme testi integration test
- test dublörü test double
- regresyon testi regression test
- kararsız test flaky test
- tohum seed
- bağımlılık enjeksiyonu dependency injection
- sürekli entegrasyon continuous integration
- test güdümlü geliştirme test-driven development
- kesme noktası breakpoint
- çağrı yığını call stack
- çerçeve stack frame
- profil oluşturucu profiler
- sıcak nokta hot spot
- kod kapsamı code coverage
- statik analiz static analysis
- özellik tabanlı test property-based testing
- karşı örnek counterexample
- çöp toplayıcı garbage collector
- bellek sızıntısı memory leak
- heap heap (memory)
- heap dökümü heap dump
7.12Kendini Dene
Sorularda notta kullanılan sınıflar (BankaHesabi, AdimOrnek, Jdb, Zar) aynen geçerlidir.
assertEquals(0.1 * 3, 0.3)geçer mi? Kalıyorsa ileti nedir ve iletide ne yanıltıcıdır?Cevap
Kalır:
expected: <0.30000000000000004> but was: <0.3>. İki sorun vardır. Tolerans yoktur: 0,1 × 3 ikili gösterimde tam 0,3 etmez. Parametreler de ters yazılmıştır: hesaplanan değer “beklenen” yerine geçmiş, sabit 0,3 “gerçek” gibi görünmüştür. DoğrusuassertEquals(0.3, 0.1 * 3, 1e-9)'dur.Bir test sınıfında üç sıradan
@Testmetodu ve@CsvSource'unda üç satır bulunan bir@ParameterizedTestvar.@BeforeEachmetodu kaç kez çalışır?Cevap
6 kez. Parametreli testin her satırı ayrı bir test çağrısıdır ve her çağrıdan önce
@BeforeEachçalışır: 3 + 3 = 6.Bakiyesi 100 olan bir hesap için
assertThrows(IllegalStateException.class, () -> hesap.yatir(-10))geçer mi?Cevap
Kalır.
yatir(-10)istisna fırlatır, ama tipiIllegalArgumentException'dır; bu sınıfIllegalStateException'ın alt sınıfı değildir. İleti:Unexpected exception type thrown, expected: <java.lang.IllegalStateException> but was: <java.lang.IllegalArgumentException>.Jdbprogramındastop at Jdb:3ile 3. satıra (int sonuc = x * x;) kesme noktası konuyor. Program kaç kez durur? İlk durmadalocalsne gösterir?Cevap
Üç kez durur:
karedöngüde üç kez çağrılır vexsırasıyla 1, 2, 3 olur. İlk durmadalocalsyalnızx = 1metot argümanını gösterir; yerel değişkenler listesi boştur. 3. satır henüz çalışmadığı içinsonuco anda yoktur.static String tur(int n)metodu üç çalıştırılabilir satırdan oluşuyor:if (n % 2 == 0),return "çift";vereturn "tek";. Yalnıztur(4)testi varsa satır ve dal kapsamı nedir?Cevap
4 çifttir;
ifsatırı vereturn "çift";çalışır. Satır kapsamı 2 / 3 ≈ %67'dir. Tek koşulun iki dalından yalnız doğru dalı denenmiştir: dal kapsamı 1 / 2 = %50. Tek bir sayıyla, örneğintur(3)ile yapılan ikinci test ikisini de %100 yapar.AdimOrnekprogramında hata ayıklayıcıkare'nin içinde,x = 3iken duruyor. Önce Step Out, sonra Step Over yapılıyor. Program hangi satırdadır,k1kaçtır?Cevap
Step Out
kare(3)'ü bitirir ve 7. satıra,int k1 = kare(a);'ya döner; dönüş değeri 9'dur ama atama henüz tamamlanmamıştır. Step Over atamayı tamamlar ve 8. satıra geçer.k1 = 9.Bir mağazada 6 ve daha fazla ürün alana %10 indirim uygulanıyor; ürün sayısı negatif olamaz. Sınır değer analiziyle hangi girdiler seçilir?
Cevap
Denklik sınıfları: geçersiz (< 0), indirimsiz (0–5), indirimli (≥ 6). Sınırlar: −1 (geçersiz bölgenin ilk değeri), 0 (geçerli bölgenin başı), 5 ve 6 (indirim eşiğini iki yandan kuşatan çift). Eşik
adet >= 6yerine yanlışlıklaadet > 6yazılmışsa yalnız 6 bu kusuru yakalar.MetinIslem.tersyanlışlıklareturn s;olarak yazılmış olsun: metni hiç değiştirmesin. “İki kez ters çevirmek metni değiştirmez” özellik testi bu kusuru yakalar mı? Nasıl güçlendirilir?Cevap
Yakalamaz:
ters(ters(s))yines'dir ve 1000 girdinin hepsi geçer. Özellik doğrudur, ama tek başına zayıftır. Güçlendirmek için ikinci bir özellik eklenir: sonucun ilk karakteri girdinin son karakteridir. Ya da birkaç bilinen örnek doğrulanır:assertEquals("cba", MetinIslem.ters("abc")).
7.13İleri okuma
- JUnit Team, JUnit 5 User Guide (junit.org/junit5/docs/current/user-guide), “Writing Tests”, “Assertions” ve “Parameterized Tests” kesimleri: etiketlerin, doğrulamaların ve parametre kaynaklarının resmî ve güncel anlatımıdır.
- K. Beck, Test-Driven Development: By Example: kırmızı-yeşil-yeniden düzenleme döngüsünü bir para birimi örneği üzerinde adım adım uygular; yöntemi kuran kişinin kitabıdır.
- C. S. Horstmann, Core Java, Volume I: Fundamentals (12. baskı), Bölüm 7 “Exceptions, Assertions, and Logging”:
assertdeyimini, günlük kaydını ve hata ayıklama ipuçlarını Java'ya özgü ayrıntılarla anlatır. - Oracle, Programming With Assertions:
assert'in ne zaman kullanılacağını ve ne zaman kullanılmayacağını açıklayan kısa resmî belgedir.
7.14Kaynaklar
- JUnit Team, JUnit 5 User Guide, junit.org/junit5/docs/current/user-guide (Writing Tests, Assertions, Parameterized Tests).
- Oracle, The Java Tutorials ve JDK 21 Tool Specifications:
javac(-Xlint),jdb,jcmd,jfr. - Oracle, Programming With Assertions (Java SE belgeleri).
- J. Gosling ve diğerleri, The Java Language Specification, Java SE 21 Edition (14.10 The assert Statement).
- P. Deitel ve H. Deitel, Java: How to Program, 11. baskı, Pearson.
- C. S. Horstmann, Core Java, Volume I: Fundamentals, 12. baskı, Pearson (Bölüm 7: Exceptions, Assertions, and Logging).
- K. Beck, Test-Driven Development: By Example, Addison-Wesley.
- Araç belgeleri: SpotBugs (spotbugs.github.io), JaCoCo (jacoco.org), Selenium (selenium.dev), Eclipse Memory Analyzer (eclipse.dev/mat), VisualVM (visualvm.github.io), jqwik (jqwik.net).
İlgili sayfalar: Bölüm 7 sunumu · Çalışma soruları · Terim sözlüğü · © Sercan Külcü. Tüm hakları saklıdır.