Məzmuna keç
Educora
İrəli20 dəq23 / 42

pytest ilə testləşdirmə

Avtomatik testlər yaz: pytest testləri necə tapır, `assert`-in yenidən yazılması, istisnaların və kəsr ədədlərin yoxlanması, fiksturalar, `tmp_path` və `parametrize`.

Özünü yoxla
Bu dərsdə öyrənəcəksən
  • pytest üçün test funksiyaları yazmaq və çıxışını oxumaq
  • pytest.raises və pytest.approx ilə istisnaları və kəsr ədədləri yoxlamaq
  • Fiksturalar və parametrize ilə 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:

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 ===============================

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:

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
Tərif
assert-in yenidən yazılması

Test 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:

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')
▸ 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:

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)

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:

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 ö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:

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 ===============================
ƏmrNə edir
pytest -xbirinci uğursuzluqda dayanır
pytest -k discountyalnız adında discount olan testləri işə salır
pytest --lfyalnız əvvəlki dəfə sınmış testləri təkrarlayır
pytest -q / pytest -vqısa / ətraflı çıxış
pytest --cov=shopkodun testlərlə əhatəsi (pytest-cov plagini)
Tapşırıq

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.

Tapşırıq · 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)
▸ Gözlənilən nəticə
PASSED test_simple
PASSED test_mixed_case
PASSED test_phrase
PASSED test_not_palindrome
Tapşırıq

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.

Tapşırıq · 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)
▸ Gözlənilən nəticə
ok: ValueError was raised
test failed: ValueError was not raised

Əsas fikirlər

  • pytest test_*.py fayllarını və test_* funksiyalarını toplayır; test istisna, adətən AssertionError, qaldıranda sınır.
  • assert-in yenidən yazılması sayəsində adi assert uğursuzluğa səbəb olan qiymətləri göstərir.
  • pytest.raises(Error, match=...) istisnaları, pytest.approx isə kəsr ədədləri yoxlayır.
  • Fiksturalar hər testə təzə hazır verilənlər verir; tmp_path daxili fiksturadır, yield-li fiksturalar isə sonda təmizləmə aparır.
  • @pytest.mark.parametrize bir test funksiyasını adlı çoxlu hala çevirir.

Özünü yoxla

10 sual. Hər düzgün cavab XP qazandırır.

1 / 10
pytest susmaya görə hansı funksiyaları test kimi toplayır?