Test yazmak çoğu yazılımcı ve aslında dolaylı olarak şirketler için büyük bir tabu. Bunun çokça sebebi var ama bugün yazılımcılar tarafında ön plana çıkan bir konuyu inceleyeceğim.
“Test yazmak istiyorum ama ne yazacağımı bilmiyorum”
Bu konuyu sıklıkla duyarız çünkü gerçekten ilk kez test yazmaya başlayan insanlar ne yazacağını bilemiyor. Ben de bilmiyordum. Bu konuda herkes rahat olsun, ilk kez yazan çoğu kişi bilmiyor ve test yazmak zamanla gelişen birşey. Kullandığınız dilin/frameworkün kültürü; şirketin/ekibin kültürü ve tecrübenizle gelişiyor. İlk yazdığınız testler kötü olabilir, gereksiz yere yazılmış olabilir. Bunlarda sıkıntı yok. Önemli olan test yazma işini bir kültür haline getirebilmek.
Neden test yazmalıyız sorusu defalarca konuşuldu. Hiç bu konulara girmeyeceğim. Testin önemini kavramış fakat ne yazacağını bilmeyenler için doğrudan konuya girmek istiyorum. Ben şahsi test yaklaşımımda TDD (Test driven development) yöntemini tercih ediyorum. Örneğin API geliştiriyorsam, bunu postman ile test etmiyorum. Tamamen kullandığım kütüphanedeki test yapıları ile birlikte geliştiriyorum. Yönettiğim projelerde, test konusunda izlediğim çok net bir yaklaşım var. Projede geliştirici arkadaşlar TDD uygulamıyor olabilir, ancak projenin en önemli kısımları için çok büyük oranda test yazılmış olmalı. Diğer kısımlar için ise ekibin kültürüne ve yaklaşımına göre planlama yapıyorum.
Gerçek hayattan örnekler
Peki nelere test yazıyoruz? Neler önemli? Bu soru aslında projeden projeye göre değişir. Testin biçimi de projeye göre değişir. Ben burada biraz daha web tabanlı backend uygulamaları üzerine örnekler vereceğim.
Linklerimiz çalışıyor mu?
Her web tabanlı uygulamanın birinci test edilecek kısmı sayfalarımız doğru biçimde yükleniyor mu sorusudur. Bu aslında çok kolay bir test. Aynı zamanda doğruluğu da zayıf bir test. Çünkü local ortamda çalışırken bir test veritabanı ile çalışıyorsunuz fakat production’da farklı datalar gelebiliyor ve linklerimiz o datalara uyum sağlayamayabiliyor. Yine de, 1 > 0 diyerek ilk etapta “tüm linkler çalışıyor mu” testimizi yazıyoruz.
Bunun için 2 yöntem kullanılabilir. Kullandığınız framework, size bir route listesi sunuyor olabilir. Bu route listesi ile tüm linkleri tek tek test edebilirsiniz. Veya, kendi test ortamınızı crawl ettirebilirsiniz. Ben size ikinci örneği vereceğim.
class AutomatedTest extends TestCase
{
private $bulkUrls = [];
public function testLinks()
{
$client = $this->get('/');
$client->assertStatus(200);
$response = $client->baseResponse->getContent();
$this->checkAllLinks($response);
}
private function checkAllLinks($html)
{
$skipLinks = [
'/login',
'/logout',
];
$links = $this->getLinksFromHtml($html);
foreach ($links as $url) {
$req = $this->get($url);
if (in_array($url, $skipLinks, false)) {
continue;
}
$this->assertEquals(200, $req->baseResponse->status(), $url);
$this->checkAllLinks($req->baseResponse->content());
}
}
public function getLinksFromHtml($html)
{
$dom = new DOMDocument();
@$dom->loadHTML($html);
$urls = [];
$links = $dom->getElementsByTagName('a');
foreach ($links as $link) {
$url = $link->getAttribute('href');
if (starts_with($url, config('app.url'))) {
$url = str_replace(config('app.url'), '', $url);
if (!in_array($url, $this->bulkUrls, true)) {
$this->bulkUrls[] = $url;
$urls[] = $url;
}
}
}
return $urls;
}
}
Öncelikle belirtmeliyim ki, yukarıdaki kod parçasını 10 yıl önce bir projede kullanmıştım. Yani kod kalitesini görmezden gelmenizi öneririm :) Testimiz ise oldukça basit. Anasayfaya bir istek atıyoruz, 200 geldiğini doğruluyoruz. Daha sonra anasayfadaki tüm linkleri topluyoruz ve o linklere de istek atıp 200 geldiğini doğruluyoruz. Bunu recursive biçimde uygulayıp sitedeki tüm linkleri “basit biçimde” test etmiş oluyoruz. Bunu çok kolay biçimde çoklayabilirsiniz. Bir kullanıcıyı, sonrasında farklı roldeki başka kullanıcıyı login edersiniz ve o şekilde test edersiniz. Tüm senaryoları karşılamaz, sadece HTTP500 hatası almadığımızı doğrulamış oluruz. Ya da data kaynaklı bir problem production ortamında olabilir. Ama dediğim gibi, hiç yoktan bu test ile bile onlarca sayfanın en azından happy path dediğimiz “herşeyin doğru olduğunu düşündüğümüz senaryoda” doğru çalıştığından emin olacağız. Aman dikkat edin, herşeyin 200 dönmesi sizi yanılgıya uğratmasın.
Burada maksimum detaya girmek sizin elinizde. Bulk biçimde yapmak yerine, her sayfa için özel testler yazabilirsiniz. Ben sadece basit bir HTTP200 kontrolünün bile ne kadar faydalı olabileceğini aktarmak istedim.
Önemli fonksiyonların testleri
Test ile ilgili yazılan makalelerde genel olarak test fonksiyonlarının nasıl kullanılacağı yazılıyor. assertTrue(true) örnekleri verilip geçiliyor. Ya da github üzerinde repoları gezdiğinizde, genelde kütüphanelerin internal testleri ya da paketlerin testleri oluyor. Dolayısıyla “test yazmak istiyorum ama ne yazacağımı bilmiyorum” sorusuna tam yanıt bulamıyoruz. Çünkü testler projelere özeldir. Bu yüzden burada biraz daha detaya inmek istedim. Aslında örneği gördüğünüz zaman “ne varmış bunda” diyeceksiniz muhtemelen. Ama backend uygulamarında test yazmak bu kadar basit.
Önemli olan şey, test yazılabilir bir kod yazmış olmanız. Örneğin tüm işlemleri controller içerisinde yaptırırsanız, bunu test etmeniz kolay olmayabilir. Yazılım geliştirmedeki SOLID vb. prensiplere sadık kalmalısınız.
İkinci önemli konu ise, sabit bir test veritabanınızın olması. Çünkü testleri tekrar tekrar çalıştırdığınızda hep aynı sonucu vermeli. Veritabanı değişirse, testleriniz doğru sonuç vermeyebilir.
Sabit veritabanı konusunu, 2 yöntem ile sağlayabilirsiniz. Sıfırdan geliştirdiğimiz projelerde, seeder yazıyoruz ve veritabanının istediğimiz gibi dolmasını sağlıyoruz. Sonradan dahil olduğumuz projelerde ise, önemli kullanıcı verilerini çıkartarak, test veritabanının basite indirgenmiş bir halini alıyoruz ve onun üzerinden testleri çalıştırmaya ve datayı geliştirmeye devam ediyoruz.
Gelin bir örnek üzerinden gidelim.
public function testFamilyDiscount()
{
/** @var ProductDiscountService $productDiscountService */
$productDiscountService = app(ProductDiscountService::class);
// Annem + Babam
$person = [
'mother' => [
'isActive' => true,
],
'father' => [
'isActive' => true,
],
'sons' => [
[
'isActive' => false,
],
],
];
$product = Product::where('family_discount_percent', '>', 0)->first();
$this->assertTrue(
$productDiscountService->canGetFamilyDiscount(
$product,
$person
)
);
$product = Product::where('family_discount_percent', '=', 0)->first();
$this->assertFalse(
$productDiscountService->canGetFamilyDiscount(
$product,
$person
)
);
}
Yukarıda gördüğünüz test, bir uygulamada özel bir durumu test ediyor. Bazı ürünler aileler için %X indirim uyguluyor. Eğer o ürüne anne+baba, anne+baba+çocuk, anne+çocuk, baba+çocuk gibi bir başvuru yaparsanız, size indirim uygulayacak. Burası gerçekten kritik öneme sahip. Çünkü, eğer son kullanıcıya yanlış bir fiyat gösterirseniz, başınız ağrıyabilir. ,
Bakalım test ne yapıyor. İndirimlere karar veren bir methodumuz var: canGetFamilyDiscount
Bu method, verilen ürün ve başvuran aile yapısı için true/false yanıt dönüyor. Biz de yukarıda bahsettiğim çeşitli formatları, belirleyip hem indirim içeren hem indirim içermeye ürünleri fonksiyona göndererek doğru yanıt vermesini sağlıyoruz.
Bu test, kullanıcıya indirim gösterileceğini %100 garanti altına almaz. Çünkü onun için muhtemelen farklı faktörler de vardır. En basitinden, frontend tarafının bu veriyi doğru işlemesi gerekir. Ama en azından, biz bu methodun belirtilen koşullar için doğru çalıştığından %100 emin olmuş oluruz. Bu methodda ileride yapılacak bir değişiklik, bizim sonuçlarımızı etkilemez ve doğru çalışacağından emin oluruz. Eğer buradaki business logic değişirse, testi de ona uygun biçimde tekrar güncelleriz. Örneğin %X indirim için artık minimum 4 kişilik başvuru yapılmalıdır gibi bir kural gelebilir. Yine bu kuralın doğru çalıştığını da bu testler sayesinde garanti altına alırız.
Bir not: Bu test aslında biraz basitleştirilmiş durumda. Biz arkaplanda yaklaşık olarak 25 adet varyasyon yazmışız. Her unique durumu ayrı ayrı test ediyoruz. Her biri bizim için çok kritik. Yanlış başvuruya indirim uygulamak ya da indirim uygulanacak birisine uygulamamak çok kötü bir senaryo.
İkinci not: Bu test gördüğünüz gibi aslında bir veritabanı bağlantısı kullanıyor. Burada bahsettiğim gibi, test için veritabanının hazır olması gerekiyor. Yoksa $product = Product::where('family_discount_percent', 0)->first()
için bir yanıt gelmez ve test çalışmaz. Ya da bu konuda bizim kullandığımız Factory sınıfları var. Test esnasında, duruma özel bir ürün yaratmanızı sağlıyoruz.
$validProduct = factory(Product::class)->create([
'family_discount_percent' => 18,
]);
$invalidProduct = factory(Product::class)->create([
'family_discount_percent' => 0,
]);
Yetki (authorization) testleri
Yetki testleri, olmazsa olmaz testlerimizin başında gelir. Hatta yukarıdaki herşeyi boşverin, ilk olarak buraya odaklanın. Yetki kontrol testleri; endpointleri, methodları yazarken ele alınmalı, sonraya bırakılmamalı. Eğer bir endpoint ya da bir method, kullanıcının rolüne ya da kullanıcıya göre farklı davranıyorsa orada mutlaka başkasının datasının görünmediğinden, güncellenmediğinden, başkası adına kayıt oluşturulamadığından vs emin olmalısınız. (Buna çok basit biçimde IDOR açıklarına karşı önlem diyebiliriz. Detaylar şurada: https://twitter.com/fkadev/status/1720434008671055961)
Aslında burada yapılacak şey yukarıda verdiğim 2 örnekten farklı değil. Duruma göre methodlara da test yazabilirsiniz, endpointlere de. Ben size endpoint örneği göstereceğim. Örneğin sipariş detaylarını gösteren bir endpointimiz olsun. Bu endpoint için yetki testi yazalım.
$user1 = factory(User::class)->create();
$user2 = factory(User::class)->create();
$order1 = factory(Order::class)->create(['user_id' => $user->id]);
// İlk olarak tamamen yetkisiz bir istek yapıyoruz, 401 almayı bekliyoruz.
$this->get('/api/orders/'.$order1->id)->assertStatus(401);
// Success testi yapalım
$this->actingAs($user1)->get('/api/orders/'.$order1->id)->assertStatus(200);
// Yetkisiz test yapalım
$this->actingAs($user2)->get('/api/orders/'.$order1->id)->assertStatus(403);
Yetki testlerini yazmak bu kadar basit. Ve büyük hayat kurtarıcı. Örnekleri yazdığım Laravel’de factory sınıfları ile kolayca istediğiniz senaryoyu üretebiliyorsunuz.
Fail senaryoları testleri
Genellikle yapılan testler happy path üzerine yapılır. Belki ilk geliştirirken, fail senaryoları test edilir. Ama daha sonra, regresyon testleri yaparken genelde success durumu kontrol edilir. Bu testlere ek olarak, hata durumlarının da test edilmesi gerekiyor. Authorization testleri bunun bir parçası. Bunun yanı sıra, çeşitli request senaryolarını da test etmemiz gerekiyor.
Yine basit bir örnek ile ilerleyelim. Login sayfamızda email adresinin girilmesi zorunlu olmalı.
$this->post('/api/login', ['password' => '123456'])
->assertStatus(422)
->assertJsonValidationErrors(['email']);
Çok basit bir test. Bu senaryoyu niye test etmeliyiz diye düşünebilirsiniz. Yarın birgün projenizdeki senaryolar kökten değişebilir. Örneğin hem email hem de username kullanarak login özelliği sisteminize gelebilir. Ve burada kodda yapılacak bir hata, yanlış kişi olarak login olunmasını sağlayabilir. Bunun önüne geçecek olan şey bu testlerdir. Username geldiğinde muhtemelen buradaki test hata verecek. Siz dönüp bakacaksınız ve buradaki testi güncelleyeceksiniz. Güncellediğiniz test ise her zaman olması gerektiği gibi çalıştığından emin olmanızı sağlayacak.
Muhtemelen bunun gibi, kullanıcı datasına göre fail yanıt dönen senaryolarınız vardır. Bu senaryoların gerçekten fail ettiğinden emin olun.
Benim için testleri yazmanın en büyük güzelliği, manuel test etme yükümü azaltması. Kendinize bir format belirlediğinizde hızlı hızlı test yazabiliyorsunuz. Postman vb. araçlar üzerinde test etmek -ya da hiç test etmemek- yerine, kodu yazarken testleri de yazarsanız kodlama yaparken bile rahat edersiniz. TDD bence genel olarak yanlış anlaşılıyor. Önce tüm testleri yazıyoruz sonra kodu yazıyoruz gibi bir algı var. Aslında burası genel olarak senkron giden bir süreç. Testi kod ile birlikte yazıyorsanız, “test güdümlü geliştirme” yapıyorsunuz demektir. Burada önemli olan geliştirme hayatınızın merkezine testi almak.
Umarım test yazmak isteyenler için güzel bir başlangıç yapabilmelerine yardımcı olmuşumdur. Sorularınızı her zaman bana çeşitli mecralar üzerinden (X, email vd.) çekinmeden iletebilirsiniz. Bir soru sorabilir miyim harici tüm sorulara kapım açık :)