İçeriğe geç
Educora
İleri20 dk23 / 42

pytest ile test yazma

Otomatik testler yaz: pytest testleri nasıl bulur, `assert` yeniden yazımı, istisnaları ve ondalıklı sayıları denetlemek, fixture'lar, `tmp_path` ve `parametrize`.

Kendini test et
Bu derste öğreneceklerin
  • pytest için test fonksiyonları yazmak ve çıktısını okumak
  • pytest.raises ve pytest.approx ile istisnaları ve ondalıklı sayıları denetlemek
  • Fixture'lar ve parametrize ile tekrarı azaltmak

İndirim fonksiyonundaki küçük bir hatayı düzeltiyorsun ve bir hafta sonra biri sepetteki fiyatların yanlış olduğunu fark ediyor. Her değişiklikten sonra her şeyi elle denetlemek imkânsızdır. Otomatik testler, kodunu çağırıp sonuçları denetleyen küçük fonksiyonlardır; kod her değiştiğinde yüzlercesi bir saniyede çalışır. Python'da bunun fiilî standardı pytest'tir: testler, sıradan assert deyimleri içeren sıradan fonksiyonlardır.

İlk testler

pytest'i projenin sanal ortamına kur: python -m pip install pytest. Testleri otomatik olarak toplar: adı test_*.py ya da *_test.py olan dosyaları ve bunların içinde adı test_ ile başlayan fonksiyonları. İşte test edilen kod ve testleri:

Python
def apply_discount(price: float, percent: float) -> float:
    if not 0 <= percent <= 100:
        raise ValueError('percent must be between 0 and 100')
    return round(price * (1 - percent / 100), 2)
pricing.py
Python
from pricing import apply_discount

def test_no_discount():
    assert apply_discount(80, 0) == 80

def test_quarter_off():
    assert apply_discount(80, 25) == 60

def test_rounding():
    assert apply_discount(19.99, 10) == 17.99
test_pricing.py
Terminal
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.13.6, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/aysel/shop
collected 3 items

test_pricing.py ...                                                      [100%]

============================== 3 passed in 0.02s ===============================

Her nokta geçen bir testtir. Şimdi birinin apply_discount'u “sadeleştirip” round()'u kaldırdığını düşün. Sonraki çalıştırma neyin bozulduğunu gerçek değerlerle hemen gösterir:

Terminal
$ pytest -q
..F                                                                      [100%]
=================================== FAILURES ===================================
________________________________ test_rounding _________________________________

    def test_rounding():
>       assert apply_discount(19.99, 10) == 17.99
E       assert 17.991 == 17.99
E        +  where 17.991 = apply_discount(19.99, 10)

test_pricing.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_rounding - assert 17.991 == 17.99
1 failed, 2 passed in 0.03s
Tanım
assert yeniden yazımı

pytest, bir test modülünü çalıştırmadan önce içindeki assert deyimlerini yeniden yazar; böylece başarısız bir denetim ifadenin tüm parçalarının değerlerini gösterir: burada apply_discount(19.99, 10) çağrısının 17.991 döndürdüğünü. Bu yüzden pytest'in assertEqual gibi özel metotlara ihtiyacı yoktur; sıradan bir assert yeterlidir.

Bir test çalıştırıcısı nasıl çalışır

pytest'in arkasında sihir yoktur. Test, bir şey yanlış olduğunda AssertionError fırlatan bir fonksiyondur; çalıştırıcı fonksiyonları toplar, her birini çağırır ve sonuçları sayar. Aşağıdaki mini çalıştırıcı aynı işi birkaç satırda yapar ve assert yeniden yazımı olmadan neyi kaybettiğimizi de gösterir: sıradan bir assert yalnızca kendi yazdığımız mesajı bildirir:

Python
def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError('percent must be between 0 and 100')
    return round(price * (1 - percent / 100), 2)

