fatiherikli

Temiz bir şekilde test yazmak bazı durumlarda sıkıcı ve zor bir iş haline gelebiliyor. Özellikle Django gibi çok katmanlı uygulamaları test ederken "kendini tekrar etme" prensibini unutup kovboy gibi kod yazmak çok kolay.

Test yazmanın eğlenceli olması gerektiğini düşünüyorum. Aynı zamanda projenin çekirdek kodlarına verilen önem kadar da önem verilmesini. Bu yazıda test yazmayı biraz daha eğlenceli kılan ve testleri güzel bir şekilde organize etmenizi sağlayan Lettuce'dan bahsedeceğim. Ruby'deki Cucumber'ın Python versiyonu diyebiliriz.

Bu yazıda BDD (Behaviour Driven Development)'dan ziyade daha çok Lettuce'un BDD için bize sağladığı güzelliklerden bahsedeceğim.

Gherkin Dili

Lettuce'da testler özellik ve senaryolardan oluşur. Bir özelliğin içinde o özellik ile çeşitli senaryolar bulunur. Bu senaryoları Gherkin adında basit bir DSL ile yazıyoruz. Bu dil programcı olmayan insanlar tarafından da testlerin anlaşılabilmesi ve testlere katkıda bulunabilmesini sağlıyor.

Gherkin'in aşağıdaki gibi basit bir söz dizimi var.

Feature: Commenting on documents
    In order to discussing with the other users,
    As an authenticated user,
    I want to comment on a pattern

  Scenario: users can comment on documents
    Given I am logged in as user "tester"
    And I create a pattern that named "Comment Model"
    When go to the that pattern
    And I type the "body" as "Test Comment"
    When I submit the comment
    Then the comment count of that pattern should be 1

Kurulum ve Konfigürasyon

Lettuce'un içinde Django ile de çalışabilmek için bir uygulama bulunuyor. Yani lettuce uygulamasını kurmamız yeterli olacaktır.

pip install lettuce

Lettuce'u kurduktan sonra settings.py'ınızda INSTALLED_APPS kısmına 'lettuce.django' uygulamasını eklemeniz gerekiyor.

INSTALLED_APPS = (
    # ...
    'lettuce.django',
)

Default konfigürasyonda senaryolarınızı herhangi bir uygulamanızın altında features klasöründe yazmanız gerekiyor.

Özellik ve Senaryolar

Örnek olarak profiles adında bir uygulamız olduğunu düşünelim. Profiles uygulamasında da bir Profile modeli olsun.

Profiles uygulamasının altında features adında bir klasör ve bunun altında following.feature adında bir dosya oluşturalım. Feature dosyamız şu şekilde olsun:

Feature: Following users
    In order to see the news of a user,
    As an authenticated user
    I want to follow an user.

  Background:
    Given following users exist
      | username | password |
      | edi      | 123456   |
      | budu     | 123456   |

    When I am logged in as user "edi"

  Scenario: users can follow the others
    When I go to the profile of "budu"
    And I click to follow button
    And I go to the profile of "budu" again
    Then the page should contains "edi following budu"

  Scenario: users can not follow himself
    When go to my profile
    The page should not contains "Follow"
    The page should not contains "Unfollow"

Bu feature dosyasının bir de adımlarının parse edildiği step dosyaları olması gerekiyor. Step tanımlarını bu dosyadaki konseptleri ne olduğunu anlatarak vermek istiyorum.

Feature: Following users # feature name
    In order to see the news of a user,
    As a authenticated user
    I want to follow that user.  # feature headline

Burada ilk olarak feature'ımızın adını belirtiyoruz. Sonraki satırlarda ise bu özelliğin başlığını tek cümle ve üç satır olacak şekilde belirtiyoruz. Genelde başlık tanımları In order to ile feature'ın amacını belirten bir satır ile başlar. İkinci satırda As a somebody şeklinde kişi belirtilir. Son satırda ise I want to ile başlanıp yapılacak eylem belirtilir. Bu tanımlar zorunlu bir kural değildir. Ancak genelde özellikler bu şekilde tanımlanır. Eğer istemiyorsanız sadece feature'ın adını belirtip de geçebilirsiniz.

  Background:
    Given following users exist
      | username | password |
      | edi      | 123456   |
      | budu     | 123456   |

    When I am logged in as user "edi"

Background tanımı ise tüm senaryoların çalışmadan önce yapılacak işlerdir. Background tanımlarını klasik unit test sınıflarındaki setUp metodu olarak düşünebiliriz. Bu background tanımında iki adet adım (step) görüyoruz.

