Özgür Adem Işıklı Özgür Adem Işıklı Software Developer Resume Contact

Software Developer

Test Yolculuğu: PHP, PHPUnit, Mockery

Yazılım bloglarında tonla “Neden Test Yazmalıyız?” başlıklı makaleler bulabilirsiniz. Bu nedenle bu soruyu cevaplamayacağım. Eğer burayı okuyorsanız test kavramı hakkında en azından bir kaç şey duymuşsunuzdur diye düşünüyorum.

Ben bu yazıda bir çok farklı test türü olmasına rağmen PHP için Unit Test geliştirme konusunu irdelemek istiyorum. Ancak bunu yaparken herhangi bir Framework’e bağlı kalmadan, işin mantığına ve neyi neden yaptığımıza da ince ince değinmeği uygun buluyorum. Dolayısıyla bu makale sizin için sıkıcı geldiği an okumayı bırakmanızı öneririm.

Nasıl?

Unit Test yazmak için PHPUnit ve Mockery kütüphanelerinden faydalanıyoruz.

  • PHPUnit: Unit test yazmak için özel olarak geliştirilmiş bir kütüphane. Profesyonel PHP projelerinin vazgeçilmezi.
  • Mockery: Sınıfları taklit etmemize yarayan kütüphane, kalpazan (benim tabirim).

Motivasyon

Test yazacak motivasyonu kendinizde bulamıyorsanız Adolf Hitler‘in şu meşhur sözünü hatırlayın;

Bir gün yazmadığım her Unit Test için bana küfür edeceksiniz.

Test Driven Development

“Kodu yazmadan önce testini yaz” dediğimiz bu yöntemde, asıl zor olan ne yazacağımızı bilmemektir. Bu nedenle önce ne yapacağımızı anlamaya çalışmak zorundayız. Bu yüzden ben test yazımından önce, doküman yazımını salık veririm.

Bu konu hakkında daha önce yazılmış olan makaleleri inceleyebilirsiniz: README Driven Development - Fatih Kadir Akın, Doküman Tabanlı Geliştirme - Özgür Adem Işıklı

Readme First!

Örnek olarak bir UserRepository sınıfı -biraz basit tutmak istiyorum- yazacağız. Bu sınıfın dokümantasyonunu yazarken tek yampanız gereken: en kolay kullanım formunu yazmak olmalıdır. Son derece basit değil mi?


# Doküman Örneği

$userRepo = new UserRepository();
$user = $userRepo->create('foo@bar.com', 'Foo Bar');
echo $user->email;  // foo@bar.com
echo $user->name;   // Foo Bar

Adım Adım Test Yapısı Oluşturma

  • Yukarıdaki dokümanımızı ana dizine “Readme.md” olarak kaydedin. (Md Uzantısı Nedir?)
  • Yeni bir composer yapılandırma dosyası oluşturun: $ composer init (Bu nedir?)
  • PHPUnit‘i global olarak kurun: composer global require phpunit/phpunit
  • PHPUnit‘i projeye ekleyin: composer require --dev phpunit/phpunit
  • Mockery kütüphanesini projeye ekleyin: composer require --dev mockery/mockery
  • Ana dizinde src ve tests klasörlerinizi oluşturun.
  • PHPUnit için ana dizinde phpunit.xml isimli bir yapılandırma dosyası oluşturun ve aşağıdaki içeriği kaydedin;

<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         bootstrap="vendor/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="true"
         syntaxCheck="false">
    <testsuites>
        <testsuite name="Application Test Suite">
            <directory>./tests/</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
    </php>
</phpunit>

Neyi Neden Yaptık?

Asıl proje dosyalarımızı src, testlerimizi de tests klasöründe barındıracağımızı tahmin etmişsinizdir.