def test_quarter_off():
    assert apply_discount(80, 25) == 60

def test_rounding():
    assert apply_discount(19.99, 10) == 17.99

def test_wrong_expectation():
    assert apply_discount(50, 10) == 40, 'expected 40'

tests = [f for name, f in list(globals().items()) if name.startswith('test_')]
passed = 0
for test in tests:
    try:
        test()
    except AssertionError as e:
        print(f'FAILED {test.__name__}: {e}')
    else:
        passed += 1
        print(f'PASSED {test.__name__}')
print(f'{passed} passed, {len(tests) - passed} failed')
▸ Beklenen çıktı
PASSED test_quarter_off
PASSED test_rounding
FAILED test_wrong_expectation: expected 40
2 passed, 1 failed

Neyi test etmeli? Deneyimli geliştiriciler her fonksiyon için dört tür durum arar:

  • Tipik değerler — fonksiyonun gündelik işi: apply_discount(80, 25).
  • Sınır durumları — 0, 100, boş liste, tek elemanlı liste; hatalar en çok burada saklanır.
  • Geçersiz girdi — negatif yüzde, 100'den büyük yüzde: fonksiyon açık bir istisna fırlatmalıdır.
  • Gerilemeler — bulunan her hata için onu yeniden üreten bir test yazılır ki bir daha geri gelmesin.

İstisnalar ve ondalıklı sayılar

Hata durumlarını denetlemek, normal durumları denetlemek kadar önemlidir. **pytest.raises** bir bağlam yöneticisidir: test ancak blok belirtilen istisnayı fırlatırsa geçer; match ise ayrıca hata mesajında bir düzenli ifade arar. Ondalıklı sayılar **pytest.approx** ile karşılaştırılır, çünkü ikili aritmetikte 0.1 + 0.2 == 0.3 ifadesi False'tur:

Python
import pytest
from pricing import apply_discount

def test_invalid_percent():
    with pytest.raises(ValueError, match='between 0 and 100'):
        apply_discount(50, 120)

def test_float_sum():
    assert 0.1 + 0.2 == pytest.approx(0.3)

Fixture'lar: testler için hazır veriler

Birkaç testin aynı başlangıç verisine ihtiyacı varsa, bu verinin hazırlanmasını bir fixture'a taşı: @pytest.fixture ile işaretlenmiş bir fonksiyona. Test, fixture'ın değerini yalnızca onu parametre olarak adlandırarak alır; pytest fixture'ı her test için çağırır, böylece her test taze bir nesne alır. pytest'in yerleşik fixture'ları da vardır; örneğin tmp_path, her test için yeni bir geçici klasördür:

Python
import pytest
from cart import Cart

@pytest.fixture
def cart():
    c = Cart()
    c.add('tea', 3.5)
    c.add('bread', 0.8)
    return c

def test_total(cart):
    assert cart.total() == pytest.approx(4.3)

def test_remove(cart):
    cart.remove('tea')
    assert cart.items == {'bread': 0.8}

def test_save(cart, tmp_path):
    path = tmp_path / 'cart.json'
    cart.save(path)
    assert path.exists()

test_remove kendi sepetini değiştirdi ama bu test_total'ı etkilemez; o kendi kopyasını aldı. Bir fixture değerini return yerine yield ile de verebilir: yield'den sonraki kod testten sonra çalışır, tıpkı bir bağlam yöneticisinin çıkış bölümü gibi; bir veritabanı bağlantısını kapatmanın doğal yeri burasıdır. Birçok dosyada kullanılan fixture'lar, pytest'in otomatik yüklediği conftest.py dosyasına yazılır.

parametrize: tek test, çok durum

Her girdi için testi kopyalamak yerine durumları **@pytest.mark.parametrize** ile listele. pytest her demeti kendi adı olan ayrı bir teste dönüştürür; böylece bir başarısızlıkta tam olarak hangi durumun bozulduğu görünür:

