Salı, Mart 08, 2011

jmockit ile ideal birim test

projeler genelde büyük bir enerji ile başlar. hele bir de çevik programlama ve test güdümlü programlama söylemlerinin büyüsüne kapılmış bir ekibiniz varsa, harıl harıl birim test yazılmaya başlanır.

zaman problemi olmayan bu enerjik ekip hiç bir şeyden üşenmez. sahte sunucular havalarda uçuşur, veritabanında çılgın sahte veriler oluşturulur. testi yazılmamış tek bir metod bulamazsınız.

sonra projelerin ortalarına doğru, yazılan kodları birleştirme aşamasına gelindiğinde, odak geliştirmek değil birleştirmek olduğundan yeni test eklenmemeye başlanır. varolan testler ise sınırlı ölçüde güncellenmeye devam eder. metodun kapsamı genişlemişse, genişleyen kapsam için test eklenmez, sadece daha önce yazılmış testlerin geçmesinin sağlayacak şekilde en az değişiklik felsefesiyle test güncellemeleri yapılır.

projenin sonuna doğru ise ekibimizde ne enerji ne de zaman kalmıştır. ama kod değişmeye devam eder. bu zamanlarda projede "önce çalışsın" havası hakim olur. yazılmış olan testler patlamaya başladıkça yorum haline getirilir.

ve gün gelir, bakmışsınız, elinizde hiç kayda değer bir birim test kalmamış, projenin başında harcadığınız onca çabayı çöpe atmışsınız.

bu duruma düşmemek için birim testler konusunda ekibin bilinçlendirilmesi şart. herkesin şu gerçekleri kabul etmesi gerek:

  • yaz ve unut birim testi yoktur. test kodunu yazarken bir gün bu kodun başkası tarafından güncellenmesi gerekeceğini unutma.
  • birim test yazımı kodun kendisinin yazımından daha uzun sürer, daha çok emek ister.
  • birim test ile fonksiyonel test ayrı şeylerdir. birim test ile test etmeye çalıştığımız kendi yazdığımız kod, kullandığımız veritabanının veya bağlandığımız web servisinin kodu değil!
  • test ettiğin metod alt metodlar çağırıyorsa, alt metodun yerine getirdiği işlevin doğrulamasını burada yapma! sadece alt metodun doğru paremetrelerle çağrılıp çağrılmadığını ve alt metodun döndüğü değerlere testini yazdığımız fonksiyon doğru tepki veriyor mu kontrolleri için test yaz.
  • testleri yorum haline getirmek testleri geçirme yöntemi değildir.
yine zaman içerisinde bu duruma düşmemek için kendimce geliştirmiş olduğum bir takım prensiplerim mevcut:
  • test kodundan harici bir sunucuya, veritabanına vs. bağlanmaya çalışma.
  • mümkünse birim testler ön ayar gerektirmesin. projenin kodlarını çekip "ant clean test" demek birim testleri çalıştırmak için yeterli olsun.
  • uygulamayı üzerinde geliştirdiğin "framework"'u birim testler için ayağa kaldırma.
  • projenin tüm birim testlerini çalıştırmak 1 dk.'yi geçmesin.
bunlar güzel idealler, peki pratikte bunu nasıl yapıyoruz, örneğin tek yaptığı iş veritabanından bir sorgu yapmak olan metodun testini veritabanına bağlanmadan nasıl yazacağız? cevap: sahte nesnelerle.

sahte nesne yaratmak için, özellikle javada bir çok gelişmiş kütüphane mevcut. yukarıda söylediğim şartları yerine getirebilmeniz için kullandığınız kütüphanenin şu özellikleri deskteklemesi gerek:
  • 'constructor' üzerine yazabilmelisiniz (metod içerisinde "new" ile yaratılmış nesneleri sahte nesnelerle değiştirebilmek için).
  • final/static/native/private metod ve değişkenlerinin üzerine yazabilmelisiniz.
  • test ettiğiniz sınıfın bir kısmını sahte hale getirebiliyor olmalısınız.
