- pytest için test fonksiyonları yazmak ve çıktısını okumak
pytest.raisesvepytest.approxile istisnaları ve ondalıklı sayıları denetlemek- Fixture'lar ve
parametrizeile 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:
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.pyfrom 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.99test_pricing.py$ 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:
$ 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.03spytest, 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:
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:
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:
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:
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$ 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 ===============================| Komut | Ne yapar |
|---|---|
pytest -x | ilk başarısızlıkta durur |
pytest -k discount | yalnızca adında discount geçen testleri çalıştırır |
pytest --lf | yalnızca geçen sefer başarısız olan testleri yeniden çalıştırır |
pytest -q / pytest -v | kısa / ayrıntılı çıktı |
pytest --cov=shop | kod kapsamı (pytest-cov eklentisi) |
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.
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
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.
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_*.pydosyalarını vetest_*fonksiyonlarını toplar; bir test istisna, genellikleAssertionErrorfırlattığında başarısız olur. assertyeniden yazımı sayesinde sıradan birassert, başarısızlığa yol açan değerleri gösterir.pytest.raises(Error, match=...)istisnaları denetler,pytest.approxise ondalıklı sayıları karşılaştırır.- Fixture'lar her teste taze hazırlanmış veri verir;
tmp_pathyerleşik bir fixture'dır,yieldkullanan 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.