Gherkin diline göre adımlar When, Then, Should, And ya da But kelimeleri ile başlamalıdırlar. Bu da diğer konseptler gibi gerekli olan bir kural değildir ancak yeniden kullanılabilirliği arttırmak için bu şekilde tanımlamak her zaman daha iyidir. Bir senaryo içerisinde adımlar sırası ile çalıştırılır. Bir senaryo diğer senaryolardan bağımsız bir şekilde çalışabilir. Ancak adımlarda bunun garantisi yoktur.

İlk adım tabular bir veri içeriyor. Given following users exist dedik ve ardından bir tablo verdik. Sonraki satırlarda | (pipe) işareti ile başlayan satırlar bir önceki satırın verileri anlamına geliyor.

Şimdi bu adımı parse edelim.

from lettuce import *

@step('following users exist') # Bu string regex ile match ediliyor.
def following_users_exist(step):
    """
    Creates new users from provided tabular data.
    """
    for profile_hash in step.hashes: # step.hashes ile adımın tabular verisini alıyoruz.
        profile = profile_hash.copy()
        password = profile.pop("password", None)
        profile, created = Profile.objects.get_or_create(**profile)
        profile.set_password(password)
        profile.save()

Şimdi de When I am logged in as user ile başlayan adımımızı parse edelim. Burada verilen kullanıcıyı siteye giriş yaptıracağız. Bunun için Django'nun test client'ını kullanacağız. İlk olarak bir Client instance'ı oluşturalım.

from lettuce import world, before
from django.test import Client

@before.all
def set_browser():
    """
    Loads django's test client.
    """
    world.browser = Client()

Django client instance'ını adımlarda kullanabilmek için global olarak erişebileceğimiz bir veri yapısına ihtiyacımız var. Lettuce altındaki world bu işe yarıyor. Buna atadığınız herhangi bir şeyi diğer adımlarda da kullanabilirsiniz.

Şimdi adımımızı parse edelim.

@step('I am logged in as user "(.*)"')
def login(step, username):
    world.profile = Profile.objects.get(username=username)
    assert world.browser.login(username=username, password="123456")

Şifreyi test amaçlı elimle verdim. Django client'ının login metodu eğer başarılı olursa True, olmazsa False döndürüyor. Assert kullanmamızın sebebi, eğer login işlemi başarısız olursa testin fail etmesini sağlamaktır.

Background adımlarını parse ettik. Şimdi de senaryo tanımlarına geçelim.

Scenario: users can follow the others
    When I go to the profile of "budu"
    And I click to follow button
    And I go to the profile of "budu" again
    Then the page should contains "edi following budu"

Scenario: users can not follow himself
    When go to my profile
    The page should not contains "Follow"
    The page should not contains "Unfollow"

En başta senaryomuzun tanımını belirttik. Sonrasında ise aynı background'da olduğu gibi adım tanımlarımızı yaptık. Şimdi bu adımları parse edelim.

@step('go to the profile of "(.*)"') # username'i match ettiriyoruz
def go_to_profile(step, username):
    # kullanıcının profil url'i için django'nun reverse metodunu kullanıyoruz
    # yüklediğimız sayfayı sonraki adımlarda ulaşmak için world'e atıyoruz
    world.page = world.browser.get(reverse("auth_profile", args=[username]))
    # dönen durum kodu 200'den farklı ise testi fail ettiyoruz.
    assert world.page.status_code == 200, "Got %s" % world.page.status_code

@step('click to follow button')
def click_to_follow_button(step):
    # follow linkini bulmak için lxml kütüphanesinin
    # cssselect özelliğini kullanıyoruz
    dom = html.fromstring(world.page.content)
    follow_link = dom.cssselect(".follow")[0]
    # buldugumuz linke post ile gidiyoruz
    world.page = world.browser.post(follow_link)
    assert world.page.status_code == 201

@step('the page should contains "(.*)"')
def page_should_contains(step, text):
    assert text in world.page.content

@step('the page should not contains "(.*)"')
def page_should_not_contains(step, text):
    assert not text in world.page.content

Parse etmemiz gereken sadece When I go to the my profile adımı kaldı. Burada bir başka konsepte değinmek istiyorum. Bu adımda login olan kullanıcının sayfasına gitmemiz gerekiyor. Bir kullanıcının sayfasına giden adımı zaten parse etmiştik. Bu işi aşağıdaki gibi daha önce yazdığımız adımı kullanarak yapabiliriz.

@step('go to my profile')
def go_to_my_profile(step):
    # step.given ile başka bir adımı çalıştırabilmekteyiz.
    step.given('go to the profile of "%s"' % world.profile.username)

Ve tüm adımlarımızı bitirdik. Şimdi ise çalışıp çalışmadıklarına bakalım. Lettuce'un Django uygulaması harvest adında bir komutla geliyor. Şimdi python manage.py harvest diyerek sebze bahçemize dalalım :)