Oluşturduğumuz phpunit.xml dosyası, PHPUnit’e test yapılandırmamızın nasıl olduğu hakkında genel bilgiler vermek için kullanılmaktadır ve şart değildir. Ancak yapılandırma dosyamızın ana dizinde durması bizim için bir rahatlık olacaktır. Dikkat ederseniz son bölümde (<php> etiketinin içinde) APP_ENV isimli bir ortam değişkeni tanımladık. Bu tarz parametreler kullanarak uygulamınızı test ortamına özel olarak çalışacak şekilde geliştirebilirsiniz.

Çalıştırma

Şuanda herhangi bir adım atlanmadıysa konsol üzerinde aşağıdaki komutu çalıştırdığınızda phpunit sorunsuz bir şekilde -inşallah- bize yanıt verecektir.


$ phpunit

PHPUnit 5.2.12 by Sebastian Bergmann and contributors.
Time: 147 ms, Memory: 10.50Mb
No tests executed!

Evet, henüz hiç bir testimiz olmadığından PHPUnit bize hiç bir test çalıştırılmadığını söyledi.

Örnek Test

Şimdi test dizinimizin altına UserRepositoryTest.php isimli bir dosya oluşturuyor ve aşağıdaki içeriği kopyalıyoruz.


class UserRepositoryTest extends PHPUnit_Framework_TestCase {

    public function testSimple()
    {
        $this->assertTrue(true);
    }

}

Tekrardan konsol üzerinden phpunit komutunu çalıştırdığımızda bu sefer sonuç aşağıdaki gibi olacaktır:


PHPUnit 5.2.12 by Sebastian Bergmann and contributors.
.                                                                   1 / 1 (100%)
Time: 134 ms, Memory: 10.75Mb
OK (1 test, 1 assertion)

PHPUnit bize toplamda 1 test çalıştırdığını ve 1 doğrulamanın başarılı bir şekilde sonuçlandığını gösteriyor. Yeşil rengi gördünüz mü? Bu bize herşeyin yolunda olduğunu gösterir. Testimizi biraz incelediğimizde şunları görürüz;

  • UserRepositoryTest isimli sınıf bizim test sınıfımızı temsil ediyor.
  • Tüm test sınıflarımızı PHPUnit_Framework_TestCase sınıfından genişletiyoruz. (Bu sınıfı bize PHPUnit sağlıyor)
  • Sınıfın içerindeki metotlara ayrı ayrı testler yazabiliyoruz.
  • Test metodu olan metotların isimlerini test ile başlatıyoruz. Aksi halde PHPUnit o metotları görmezden gelecektir.
  • PHPUnit’in bize sağladığı doğrulama metotlarını kullanarak (assertTrue()) kontrollerimizi gerçekleştirebiliyoruz. Bu doğrulama metotlarının tamamı PHPUnit Dokümanı üzerinde yer almaktadır.

Gerçek Testimizi Yazalım

Test sınıfımızın sorunsuz olarak çalıştığını gördüğümüzde gerçek test kodlarını yazıyoruz. Bunu yaparken aslında daha önceden yazdığımız dokümana sadık kalıyoruz;


public function testSimple()
{
    $userRepo = new UserRepository();
    $user = $userRepo->create('foo@bar.com', 'Foo Bar');
    $this->assertEquals($user->email, 'foo@bar.com');
    $this->assertEquals($user->name, 'Foo Bar');
}

Dikkat ederseniz açıklama satırı olarak “Burada bu değeri görebilmem gerekiyor.” dediğim bölümler benim asıl test edeceğim unsurları içeriyor. Bu testi yaparken PHPUnit’in bize sağladığı assertEquals metodunu kullanıyoruz ve beklediğimiz ve dönen değeri karşılaştırıyoruz.

Yine konsol üzerinden phpunit komutunu çalıştırdığımızda FatalError ile karşılaştığımızı göreceksiniz.


PHPUnit 5.2.12 by Sebastian Bergmann and contributors.

PHP Fatal error:  Class 'UserRepository' not found in /home/makale/tests/UserRepositoryTest.php on line 7
Fatal error: Class 'UserRepository' not found in /home/makale/tests/UserRepositoryTest.php on line 7

