- pytest üçün test funksiyaları yazmaq və çıxışını oxumaq
pytest.raisesvəpytest.approxilə istisnaları və kəsr ədədləri yoxlamaq- Fiksturalar və
parametrizeilə təkrarlanan kodu azaltmaq
Endirim funksiyasında kiçik bir səhvi düzəldirsən, bir həftə sonra isə kimsə səbətdəki qiymətlərin yanlış olduğunu aşkar edir. Hər dəyişiklikdən sonra hər şeyi əl ilə yoxlamaq mümkün deyil. Avtomatik testlər kodunu çağırıb nəticələri yoxlayan kiçik funksiyalardır; yüzlərlə belə test kod hər dəfə dəyişəndə bir saniyədə işləyir. Python-da bunun faktiki standartı pytest-dir: testlər adi assert operatorları olan adi funksiyalardır.
İlk testlər
pytest-i layihənin virtual mühitinə quraşdır: python -m pip install pytest. O, testləri avtomatik toplayır: adı test_*.py və ya *_test.py olan faylları, onların içində isə adı test_ ilə başlayan funksiyaları. Budur yoxlanılan kod və onun testləri:
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 ===============================Hər nöqtə uğurla keçmiş bir testdir. İndi təsəvvür et ki, kimsə apply_discount-u «sadələşdirib» round()-u silir. Növbəti işə salma nəyin sındığını dərhal, faktiki qiymətlərlə göstərir:
$ 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.03sTest modulunu işə salmazdan əvvəl pytest onun assert operatorlarını yenidən yazır ki, uğursuz yoxlama ifadənin bütün hissələrinin qiymətlərini göstərsin: burada apply_discount(19.99, 10) çağırışının 17.991 qaytardığını. Buna görə pytest-ə assertEqual kimi xüsusi metodlar lazım deyil — adi assert kifayətdir.
Testləri işə salan proqram necə işləyir
pytest-in arxasında sehr yoxdur. Test — nəsə səhv olanda AssertionError qaldıran funksiyadır; test icraçısı funksiyaları toplayır, hər birini çağırır və nəticələri sayır. Aşağıdakı mini icraçı eyni işi bir neçə sətirdə görür — və assert-in yenidən yazılması olmadan nə itirdiyimizi də göstərir: adi assert yalnız özümüzün 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')▸ Gözlənilən nəticə
PASSED test_quarter_off PASSED test_rounding FAILED test_wrong_expectation: expected 40 2 passed, 1 failed
Nəyi yoxlamalı? Təcrübəli proqramçılar hər funksiya üçün dörd növ hal axtarır:
- Tipik qiymətlər — funksiyanın gündəlik işi:
apply_discount(80, 25). - Sərhəd halları — 0, 100, boş siyahı, bir elementli siyahı; səhvlər ən çox burada gizlənir.
- Yanlış giriş — mənfi faiz, 100-dən böyük faiz: funksiya aydın istisna qaldırmalıdır.
- Reqressiyalar — tapılan hər səhv üçün onu təkrarlayan test yazılır ki, səhv bir daha qayıtmasın.
İstisnalar və kəsr ədədlər
Xəta hallarını yoxlamaq normal halları yoxlamaq qədər vacibdir. **pytest.raises** kontekst meneceridir: test yalnız blok göstərilən istisnanı qaldırdıqda keçir; match isə əlavə olaraq xəta mesajında requlyar ifadə ilə axtarış edir. Kəsr ədədlər **pytest.approx** ilə müqayisə olunur, çünki ikilik hesabda 0.1 + 0.2 == 0.3 False-dur:
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)Fiksturalar: testlər üçün hazır verilənlər
Bir neçə testə eyni başlanğıc verilənlər lazımdırsa, onların hazırlanmasını fikstura (fixture) köçür: @pytest.fixture ilə işarələnmiş funksiyaya. Test fiksturanın qiymətini sadəcə onu parametr kimi adlandıraraq alır — pytest fiksturanı hər test üçün çağırır, ona görə hər test təzə obyekt alır. pytest-in daxili fiksturaları da var, məsələn, tmp_path — hər test üçün yeni müvəqqəti qovluq:
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 öz səbətini dəyişdi, amma bu, test_total-a təsir etmir — o, öz nüsxəsini aldı. Fikstura qiyməti return əvəzinə yield ilə də verə bilər: yield-dən sonrakı kod testdən sonra işləyir, kontekst menecerinin çıxış hissəsi kimi — verilənlər bazası ilə əlaqəni bağlamaq üçün təbii yer. Bir çox faylda işlədilən fiksturalar conftest.py faylına yazılır, pytest onu avtomatik yükləyir.
parametrize: bir test, çoxlu hallar
Hər giriş üçün testi köçürmək əvəzinə halları **@pytest.mark.parametrize** ilə sadala. pytest hər kortejdən öz adı olan ayrıca test yaradır, ona görə uğursuzluq zamanı məhz hansı halın sındığı 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 ===============================| Əmr | Nə edir |
|---|---|
pytest -x | birinci uğursuzluqda dayanır |
pytest -k discount | yalnız adında discount olan testləri işə salır |
pytest --lf | yalnız əvvəlki dəfə sınmış testləri təkrarlayır |
pytest -q / pytest -v | qısa / ətraflı çıxış |
pytest --cov=shop | kodun testlərlə əhatəsi (pytest-cov plagini) |
Aşağıdakı testlərin hamısı keçsin deyə is_palindrome(text) funksiyasını yaz: böyük-kiçik hərflər, boşluqlar və durğu işarələri nəzərə alınmamalı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)▸ Gözlənilən nəticə
PASSED test_simple PASSED test_mixed_case PASSED test_phrase PASSED test_not_palindrome
pytest.raises-in sadə variantını yaz: raises(exc_type) kontekst meneceri blok həmin istisnanı qaldıranda ok: <Ad> was raised çap etsin və istisnanı udsun, istisna olmayanda isə AssertionError qaldırsı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)▸ Gözlənilən nəticə
ok: ValueError was raised test failed: ValueError was not raised
Əsas fikirlər
- pytest
test_*.pyfayllarını vətest_*funksiyalarını toplayır; test istisna, adətənAssertionError, qaldıranda sınır. assert-in yenidən yazılması sayəsində adiassertuğursuzluğa səbəb olan qiymətləri göstərir.pytest.raises(Error, match=...)istisnaları,pytest.approxisə kəsr ədədləri yoxlayır.- Fiksturalar hər testə təzə hazır verilənlər verir;
tmp_pathdaxili fiksturadır,yield-li fiksturalar isə sonda təmizləmə aparır. @pytest.mark.parametrizebir test funksiyasını adlı çoxlu hala çevirir.
Özünü yoxla
10 sual. Hər düzgün cavab XP qazandırır.