Eskiden bazı uygulama çatıları (framework) ile çalışabilmek için belli isimlendirme kurallarına uymak gerekiyordu. Örneğin, JUnit test çatısının 4. versiyondan önce test metotlarını algılayıp çalıştırabilmesi için metot isimlerinin “test” ile başlaması gerekiyordu. Bu yaklaşımın birçok dezavantajı vardı. Yapılan yazım hataları hiçbir hata vermeden test metotlarının görmezden gelinmesine sebep oluyordu. Örneğin testSafetyOverride
isimli bir test metodu yazmak isterken yanlışlıkla tsetSafetyOverride
yazarsanız JUnit 3 size bir uyarı vermez ama testi de çalıştırmaz. Böylece siz de testin geçtiğini zannederek sahte bir güven duygusuna kapılırsınız.
Ayrıca bu isimlendirme kurallarının doğru yerlerde kullanıldığına emin olamayız. Mesela bir programcı bir sınıfa TestSafetyMechanisms
adını vermek suretiyle içindeki bütün metotların isimleri ne olursa olsun çalıştırılacağını zannedebilir. Ancak sınıf isimlerinin JUnit 3 için bir anlamı yoktur. Bu yüzden testler yine çalıştırılmayacak, hata görmeyen programcı da testlerinin geçtiği yanılgısına kapılacaktır.
Başka bir problem de testleri parametreli bir biçimde yazmak istersek ortaya çıkar. Örnek olarak diyelim ki yazdığımız bazı testlerin sadece belirli bir aykırı durum fırlatıldığında başarılı olmasını istiyoruz. Burada aykırı durumun ismi aslında test için bir parametredir ancak bunu isimlendirme modelleri kullanarak yapmaya çalışmak ortaya çirkin ve kırılgan kodların ortaya çıkmasına sebep olur. (Madde 62)
Notasyonlar (annotations) bu problemlerin hepsinin üstesinden kolaylıkla gelmektedir. JUnit de 4. sürümünden itibaren bunları desteklemektedir. Bu maddede biz de çok basit bir test çatısının nasıl yazılabileceğini notasyonlar kullanarak göstereceğiz. Varsayalım ki testlerin otomatik olarak çalışmasını ve bir aykırı durum oluşursa hata vermesini sağlamak için bir notasyon tanımlamak istiyoruz. Test
isimli bu notasyon aşağıdaki gibi yazılabilir:
// notasyon türlerinin tanımlanması
import java.lang.annotation.*;
/**
* Notasyonu uygulayan metotların test metodu olduğunu gösterir
* Sadece parametresiz ve statik metotlarla kullanılabilir
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
Test
notasyonunu tanımlarken de başka notasyonlar kullandık, bunlara meta notasyon denmektedir. @Retention(RetentionPolicy.RUNTIME)
tanımladığımız Test
notasyonunun çalışma zamanında korunması gerektiğini belirtmektedir, aksi taktirde notasyonumuz test kütüphanesi tarafından görülemezdi. @Target(ElementType.METHOD)
ise bu notasyonun sadece metotlar üzerinde kullanılabileceğini belirtmektedir. Sınıflar, alanlar veya başka program elemanları ile kullanmak derleme hatalarına yol açacaktır.
Yorum satırlarında görebileceğiniz gibi “Sadece parametresiz ve statik metotlarla kullanılabilir” uyarısı yapılmaktadır ancak derleyici bu kurala uyulup uyulmadığını siz ek olarak bir de notasyon işleyici (annotation processor) yazmadığınız sürece denetleyemez. Böyle bir işleyici olmadığı için Test
notasyonu bir nesne metodu üzerinde veya parametresi olan bir metot üzerinde tanımlanırsa derleyici şikayet etmeyecektir. Bu durumu çalışma zamanında test koşturucu (testleri çalıştıran basit program) ele alacaktır.
Pratikte Test
notasyonunun nasıl kullanılacağını aşağıdaki örnekten anlayabiliriz. Bu tarz notasyonlara işaretçi notasyon denilir çünkü notasyonun parametresi olmadığı için beraber kullanıldığı metodu başka program parçalarının görebilmesi için işaretler. Burada programcı bir yazım yanlışı yaparsa veya notasyonu izin verilmeyen bir program elemanı ile kullanırsa derleyiciden hata alacaktır.
// işaretçi notasyonun kullanıldığı sınıf
public class Sample {
@Test
public static void m1() { } // bu test geçmelidir
public static void m2() { }
@Test
public static void m3() { // bu test başarısız olmalıdır
throw new RuntimeException("Boom");
}
public static void m4() { }
@Test
public void m5() { } // geçersiz kullanım: nesne methodu
public static void m6() { }
@Test
public static void m7() { // // bu test başarısız olmalıdır
throw new RuntimeException("Crash");
}
public static void m8() { }
}
Sample
sınıfında yedi tane statik metot var ve bunların dört tanesi Test
notasyonuyla işaretlenmiş. Bunlardan m3
ve m7
aykırı durum fırlatıyor, m1
ve m5
ise fırlatmıyor. Ancak m5
statik metot olmadığı için geçersiz kullanıma örnektir. Dolayısıyla, bu sınıfta dört tane test var. Bunlardan bir tanesi başarılı iki tanesi başarısız olacaktır, bir tanesi ise geçersizdir. Test
notasyonuyla işaretlenmemiş olan diğer dört metot test koşturucu taradından görmezden gelinecektir.
Burada Test
notasyonunun kullanılması Sample
sınıfında bir anlam değişikliğine yol açmaz. İşaretli metotlar bu notasyonla ilgilenen başka programlar tarafından özel bir işleme tabi tutulur. Şimdi Test
notasyonu için bunu yapan test koşturucuya bakalım:
// Test notasyonunu işleyen program
import java.lang.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " failed: " + exc);
} catch (Exception exc) {
System.out.println("Invalid @Test: " + m);
}
}
}
System.out.printf("Passed: %d, Failed: %d%n",
passed, tests - passed);
}
}
Bu test koşturucu program komut satırından bir sınıf ismi almakta ve bu sınıftaki Test
notasyonuya işaretlenmiş bütün metotları Method.invoke
ile reflection kullanarak çalıştırmaktadır. 9. satırda isAnnotationPresent
kullanılarak metodun Test
notasyonuna sahip olup olmadığını kontrol edilmektedir. Eğer test metodu bir aykırı durum üretirse reflection mekanizması bunu InvocationTargetException
ile sarmalamaktadır. Program da bu aykırı durumu yakalayıp test metodu tarafından fırlatılan orijinal aykırı durumu getCause
metodu ile çıkarmakta ve yazdırmaktadır.
Eğer test modunu çalıştırınca InvocationTargetException
haricinde bir aykırı durumla karşılaşıyorsak bu derleme anında yakalanamayan bir geçersiz bir kullanımı ifade etmektedir. Bizim örneğimizde nesne metotları veya parametresi olan metotlar çağırıldığında geçersiz kullanım oluşmaktadır. İkinci catch
bloğu bu hataları yakalayıp raporlamak için yazılmıştır. RunTests
programını Sample
sınıfı için çalıştırırsak aşağıdaki sonucu alırız:
public static void Sample.m3() failed: RuntimeException: Boom Invalid @Test: public void Sample.m5() public static void Sample.m7() failed: RuntimeException: Crash Passed: 1, Failed: 3
Şimdi de belli bir aykırı durumu fırlattığı taktirde başarılı sayılacak testleri nasıl destekleriz ona bakalım. Bunun için yeni bir notasyon türü tanımlayacağız:
// Parametreli notasyon türü
import java.lang.annotation.*;
/**
* Notasyonu kullanan metotların başarılı sayılması
* için belirli bir aykırı durumu fırlatması gerekmektedir.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
Bu notasyonun Class<? extends Throwable>
türünde bir parametresi vardır. Yani parametre olarak Throwable
sınıfını kalıtan bir sınıfın Class
nesnesini kullanabiliriz. Bütün aykırı durumlar (exception) Throwable
sınıfını kalıttığı için bu notasyonu kullanan kişiler istedikleri aykırı durum türünü parametre olarak kullanabilirler. Bu kullanım sınırlandırılmış tür belirtecine bir örnektir. (Madde 33) Şimdi bu notasyon pratikte nasıl kullanılabilir ona bakalım:
// ExceptionTest notasyonunun pratikte kullanımı
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // Bu test geçecektir
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // Başarısız! (yanlış aykırı durum)
int[] a = new int[0];
int i = a[1];
}
@ExceptionTest(ArithmeticException.class)
public static void m3() { } // Başarısız (aykırı durum yok)
}
Şimdi de test koşturucuyu düzenleyip bu yeni notasyonu da işlemesini sağlayalım. Tek yapmamız gereken aşağıdaki kodu main
metoduna eklemek olacaktır:
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.getCause();
Class<? extends Throwable> excType =
m.getAnnotation(ExceptionTest.class).value();
if (excType.isInstance(exc)) {
passed++;
} else {
System.out.printf(
"Test %s failed: expected %s, got %s%n",
m, excType.getName(), exc);
}
} catch (Exception exc) {
System.out.println("Invalid @Test: " + m);
}
}
Bu kod Test
notasyonu için yazdığımıza benziyor ama bir ekleme yaptık. Bir aykırı durum fırlatıldığında bunun türünü notasyonun parametresinde tanımlı aykırı durumla karşılaştırıyoruz. Eğer beklediğimiz aykırı durumla catch
bloğunda yakalanan aynı ise test başarılı olmuştur diyoruz. Bu program hatasız ve uyarısız derlendiğinden çalışma zamanında ClassCastException
alma riskimiz yoktur.
Şimdi bu örneği bir adım daha ileriye götürelim. Bir testin başarılı olabilmesi için belli bir aykırı durum değil de bir grup içinden herhangi bir aykırı durum fırlatılması şart olsun. Mesela bir test metodu NullPointerException
veya IndexOutOfBoundsException
fırlatıldığı taktirde başarılı sayılsın. Notasyon mekanizması bu kullanımı destekleyen bir kolaylık sunmaktadır. ExceptionTest
notasyonundaki parametre türünü Class
nesnelerinin dizisi olacak şekilde değiştirebiliriz:
// parametre türü olarak dizi kullanan notasyon
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Exception>[] value();
}
Notasyonlarda kullanılan bu dizi gösterimi esnektir. Tek elemanlı diziler için optimize edilmiştir. Önceki örnekte tanımladığımız ExceptionTest
notasyonlarının tamamı bu kullanımla tek elemanlı dizilere dönüşeceği için geçerli olacaktır. Parametre olarak birden çok aykırı durum geçmek istersek notasyonu aşağıdaki gibi kullanabiliriz:
// Notasyonun dizi parametresi ile kullanımı
@ExceptionTest({ IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
// Bu metot IndexOutOfBoundsException veya
// NullPointerException fırlatabilir.
list.addAll(5, null);
}
Bunu desteklemek için test koşturucu programımızda ExceptionTest
notasyonunu işleyen kısmı aşağıdaki gibi güncelleyebiliriz:
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
Class<? extends Exception>[] excTypes =
m.getAnnotation(ExceptionTest.class).value();
for (Class<? extends Exception> excType : excTypes) {
if (excType.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed) {
System.out.printf("Test %s failed: %s %n", m, exc);
}
}
}
Java 8 itibariyle notasyon parametresini diziye çevirmek yerine kullanabileceğimiz ikinci bir seçenek daha vardır. @Repeatable
meta notasyonunu kullanarak tekrarlı notasyonlar tanımlamak mümkündür. Şimdi ExceptionTest
notasyonu için bunun nasıl yapıldığını görelim:
// Repeatable notasyon
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
Class<? extends Exception> value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
ExceptionTest[] value();
}
Dikkat ederseniz @Repeatable
meta notasyonu parametre olarak bir taşıyıcı notasyon türünün (containing annotation type) Class
nesnesini almaktadır.
Şimdi de test metotlarımızı bu yeni teknikle nasıl yazmalıyız ona bakalım:
// tekrarlı notasyon kullanan test metodu örneği
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() { ... }
Gördüğünüz gibi aynı metot için ExceptionTest
notasyonunu iki kere kullanabildik. Notasyon tanımlanırken @Repeatable
kullandığımız için bu mümkün olmaktadır.
Şimdi tekrarlı notasyonları işlemek için test koşturucu programı nasıl güncellemek gerekiyor ona bakalım:
// Tekrarlı notasyonların işlenmesi
if (m.isAnnotationPresent(ExceptionTest.class)
|| m.isAnnotationPresent(ExceptionTestContainer.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
ExceptionTest[] excTests =
m.getAnnotationsByType(ExceptionTest.class);
for (ExceptionTest excTest : excTests) {
if (excTest.value().isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed) {
System.out.printf("Test %s failed: %s %n", m, exc);
}
}
}
Tekrarlı notasyonlar okunabilirliği artırmak için dile eklenmiştir. Tanımladığınız notasyonun aynı program elemanı için birden çok kez uygulanabilmesini istiyorsanız kullanabilirsiniz. Ancak tanımlanması ve işlenmesi için biraz daha fazla ve karmaşık kod yazmanız gerektiğini unutmayın.
Bu maddede yazdığınız test çatısı tabii ki çok basittir ancak yine de notasyonların isimlendirme modellerine göre ne kadar üstün olduğunu göstermeye yeterlidir. Notasyonların yapabilecekleri burada gösterdiklerimizin çok daha ötesindedir. Siz de programcıların yazdıkları kaynak koda herhangi bir bilgi eklemesini gerektiren araçlar yazarsanız isimlendirme modelleri değil mutlaka notasyonları kullanın.
Bununla beraber, çoğu programcı günlük hayatlarında notasyon yazma ihtiyacı hissetmeyecektir. Ancak bütün programcılar Java tarafından bize sunulan notasyonları gereken yerlerde kullanmalıdırlar. (Madde 40, Madde 27)
[…] Madde 39’da anlattığımız işaretçi notasyonlar varken işaretçi arayüzlerin gereksiz olduğunu düşünebilirsiniz ama bu doğru değildir. İşaretçi arayüzlerin iki tane üstünlüğü vardır. Birincisi ve en önemlisi işaretçi arayüzler uygulayan sınıflar için bir tür görevi görürler ancak notasyonlarda bu yoktur. İşaretçi arayüz türünün varlığı hataları derleme anında yakalamamızı sağlar. İşaretçi notasyonlar kullanıldığında ise çalışma zamanına kadar hataları farkedemeyiz. […]