Hemen bu sorunu aşmak için src klasörü altına UserRepository.php isimli dosyayı aşağıdaki gibi oluşturuyoruz.


class UserRepository {
}

Ancak phpunit komutunu çalıştırdığınız zaman yine aynı hatanın oluştuğunu göreceksiniz. Buradaki sorun ilgili sınıfın test bölümünden ulaşılabilecek şekilde include işlemine tabi tutulmamasıdır. Ancak biz include işlemini profesyoneller gibi composer tarafından bize sağlanan autoload bölümüne yaptıracağız. (Autoload Nedir?)

Autoload İşlemi Tanımlaması

Bu işlem için ana dizinde bulunan composer.json dosyasına aşağıdaki eklemeyi yapıyoruz;


"autoload": {
    "classmap": ["src"]
}

Bu eklemeden sonra konsol üzerinde composer dump-autoload komutunu uygulayarak otomatik olarak yüklenecek sınıfların yeniden belirlenmesini istiyoruz.

Autoload işleminde ben kolay olması açısından tüm src klasörünün taranmasını istedim. Ancak bu pek doğru bir yaklaşım değil. Bu tarz otomatik yüklemeler için Namespace kullanmanızı öneririm.

Test Yazmaya Devam

Bu işlemden sonra phpunit komutunu çalıştırdığınızda ilgili fonksiyonun UserRepository içerisinde olmadığıyla ilgili hatayı göreceksiniz. Bu nedenle aşağıdaki eklemeyi yapıyoruz.


public function create($email, $name)
{
}

phpunit komutumuzu çalıştırmamız sonrasında aşağıdaki gibi kırmızı bir ekranla karşılaşırız ve beklenen ve alınan değerlerin uyuşmadığını PHPUnit bize söyler.


PHPUnit 5.2.12 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)
Time: 165 ms, Memory: 10.75Mb

There was 1 failure:
1) UserRepositoryTest::testSimple
Failed asserting that 1 matches expected null.
/home/ubuntu/workspace/makale/tests/UserRepositoryTest.php:9

FAILURES!

Tests: 1, Assertions: 1, Failures: 1.
ozziest:~/workspace/ma

Gerçek Dünyaya Dönmek

Testimizi yeniden yeşile çevirmeden önce gerçek dünyaya dönelim. Test yazmak bu kadar basit değil. Örneğimizde veritabanı işlemi için Eloquent isimli bir başka kütüphane (Laravel kullananlar bilir) kullandığımızı düşünelim. Eloquent‘ın bize sağladığı modeli kullanarak ilgili kaydı yaptımızı varsayalım.

Neden böyle bir varsayım yapıyorum? Çünkü her (fucking) test yazma örneğinde size başka bir yerle bağlantısı olmayan bir sınıf anlatılır. Siz de bağımsız sınıfla olayı anladığınızı düşünürsünüz. Ancak kendi projenizin başına geçtiğinizde kod size, siz koda bakarsınız. Oysaki test yazabilmek için bağımsızlığını ilan etmiş sınıflara sahip olmanız bir zorunluluktur.

Bu nedenle UserRepository.php içerisindeki kodumuzu Eloquent‘ın bize gösterdiği şekliyle User modeli üzerinden kaydedecekmişiz gibi tasarlayalım.


use App\Models\User;

class UserRepository {
    
    public function create($email, $name)
    {
        $user = new User();
        $user->email = $email;
        $user->name  = $name;
        $user->save();
        
        return $user;
    }
    
}

UserRepository sınıfımız burada adı geçen App\Models\User isimli sınıfa sıkı sıkıya bağlı oldu. Olayı bu noktaya getirdikten sonra hep birlikte bir kez daha tekrarlayalım:

Sıkı sıkıya başka sınıflara bağlı olan sınıflar için unit test yazamazsınız!

Yazsanız bile kırk takla atmak zorunda kalırsınız. Bizim buradaki amacımız User sınıfını değil, UserRepository sınıfını test etmek. Aksi halde sadece bir unit‘i, yazılımın basit bir parçasını test etmiş olmayız. Yazdığımız şey de Unit Test olmaz. Dolayısıyla bu bağımlılıktan kurulmamız gerekiyor.