tüm bunları yapabildiğim kütüphane jmockit oldu. yine jmockit ekibinin, biraz yanlı da olsa, sahte nesne kütüphanelerinin özelliklerini karşılaştırdığı şu tablodan, seçtiğiniz kütüphanenin yeteneklerini kontrol edebilirsiniz.

ben burada jmockit üzerinden bazı pratik örnekler vermeye çalışacağım:
  • sınıfın bir kısmının üzerine yazmak.
  • statik metodun üzerine yazmak.
  • private değişkene değer atamak.
  • metod içerisinde new ile yarattığın bir nesnenin üzerine yazmak.

jmockit ile sahte nesnesi üretilecek bir interface:
public interface IFoo {
public String bar();
}

jmockit ile 'constructor'i üzerine yazılacak 'concrete' sınıf:
public class Foo implements IFoo {
public Foo() {
}

public String bar() {
return "foobar";
}
}
birim testi yazılacak örnek sınıf:
public class Sample {

private String privateField = "sample";
private IFoo fooNotInitiated = null;
private IFoo fooInitiatedAtConstructor = null;
private IFoo fooInitiatedAtDefinition = new Foo();

public Sample() {
this.fooInitiatedAtConstructor = new Foo();
}

public Sample(IFoo fooPassed) {
this.fooInitiatedAtConstructor = fooPassed;
}

public static String staticMethodA() {
return "a";
}

public static String staticMethodBCallingA() {
return staticMethodA() + ",b";
}

public String instanceMethodUsingPrivateFieldAndNewFoo() {
Foo foo = new Foo();
return privateField + ":" + foo.bar();
}

public String instanceMethodUsingPrivateFooNotInitiated() {
return fooNotInitiated.bar();
}

public String instanceMethodUsingPrivateFooInitiatedAtDefinition() {
return fooInitiatedAtDefinition.bar();
}

public String instanceMethodUsingPrivateFooInitiatedAtConstructor() {
return fooInitiatedAtConstructor.bar();
}
}
örnek birim test:
import junit.framework.TestCase;
import mockit.Mock;
import mockit.Mockit;
import mockit.MockUp;
import static mockit.Deencapsulation.setField;

public class SampleTest extends TestCase {

private Sample instance;

protected void setUp() {
instance = new Sample();
}

public void testStaticMethodA() {
assertEquals("a", Sample.staticMethodA());
}

public void testStaticMethodBCallingA() {
Mockit.setUpMock(Sample.class, new Object() {
@Mock
public String staticMethodA() {
return "m";
}
});
assertEquals("m,b", Sample.staticMethodBCallingA());
}

public void testOverridingNewOperator() {
new MockUp() {
@Mock
void $init() {
}

@Mock
public String bar() {
return "moobar";
}
};
assertEquals("sample:moobar", instance.instanceMethodUsingPrivateFieldAndNewFoo());
setField(instance, "privateField", "mample");
assertEquals("mample:moobar", instance.instanceMethodUsingPrivateFieldAndNewFoo());
assertEquals("moobar", instance.instanceMethodUsingPrivateFooInitiatedAtConstructor());
assertEquals("moobar", instance.instanceMethodUsingPrivateFooInitiatedAtDefinition());
instance = new Sample(new Foo());
assertEquals("sample:moobar", instance.instanceMethodUsingPrivateFieldAndNewFoo());
assertEquals("moobar", instance.instanceMethodUsingPrivateFooInitiatedAtConstructor());
assertEquals("moobar", instance.instanceMethodUsingPrivateFooInitiatedAtDefinition());
}

public void testCreatingMockFromInterface() {

IFoo mockFoo = new MockUp() {
@Mock
public String bar() {
return "moobar";
}
}.getMockInstance();
try {
assertEquals("moobar", instance.instanceMethodUsingPrivateFooNotInitiated());
fail("expecting null pointer exception here!");
} catch(java.lang.NullPointerException e) {
}
setField(instance, "fooNotInitiated", mockFoo);
assertEquals("moobar", instance.instanceMethodUsingPrivateFooNotInitiated());
assertEquals("foobar", instance.instanceMethodUsingPrivateFooInitiatedAtDefinition());
assertEquals("foobar", instance.instanceMethodUsingPrivateFooInitiatedAtConstructor());
instance = new Sample(new Foo());
assertEquals("foobar", instance.instanceMethodUsingPrivateFooInitiatedAtConstructor());
assertEquals("foobar", instance.instanceMethodUsingPrivateFooInitiatedAtDefinition());
setField(instance, "fooNotInitiated", mockFoo);
setField(instance, "fooInitiatedAtConstructor", mockFoo);
setField(instance, "fooInitiatedAtDefinition", mockFoo);
assertEquals("moobar", instance.instanceMethodUsingPrivateFooNotInitiated());
assertEquals("moobar", instance.instanceMethodUsingPrivateFooInitiatedAtDefinition());
assertEquals("moobar", instance.instanceMethodUsingPrivateFooInitiatedAtConstructor());
}
}