Preparing to serve admin site static files...
Creating test database for alias 'default'...

Feature: Following users

  Background:
    Given following users exist
      | username | password |
      | edi      | 123456   |
      | budu     | 123456   |
    When I am logged in as user "edi"

  Scenario: users can follow the others
    When I go to the profile of "budu"
    And I click to follow button
    And I go to the profile of "budu" again
    Then the page should contains "edi following budu"

  Scenario: users can not follow himself
    When go to my profile
    The page should not contains "Follow"
    The page should not contains "Unfollow"

1 feature (1 passed)
2 scenarios (2 passed)
9 steps (9 passed)
(finished within 1 seconds)

Testlerimiz başarılıyla sonlandı.

Testleri Organize Etmek

Bazı durumlarda varsayılan test konfigürasyonlarıyla test yazmak pek verimli olmuyor. Mesela bana her uygulamanın altında bir features klasörü bulunması yerine testlerin ayrı bir klasörde toplanması daha düzenli geliyor. Django'nun kendi test sınıfının çalışma stratejisini değiştirmek için kendi test runner'larımı yazıyorduk. Lettuce'da ise bunun için arazi (terrain) adında bir konsept bulunuyor. (Evet, lettuce ve cucumber'ın isimlendirmelerine hastayım.)

Terrain konsepti ile django'daki manage.py konsepti aynı diyebiliriz. Testler çalışmadan önce terrain çalışır. Bir terrain oluşturmak için manage.py'ın bulunduğu dizinde terrain.py adında bir dosya oluşturalım.

Testlerimizi projenin bulunduğu dizinin üstünde toplayacağımızı varsayalım. Ayrıca her senaryo başlangıcınde Django'nun test sınıfı gibi tüm veritabanı sıfırlansın.

import os, sys

from django.conf import settings
from django.core.management import call_command
from django.test.simple import DjangoTestSuiteRunner

from lettuce import *
from south.management.commands import patch_for_test_db_setup

@before.all
def setup_test_environment():
    """
    Switching to the test database
    """
    patch_for_test_db_setup() # south ile çalışırken gerekli
    # django'nun test runner'ını başlatıyoruz
    world.test_runner = DjangoTestSuiteRunner(interactive=False)
    world.test_runner.setup_test_environment()
    # test veritabanını ayarlıyoruz
    world.test_db = world.test_runner.setup_databases()

    # syncdb metodu ile tablolarımızı oluşturuyoruz
    call_command('syncdb', **{
        'settings': settings.SETTINGS_MODULE,
        'interactive': False,
        'verbosity': 0
    })

@after.all
def destroy_test_environment(total):
    world.test_runner.teardown_databases(world.test_db)
    world.test_runner.teardown_test_environment()

@after.each_scenario
def flush_database(scenario):
    # her senaryoda tablolardaki verileri sıfırlıyoruz
    call_command('flush', **{
        'settings': settings.SETTINGS_MODULE,
        'interactive': False})

def setup_test_directory():
    # step'lerimizin bulundugu klasörü python dizinine ekliyoruz.
    sys.path.append(os.path.join(os.path.dirname(__file__), "../tests"))

    # steps modülünü import ediyoruz
    __import__("steps")

setup_test_directory()

Artık testlerimizi projenin üst dizininde tests klasöründe tutabiliriz. Adımlarımızı da tests klasörünün steps.py ya da steps adında bir pakette tutabiliriz.

Senaryo dizinlerini ayarlamak için ise bir bash scripti yazalım. Proje ve testlerin bulunduğu dizinde run_tests.sh adında bir dosya oluşturalım.

# run_tests.sh
python projectname/manage.py harvest "tests/scenarios/$1"

Artık source run_tests.sh şeklinde testleri çalıştırabilirsiniz. Eğer tek bir feature dosyasını test etmek istiyorsanız da source run_tests.sh users.feature şeklinde testleri çalıştırabilirsiniz.

Adım Tanımlarını Organize Etmek

Bütün adımları tek bir dosyada toplamak can sıkıcı olabilir. Ben bunu aşmak için steps.py yerine içinde __init__.py bulunan steps adında klasörde tutuyorum. Yani bir python paketinde.

Paketin __init__.py'ı aşağıdaki gibi:

from django.test import Client

from lettuce import before, world

# aktif olmasını istediğimiz adımları aşağıdaki gibi import ediyoruz
from .following_steps import *
from .commenting_steps import *
from .registration_steps import *

@before.all
def set_browser():
    # browser'ı ve çeşitli ayarlarınızı burada yapabilirsiniz.
    world.browser = Client()

Örnek Projeler

Kaynaklar

comments powered by Disqus