Bu konu ayrıca SOLID Prensipleri‘yle de doğrudan doğruya alakalı olan bir konudur. SOLID prensipleri hakkında öğreneceğiniz her şey test yazımında size yardımcı olacaktır.

Dependency Injection

Bu gibi durumlar için ortaya atılan bir terimdir dependency injection. Yani ilgili bağımlılıkların dışarıdan gönderilmesi işidir. Böylece asıl sınıf, çalışacağı sınıfı dışarıdan alır. Peki ama nasıl?


use Illuminate\Database\Eloquent\Model;

class UserRepository {
    
    private $model;
    
    public function __construct(Model $model)
    {
        $this->model = $model;
    }
    
    public function create($email, $name)
    {
        $this->model->email = $email;
        $this->model->name  = $name;
        $this->model->save();
        return $this->model;
    }
    
}

Bağımlılıkları dışarıdan almanın en iyi yolu yapıcı metodu (construct()) kullanmaktır. Böylece UserRepository sınıfının ilgili model sınıfı ile ilgili bağlantısını kesmiş oluruz. Eğer dikkat ederseniz dışarıdan aldığımız sınıf için Illuminate\Database\Eloquent\Model sıfını dayatırız (bkz: Type Hinting).

Bu noktada kafa karışıklığı olabilir. Sınıf bağımlı olmasın dedik ve bağımlılığı dışarıdan aldık ama yine de bir tip dayatma işlemi gerçekleştirdik. Böyle yapmamızın nedeni ufak bir kalpazanlıkla harika şeyler yapacak oluşumuz. Biraz daha sabırla okumaya devam edin.

Mockery İle Kalpazanlık

Konsol üzerinden phpunit komutunu çalıştırdığınızda aşağıdaki hatayı göreceksiniz;


There was 1 error:

1) UserRepositoryTest::testSimple
Argument 1 passed to UserRepository::__construct() must be an instance of Illuminate\Database\Eloquent\Model, none given, called in /home/ubuntu/workspace/makale/tests/UserRepositoryTest.php on line 7 and defined

Burada yapmak zorunda olduğumuz; testimizde UserRepository sınıfından yeni bir instance alırken bizden talep edilen bağımlılığı dışarıdan göndermemiz. Ama amacımız sadece ve sadece UserRepository sınıfını test etmek olduğundan, gerçekte var olan App\Models\User sınıfını gönderemeyiz. Bu sınıfı taklit eden bir başka sınıf kullanmamız gerekir. Bu iş için Mockery kütüphanesinden yararlanırız;


$userMock = Mockery::mock('Illuminate\Database\Eloquent\Model');
$userRepo = new UserRepository($userMock);

Yukarıdaki kodun ilk satırında verdiğimiz namespace türünde bir taklit sınıfı oluşturulur. Bu işlemi bu kadar kolay yapmamızı sağlayan Mockery kütüphanesidir. Biz de oluşturduğumuz bu yalancı sınıfı UserReposiytory sınıfına göndeririz. Tekrar konsol üzerinde phpunit komutunu çalıştırdığınızda bu sefer çıkacak olan hata şudur;


There was 1 error:
1) UserRepositoryTest::testSimple
BadMethodCallException: Method Mockery_0__Illuminate_Database_Eloquent_Model::save() does not exist on this mock object

Hatayı incelediğimizde taklit sınıfımızın save isimli bir metoda sahip olmadığını görürüz. Özetle: UserRepository sınıfımız gönderdiğimiz yalancı sınıfı bir güzel yedi. Hatta şuan ortada gerçekten (elle tutulur, somut) bir User sınıfı dahi yok. :)

Bundan sonrası Mockery‘nin bize sağladığı güzellikleri kullanma;