derleyip birim testleri çalıştıran kod:
#!/bin/sh
javac -cp .:jmockit/jmockit.jar:junit-4.9b2.jar Sample.java SampleTest.java && java -javaagent:jmockit/jmockit.jar -cp jmockit/jmockit.jar:junit-4.9b2.jar:. org.junit.runner.JUnitCore SampleTest

testStaticMethodA:

Kendi ürettiği string bir değeri dönen basit bir metod. yazdığımız test de üretilen string değerin ne olduğunu test ediyor.

testStaticMethodBCallingA:

burada B metodu kendi içerisinde A'yı çağırıyor. kendi ürettiği string bir değeri A'nın ürettiğiyle birleştirip dönüyor. kurada yazılacak birim testte A'nın ne ürettiğiyle ve nasıl ürettiğiyle ilgilenmiyoruz aslında. doğrulamamız gereken B'nin ürettiği string değer ve A'nın dönüş değerini nasıl kullandığı. dolayısıyla staticMethodA'nın üzerine yazıp döndüğü değere müdahale ediyoruz. staticMethodA veritabanına veya bir webservisine bağlanan bir metod olabilirdi. Bu fonksiyonu sahte hale getirerek tüm bu bağımlılıklardan da kurtulmuş oluyoruz.

aynı zamanda bu örnekle "sınıfın bir kısmının üzerine yazmak" ve "statik metodun üzerine yazmak" kavramlarını da açıklamış olduk. "staticMethodA" sahte hale getirilirken birim testini yazdığımız "staticMethodBCallinA" metodunun orjinal halini çağırabildik. burada statik metodu sahte hale getirme yöntemi önemli ("Mockit.setUpMock(Sample.class, new Object(){...});" şeklinde). benim denediğim diğer yöntemlerde (new Object yerine Class kullanmak mesela) çeşitli problemler yaşadım (testin birinde sahte hale getirdiğim bir metodun başka bir testte orjinal halini kullanamamak gibi).

testOverridingNewOperator:

bu test içinde yaptığımız new operatörü üzerine yazma işini her birim test arabirimi desteklemiyor, kişisel olarak başlıca jmockit tercih sebebimdir.

dünya'da herkes ideal kod yazmıyor malesef, kullandığınız her 'framework' veya kütüphane doğru enjeksiyon noktaları bıraksa muhtemelen çok ihtiyacımız olmazdı bu özelliğe. eğer değişken tipi olarak 'interface' değil de 'concrete' sınıflar kullanırsanız ve yarattığınız nesnelerin üzerine yazılabilecek herhangi bir yöntem sunmazsanız (örneğin bir metodun içinde bir nesne yaratıyorsunuz, yeni nesneden bir metod çağrısı yapıp, nesneyi çöpe atıyorsunuz), insanlara new operatörünün üzerine yazmak dışında bir şans bırakmazsınız.