Python
import pytest
from pricing import apply_discount

@pytest.mark.parametrize('price, percent, expected', [
    (100, 0, 100),
    (100, 15, 85),
    (80, 25, 60),
    (19.99, 10, 17.99),
])
def test_apply_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected
Terminal
$ pytest -v test_pricing.py
============================= test session starts ==============================
platform linux -- Python 3.13.6, pytest-8.3.4, pluggy-1.5.0 -- /home/aysel/shop/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/aysel/shop
collected 4 items

test_pricing.py::test_apply_discount[100-0-100] PASSED                   [ 25%]
test_pricing.py::test_apply_discount[100-15-85] PASSED                   [ 50%]
test_pricing.py::test_apply_discount[80-25-60] PASSED                    [ 75%]
test_pricing.py::test_apply_discount[19.99-10-17.99] PASSED              [100%]

============================== 4 passed in 0.02s ===============================
KomutNe yapar
pytest -xilk başarısızlıkta durur
pytest -k discountyalnızca adında discount geçen testleri çalıştırır
pytest --lfyalnızca geçen sefer başarısız olan testleri yeniden çalıştırır
pytest -q / pytest -vkısa / ayrıntılı çıktı
pytest --cov=shopkod kapsamı (pytest-cov eklentisi)
Alıştırma

Aşağıdaki tüm testlerin geçmesi için is_palindrome(text) fonksiyonunu yaz: büyük-küçük harf, boşluklar ve noktalama işaretleri yok sayılmalıdır.

Alıştırma · Python
def is_palindrome(text):
    # keep only letters and digits, ignore case, compare with the reversed version
    return False

def test_simple():
    assert is_palindrome('level')

def test_mixed_case():
    assert is_palindrome('Anna')

def test_phrase():
    assert is_palindrome('Was it a car or a cat I saw?')

def test_not_palindrome():
    assert not is_palindrome('python')

for name, test in list(globals().items()):
    if name.startswith('test_'):
        test()
        print('PASSED', name)
▸ Beklenen çıktı
PASSED test_simple
PASSED test_mixed_case
PASSED test_phrase
PASSED test_not_palindrome
Alıştırma

pytest.raises'in basit bir sürümünü yaz: raises(exc_type) bağlam yöneticisi, blok o istisnayı fırlattığında ok: <Ad> was raised yazdırıp istisnayı yutsun, istisna olmadığında ise AssertionError fırlatsın.

Alıştırma · Python
from contextlib import contextmanager

@contextmanager
def raises(exc_type):
    # yield inside try; handle exc_type in except; raise AssertionError in else
    yield

def grade(score):
    if not 0 <= score <= 100:
        raise ValueError('score must be between 0 and 100')
    return 'pass' if score >= 50 else 'fail'

with raises(ValueError):
    grade(120)

try:
    with raises(ValueError):
        grade(70)
except AssertionError as e:
    print('test failed:', e)
▸ Beklenen çıktı
ok: ValueError was raised
test failed: ValueError was not raised

Önemli noktalar

  • pytest test_*.py dosyalarını ve test_* fonksiyonlarını toplar; bir test istisna, genellikle AssertionError fırlattığında başarısız olur.
  • assert yeniden yazımı sayesinde sıradan bir assert, başarısızlığa yol açan değerleri gösterir.
  • pytest.raises(Error, match=...) istisnaları denetler, pytest.approx ise ondalıklı sayıları karşılaştırır.
  • Fixture'lar her teste taze hazırlanmış veri verir; tmp_path yerleşik bir fixture'dır, yield kullanan fixture'lar ise sonrasında temizlik yapar.
  • @pytest.mark.parametrize, tek bir test fonksiyonunu adlandırılmış birçok duruma dönüştürür.

Kendini test et

10 soru. Her doğru cevap XP kazandırır.

1 / 10
pytest varsayılan olarak hangi fonksiyonları test olarak toplar?