public function testSimple()
{
    $userMock = Mockery::mock('Illuminate\Database\Eloquent\Model')
                       ->shouldReceive('save')->times(1)
                       ->mock();
    
    $userRepo = new UserRepository($userMock);
    $user = $userRepo->create('foo@bar.com', 'Foo Bar');
    $this->assertEquals($user->email, 'foo@bar.com');
    $this->assertEquals($user->name, 'Foo Bar');
}

Taklit sınıfını oluşturduğumuz bölüme bakarsanız, shouldReceive metodu ile taklit sınıfımızda save isimli bir metodun olması gerektiğini ve bu metodun da 1 defa çağırılması gerektiğini belirtiyoruz. Taklit sınıfınıza dilediğiniz kadar metot ekleyebilirsiniz. Mockery bu konuda oldukça yardımcı oluyor ve ihtiyacınız her şey Mockery Dokümanı üzerinde detaylıca anlatılmış durumda.

Son bir kez phpunit komutunu çalıştırdığınızda aşağıdaki güzeller güzeli ekranla karşılaşırsınız;


PHPUnit 5.2.12 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 133 ms, Memory: 11.50Mb
OK (1 test, 2 assertions)

Bu noktada eğer dilerseniz UserRepository sıfındaki create metodunun içeriğini temizleyerek ya da bozarak, testlerin düzgün çalışıp çalışmadığını dahi test edebilirsiniz.

Lafı Çok Mu Uzatıyoruz?

İlk başta olay böyle gelebilir, kabul. Bunun en önemli sebebi örnek olarak basit bir şey seçme zaruretimizin oluşunu söyleyebiliriz. Ancak gerçek hayatta sınıflar daha komplikedir. Tam da bu noktada Laravel‘in kendi testlerinden bir bölüm incelemek size biraz ipucu verebilir. (bkz)

İşleri Otomatize Etmek

Ben işin en çok bu kısmından zevk alıyorum. Laravel gibi projelerin GitHub sayfalarında yer alan yeşil renkli kutucuklar hiç dikkatinizi çekti mi? [build-passing] Projede yapılan her değişiklikten sonra tüm testleri elle çalıştırmak zahmetli olabileceğinden, yapılan her değişiklik (commit) sonrası testlerinizi bir çok farklı ortamda (PHP 5.5, PHP 5.6, PHP7, PHP:hhvm) sizin için çalıştıran araçlar vardır. Bunların en meşhurlarından biri Travis-CI. Bu işleri otomatize etmenin terminolojideki genel adı da Continuous Integration‘dır.

Travis sadece private repolar için ücretlidir. Public olan repolarda sınırsızca kullanabilirsiniz. Tek yapmanız gereken projenize bir travis.yml dosyası dahil ederek Travis-GitHub entegrasyonunu yapmak.

Bu bölüm ayrı bir blog yazısı olduğu ve ben zaten hali hazırda bu yazıyı yeterince uzattığım için bu bölüme değinmiyorum. Ancak dileyenler Laravel’in testleri ne durumda inceleyebilirler. (bkz)

Sonuç

  • Proje büyüdükçe ve bir çok farklı yapıdan oluştukça test yazmak zaruri bir hal alır.
  • Bir çok farklı test türü vardır: Unit, Acceptance, Integration vb.
  • Test türleri için farklı farklı araçlar vardır: PHPUnit, Mockery, CodeCeption, Selenium, PHPSpec
  • Unit Test yazabilmek için sınıflarımızın Unit olması gerekmektedir. Gereksiz bağımlılıklar temizlenmeli/dışarıdan dahil edilmelidir.
  • SOLID prensipleri test yazma sürecinin olmazsa olmazıdır ve öğrenilmesi elzemdir.
  • Taklit sınıflar işin önemli bir kısmını oluştururlar.
  • Continuous Integration önemlidir ve her fırsat bulunduğu an uygulanmalıdır.

İleri Okumalar

What About Comments?

I do not use any comment system such as Disqus because of user's privacy. If you want to discuss something about this post, you may open an issue on the repo.

You may check what is the problem with Disqus;