örnekteki testte $init fonksiyonuyla hiç parametre almayan 'constructor'in üzerine yazmış olduk. o satırdan itibaren çalıştırılan her "new Foo();" işleminde gerçek 'constructor' yerine sahte 'constructor' çalıştırılacaktır. yaratılan nesne'den yapılan metod çağrılarında eğer sahtesi yazılmış bir metod varsa sahtesi, yoksa orjinali çalıştırılacaktır. gerçek 'constructor'in çağrılması istendiği durumlar için jmockit gerçek sınıftan yaratılan bir nesneyi enkapsüle ederek bir çözüm sunmaktadır. burada detaya inmemek için bu konuya girmiyorum.

dikkat ederseniz 'Sample' sınıfının nesnesi 'setup' fonskiyonunda yaratılmış olmasına rağmen, 'Sample' sınıfının alanlarının tanımlanması aşamasında ve 'constructor'inda yaratılmış 'Foo' nesneleri de sahte nesneler olarak gelmiş (bar fonksiyon çağrıları 'foobar' yerine 'moobar' dönmüş).

yine bu testte "private değişkene değer atamak" örneğini de 'privateField' değişkeni üzerinden vermiş olduk. 'Spring' gibi sınıf değişkenleri "bean" ayarları okunduktan sonra oluşturulan nesnelerle doldurulan bir 'framework' kullanıyorsanız bu özellik çok işinize yarayacaktır.

testCreatingMockFromInterface:

new operatörünün üzerine yazmak güzel ama getirdiği bir dezavantaj var. ya o sınıftan yaratılan her nesnenin üzerine yazmak istemiyorsak? örneğin test etmek istediğiniz metod java.io.BufferedReader sınıfından bir nesne yaratıyor ve sizin de bu sınıfın sahtesini yazmanız gerek. eğer new operatörü üzerine yazarsanız sistemdeki her BufferedReader nesnesini sahte hale getirmiş olursunuz. örneğin üzerine çalıştığınız 'framework' bir yerde BufferedReader nesnesi yaratıyorsa kodunuz çalışmayacak hale gelecektir.

bu örnekte 'IFoo' interface'inden bir sahte sınıf yaratılıyor ve önce hiçbir yerde ataması olmayan "fooNotInitiated" değişkenine bu sahte nesne atanıyor. "fooNotInitiated" örneği annotasyonlarla yaratılan sınıf değişkenleri için güzel bir çözüm. bu sırada "fooInitiatedAtConstructor" ve "fooInitiatedAtDefinition" değişkenleri orjinal 'Foo' nesnesini barındırmaya devam ediyor. daha sonra bu değişkenlerin de üzerine yazılabileceğine örnek olması açısından sahte nesne bu değişkenlere atanarak aynı fonksiyon çağrıları tekrar yapılıyor ve dönüş değerlerinin "moobar" olarak değiştiği görünüyor.

bu yöntemde en bağlayıcı nokta 'concrete' sınıflardan 'mock' nesne yaratılamaması! yani 'Sample' sınıfının "fooNotInitiated" değişkeninin tipi 'IFoo' değil de 'Foo' olsaydı elimizden birşey gelmezdi. 'concrete' sınıfı sahte hale getirmenin tek yolu $init fonksiyonunu kullanmak. yani kullandığınız kütüphane değişken tipi olarak 'concrete' sınıflar kullanmışsa elinizi kolunuzu bağlamış oluyor.

özetle kullandığınız birim test çatılarının yetenekleri de bir yere kadar, bir yerde yine gelip insan faktörüne takılabiliyorsunuz.

test edilebilir kod yazmak ya da yazmamak! işte bütün mesele bu!


Hiç yorum yok:

Yorum Gönder