Практика · Тестування з pytest¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

Тема 19 блоку «Організація коду». Тут ми не будемо читати про тести — ми їх напишемо й запустимо, причому двома способами.

Що зробимо по кроках:

  1. Перевіримо, чи є в цьому середовищі pytest — і домовимось, що робити, якщо його немає.
  2. Візьмемо три функції кошика з лекції й перевіримо їх спершу очима (print), потім assert.
  3. Напишемо власний міні-запускач тестів на десять рядків — щоб побачити, що всередині pytest немає магії.
  4. Відтворимо регресію з інтерактиву 1: підіймемо знижку з 5% до 7% і подивимось, які тести почервоніють.
  5. Створимо справжні файли кошик.py і test_кошик.py у тимчасовій теці, запустимо справжній pytest і переконаємось, що наш саморобний запускач і pytest дають однаковий вирок.
  6. Розберемо pytest.raises, parametrize і межові випадки.
  7. Приберемо за собою тимчасову теку.

Усе, що нижче, виконується згори вниз без жодних правок.

1 · Що є в цьому середовищі¶

Перше правило роботи з чужим інструментом — спершу перевір, чи він узагалі є. importlib.util.find_spec шукає модуль, не імпортуючи його: якщо модуля немає, повертається None, а не летить ImportError.

In [1]:
import sys
import importlib.util

# find_spec лише шукає модуль — імпорт не відбувається, тому це безпечна перевірка
PYTEST_Є = importlib.util.find_spec("pytest") is not None

print("Python:", sys.version.split()[0])
print("pytest встановлений:", "так" if PYTEST_Є else "ні")

if PYTEST_Є:
    import pytest
    print("версія pytest:", pytest.__version__)
else:
    print("Нічого страшного: усе нижче працює й без нього.")
    print("Замість pytest тести запускатиме наша власна функція перевірити(),")
    print("а pytest-синтаксис буде показано текстом із поясненням.")
Python: 3.12.2
pytest встановлений: так
версія pytest: 8.4.0

2 · Наскрізний приклад: кошик покупок¶

Ті самі три функції, що в лекції. до_сплати викликає обидві інші — саме через цей звʼязок далі й станеться регресія.

In [2]:
def знижка(сума):
    """Ставка знижки: 0% до 200, 5% від 200, 10% від 500."""
    if сума < 200:
        return 0.0
    if сума < 500:
        return 0.05
    return 0.10


def підсумок(ціна, кількість):
    """Вартість позиції без знижки, округлена до копійок."""
    return round(ціна * кількість, 2)


def до_сплати(ціна, кількість):
    """Скільки платити за позицію з урахуванням знижки."""
    сума = підсумок(ціна, кількість)
    return round(сума * (1 - знижка(сума)), 2)


# зберігаємо посилання на правильну версію — вона знадобиться, щоб відкотити поломку
знижка_вихідна = знижка

print("знижка(150)  =", знижка(150))
print("знижка(300)  =", знижка(300))
print("знижка(700)  =", знижка(700))
print("підсумок(19.99, 7) =", підсумок(19.99, 7))
print("до_сплати(150.0, 2) =", до_сплати(150.0, 2))
знижка(150)  = 0.0
знижка(300)  = 0.05
знижка(700)  = 0.1
підсумок(19.99, 7) = 139.93
до_сплати(150.0, 2) = 285.0

3 · Перевірка очима: чому print не масштабується¶

Ось як перевіряють функцію в перший день. Числа праворуч у коментарях — те, що ми очікуємо; порівнювати доводиться самому.

In [3]:
print(знижка(150), "   # має бути 0.0")
print(знижка(300), "  # має бути 0.05")
print(знижка(700), "   # має бути 0.1")
print(підсумок(19.99, 7), "# має бути 139.93")
print(до_сплати(150.0, 2), " # має бути 285.0")
print(до_сплати(50.0, 2), " # має бути 100.0")
print()
print("Шість рядків прочитати ще можна. Шістдесят — уже ні:")
print("одна неправильна цифра посеред стіни чисел не впадає в око.")
0.0    # має бути 0.0
0.05   # має бути 0.05
0.1    # має бути 0.1
139.93 # має бути 139.93
285.0  # має бути 285.0
100.0  # має бути 100.0

Шість рядків прочитати ще можна. Шістдесят — уже ні:
одна неправильна цифра посеред стіни чисел не впадає в око.

4 · Та сама перевірка через assert¶

assert вираз не друкує нічого, поки все гаразд, і зупиняє виконання, щойно вираз виявився хибним. Очікувана відповідь тепер записана в коді, а не в коментарі.

In [4]:
assert знижка(150) == 0.0
assert знижка(300) == 0.05
assert знижка(700) == 0.10
assert підсумок(19.99, 7) == 139.93
assert до_сплати(150.0, 2) == 285.0
assert до_сплати(50.0, 2) == 100.0

# сюди виконання дійде тільки якщо жоден assert не спрацював
print("✅ усі шість перевірок пройшли — і жодного рядка виводу від самих assert")
✅ усі шість перевірок пройшли — і жодного рядка виводу від самих assert

5 · Що показує голий assert, коли падає¶

Спіймаємо AssertionError навмисно, щоб роздивитись його зміст. Побачиш: у ньому немає жодного натяку на фактичне значення — саме цю дірку й затуляє pytest своєю інтроспекцією.

In [5]:
try:
    # 19.99 * 7 у двійковій арифметиці дає не рівно 139.93 — перевіримо неправильне число
    assert підсумок(19.99, 7) == 139.0
except AssertionError as розбіжність:
    print("тип помилки:", type(розбіжність).__name__)
    print("текст помилки:", repr(str(розбіжність)))

print()
print("Щоб дізнатись, скільки функція повернула насправді, потрібен окремий рядок:")
print("фактично підсумок(19.99, 7) =", підсумок(19.99, 7))
print()
print("pytest на тому самому місці написав би одразу:")
print("E   assert 139.93 == 139.0")
print("E    +  where 139.93 = підсумок(19.99, 7)")
тип помилки: AssertionError
текст помилки: ''

Щоб дізнатись, скільки функція повернула насправді, потрібен окремий рядок:
фактично підсумок(19.99, 7) = 139.93

pytest на тому самому місці написав би одразу:
E   assert 139.93 == 139.0
E    +  where 139.93 = підсумок(19.99, 7)

6 · Власний міні-запускач тестів¶

Тепер найцікавіше. Усередині pytest немає магії: він бере функції, викликає їх по черзі й дивиться, чи не вилетів AssertionError. Напишемо своє — на пʼятнадцять рядків.

In [6]:
def перевірити(тести):
    """Запускає словник {імʼя тесту: функція} і друкує звіт у стилі pytest.

    тести — словник, де ключ це імʼя тесту, а значення — функція без аргументів.
    Повертає пару (скільки зелених, скільки червоних).
    """
    зелених = 0
    червоних = 0

    for імʼя, тест in тести.items():
        try:
            тест()
        except AssertionError as розбіжність:
            # тест впав саме там, де ми його про це просили
            червоних += 1
            текст = str(розбіжність) or "умова виявилась хибною"
            print("✗", імʼя, "—", текст)
        else:
            зелених += 1
            print("✓", імʼя)

    print(f"\n{зелених} passed, {червоних} failed")
    return зелених, червоних


print("функцію перевірити() створено — далі вона замінятиме нам pytest")
функцію перевірити() створено — далі вона замінятиме нам pytest

Шість тестів на три функції¶

Ті самі шість тестів, що в інтерактиві 1 лекції. Кожен — окрема функція без аргументів, усередині один assert.

In [7]:
def test_знижки_немає_на_малій_сумі():
    assert знижка(150) == 0.0


def test_знижка_5_на_середній_сумі():
    assert знижка(300) == 0.05


def test_знижка_10_на_великій_сумі():
    assert знижка(700) == 0.10


def test_підсумок_округлює_до_копійок():
    assert підсумок(19.99, 7) == 139.93


def test_до_сплати_зі_знижкою():
    assert до_сплати(150.0, 2) == 285.0


def test_до_сплати_без_знижки():
    assert до_сплати(50.0, 2) == 100.0


НАБІР = {
    "test_знижки_немає_на_малій_сумі": test_знижки_немає_на_малій_сумі,
    "test_знижка_5_на_середній_сумі": test_знижка_5_на_середній_сумі,
    "test_знижка_10_на_великій_сумі": test_знижка_10_на_великій_сумі,
    "test_підсумок_округлює_до_копійок": test_підсумок_округлює_до_копійок,
    "test_до_сплати_зі_знижкою": test_до_сплати_зі_знижкою,
    "test_до_сплати_без_знижки": test_до_сплати_без_знижки,
}

зелених, червоних = перевірити(НАБІР)
assert (зелених, червоних) == (6, 0), "на правильному коді всі тести мають бути зеленими"
print("\n✅ базовий стан зафіксовано: 6 зелених, 0 червоних")
✓ test_знижки_немає_на_малій_сумі
✓ test_знижка_5_на_середній_сумі
✓ test_знижка_10_на_великій_сумі
✓ test_підсумок_округлює_до_копійок
✓ test_до_сплати_зі_знижкою
✓ test_до_сплати_без_знижки

6 passed, 0 failed

✅ базовий стан зафіксовано: 6 зелених, 0 червоних

7 · Регресія: правимо одну функцію, ламається сусідня¶

А тепер відтворимо вівторок із лекції. Замовник просить підняти середню знижку з 5% до 7%. Правка на один символ — і ми перевизначаємо знижка.

Зверни увагу, чому це працює: до_сплати шукає імʼя знижка у момент виклику, а не в момент свого визначення. Тому нова версія автоматично підставиться і туди — саме так і виникають регресії в реальному коді.

In [8]:
def знижка(сума):
    """Та сама функція, але середню ставку підняли з 5% до 7%."""
    if сума < 200:
        return 0.0
    if сума < 500:
        return 0.07          # ← ось уся правка
    return 0.10


зелених, червоних = перевірити(НАБІР)

print()
print("Правили ми ОДНУ функцію — знижка. А червоних тестів:", червоних)
assert червоних == 2, "очікуємо рівно два червоних: сама знижка і до_сплати"
print("✅ другий червоний — це до_сплати, яку ми навіть не відкривали.")
print("   300 × 0.93 =", до_сплати(150.0, 2), "замість 285.0")
✓ test_знижки_немає_на_малій_сумі
✗ test_знижка_5_на_середній_сумі — умова виявилась хибною
✓ test_знижка_10_на_великій_сумі
✓ test_підсумок_округлює_до_копійок
✗ test_до_сплати_зі_знижкою — умова виявилась хибною
✓ test_до_сплати_без_знижки

4 passed, 2 failed

Правили ми ОДНУ функцію — знижка. А червоних тестів: 2
✅ другий червоний — це до_сплати, яку ми навіть не відкривали.
   300 × 0.93 = 279.0 замість 285.0

Відкат¶

Функція — це звичайний обʼєкт (тема 14), тому повернути стару версію можна простим присвоєнням: ми ж завбачливо зберегли на неї друге посилання.

In [9]:
знижка = знижка_вихідна          # чіпляємо старий обʼєкт назад на імʼя «знижка»

зелених, червоних = перевірити(НАБІР)
assert (зелених, червоних) == (6, 0), "після відкату все має бути зеленим"
print("\n✅ 6 зелених — код повернувся у робочий стан")
✓ test_знижки_немає_на_малій_сумі
✓ test_знижка_5_на_середній_сумі
✓ test_знижка_10_на_великій_сумі
✓ test_підсумок_округлює_до_копійок
✓ test_до_сплати_зі_знижкою
✓ test_до_сплати_без_знижки

6 passed, 0 failed

✅ 6 зелених — код повернувся у робочий стан

8 · Справжні файли й справжній pytest¶

Досі все жило в зошиті. Але pytest працює з файлами: шукає test_*.py і функції test_* усередині них. Створимо тимчасову теку й покладемо туди два файли — модуль кошик.py і тест test_кошик.py.

Тимчасову теку робить tempfile.mkdtemp(). Наприкінці зошита ми її приберемо.

In [10]:
import os
import pathlib
import shutil
import subprocess
import tempfile
import textwrap

тека = pathlib.Path(tempfile.mkdtemp(prefix="кошик_"))

# Python зазвичай складає поруч скомпільований байткод у __pycache__ і бере його,
# якщо розмір і час зміни файлу збіглись. Ми переписуватимемо кошик.py по кілька
# разів на секунду, міняючи один символ, — тож кеш вимикаємо, інакше імпортувалась
# би стара версія.
sys.dont_write_bytecode = True
СЕРЕДОВИЩЕ = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")

КОД_МОДУЛЯ = textwrap.dedent("""
    def знижка(сума):
        if сума < 200:
            return 0.0
        if сума < 500:
            return 0.05
        return 0.10


    def підсумок(ціна, кількість):
        if ціна < 0 or кількість < 0:
            raise ValueError("ціна й кількість не можуть бути відʼємними")
        return round(ціна * кількість, 2)


    def до_сплати(ціна, кількість):
        сума = підсумок(ціна, кількість)
        return round(сума * (1 - знижка(сума)), 2)
""").strip() + "\n"

(тека / "кошик.py").write_text(КОД_МОДУЛЯ, encoding="utf-8")

print("тимчасова тека:", тека)
print("створено файл: кошик.py,", len(КОД_МОДУЛЯ), "символів")
тимчасова тека: /tmp/кошик_c9cyqil_
створено файл: кошик.py, 405 символів

Файл із тестами¶

Тест починається зі звичайного імпорту модуля, який перевіряє. Імена функцій — з test_, інакше pytest їх не збере.

In [11]:
КОД_ТЕСТІВ = textwrap.dedent("""
    from кошик import знижка, підсумок, до_сплати


    def test_знижки_немає_на_малій_сумі():
        assert знижка(150) == 0.0


    def test_знижка_5_на_середній_сумі():
        assert знижка(300) == 0.05


    def test_знижка_10_на_великій_сумі():
        assert знижка(700) == 0.10


    def test_підсумок_округлює_до_копійок():
        assert підсумок(19.99, 7) == 139.93


    def test_до_сплати_зі_знижкою():
        assert до_сплати(150.0, 2) == 285.0


    def test_до_сплати_без_знижки():
        assert до_сплати(50.0, 2) == 100.0
""").strip() + "\n"

(тека / "test_кошик.py").write_text(КОД_ТЕСТІВ, encoding="utf-8")

print("вміст теки:", sorted(файл.name for файл in тека.iterdir()))
print()
print(КОД_ТЕСТІВ[:220], "...")
вміст теки: ['test_кошик.py', 'кошик.py']

from кошик import знижка, підсумок, до_сплати


def test_знижки_немає_на_малій_сумі():
    assert знижка(150) == 0.0


def test_знижка_5_на_середній_сумі():
    assert знижка(300) == 0.05


def test_знижка_10_на_великій_ ...

Запускач №1: наш власний, з обходом файлів¶

Розширимо перевірити() до повноцінного запускача: хай сам знаходить файли test_*.py, імпортує їх і викликає всі функції, чиї імена починаються з test_. Це буквально те, що робить pytest, тільки без звітів і без інтроспекції.

In [12]:
def знайти_тести(шлях_до_файлу):
    """Імпортує файл і повертає словник {імʼя: функція} для всіх функцій test_*."""
    # тека з модулем має бути у шляху пошуку — інакше «from кошик import ...» не спрацює
    if str(тека) not in sys.path:
        sys.path.insert(0, str(тека))
    # скидаємо кеш модуля: інакше після зміни кошик.py імпортувалась би стара версія
    sys.modules.pop("кошик", None)
    importlib.invalidate_caches()

    специфікація = importlib.util.spec_from_file_location(шлях_до_файлу.stem, шлях_до_файлу)
    модуль = importlib.util.module_from_spec(специфікація)
    специфікація.loader.exec_module(модуль)

    знайдені = {}
    # vars() віддає імена в порядку визначення — так само, як їх збирає pytest
    for імʼя, значення in vars(модуль).items():
        # два фільтри pytest: імʼя починається з test_ і це справді функція
        if імʼя.startswith("test_") and callable(значення):
            знайдені[імʼя] = значення
    return знайдені


def запустити_власним(шлях_до_файлу):
    """Наш саморобний запускач: знаходить тести у файлі й проганяє їх."""
    тести = знайти_тести(шлях_до_файлу)
    print(f"collected {len(тести)} items\n")
    return перевірити(тести)


наш_вирок = запустити_власним(тека / "test_кошик.py")
print("\nнаш вирок:", наш_вирок)
collected 6 items

✓ test_знижки_немає_на_малій_сумі
✓ test_знижка_5_на_середній_сумі
✓ test_знижка_10_на_великій_сумі
✓ test_підсумок_округлює_до_копійок
✓ test_до_сплати_зі_знижкою
✓ test_до_сплати_без_знижки

6 passed, 0 failed

наш вирок: (6, 0)

Запускач №2: справжній pytest в окремому процесі¶

subprocess.run запускає команду так, ніби ти набрав її в терміналі. Ми беремо sys.executable — шлях до того самого Python, у якому працює зошит, — щоб напевно влучити в потрібне середовище (тема 17).

Якщо pytest не встановлений, ця клітинка це чесно скаже й піде далі.

In [13]:
def порахувати(текст, слово):
    """Дістає число, що стоїть перед словом passed або failed у звіті pytest."""
    слова = текст.replace(",", " ").split()
    for індекс, поточне in enumerate(слова):
        if поточне == слово and індекс > 0 and слова[індекс - 1].isdigit():
            return int(слова[індекс - 1])
    return 0


def запустити_pytest(імʼя_файлу):
    """Запускає справжній pytest і повертає (зелених, червоних)."""
    # --color=no важливий: інакше у вивід потраплять керівні символи кольору
    команда = [sys.executable, "-m", "pytest", "-q", "--tb=line", "--color=no",
               "-p", "no:cacheprovider", імʼя_файлу]
    # cwd=тека — саме звідти pytest бачить і тести, і модуль кошик
    результат = subprocess.run(команда, cwd=тека, capture_output=True, text=True,
                               env=СЕРЕДОВИЩЕ)
    print(результат.stdout.strip())
    return порахувати(результат.stdout, "passed"), порахувати(результат.stdout, "failed")


if PYTEST_Є:
    вирок_pytest = запустити_pytest("test_кошик.py")
    print("\nвирок pytest:", вирок_pytest)
else:
    вирок_pytest = наш_вирок
    print("pytest недоступний — порівнювати будемо наш запускач сам із собою.")
    print("У терміналі з установленим pytest та сама команда виглядала б так:")
    print("    python3 -m pytest -q test_кошик.py")
......                                                                   [100%]
6 passed in 0.97s

вирок pytest: (6, 0)

Головна перевірка практики: наша реалізація = бібліотечна¶

Ось те, заради чого ми писали власний запускач. Якщо два незалежні способи запуску дають однакову кількість зелених і червоних — значить, усередині pytest справді немає нічого, крім акуратного виклику функцій і ловлі AssertionError.

In [14]:
assert наш_вирок == вирок_pytest, "вироки розійшлись — шукай різницю в збиранні тестів!"
print("наш запускач:", наш_вирок)
print("pytest:      ", вирок_pytest)
print("✅ збігається — 6 зелених, 0 червоних в обох випадках")
наш запускач: (6, 0)
pytest:       (6, 0)
✅ збігається — 6 зелених, 0 червоних в обох випадках

9 · Регресія у файлах¶

Повторимо поломку, але тепер по-справжньому: перезапишемо кошик.py зі ставкою 7% і запустимо обидва запускачі знову.

In [15]:
(тека / "кошик.py").write_text(КОД_МОДУЛЯ.replace("return 0.05", "return 0.07"),
                               encoding="utf-8")

наш_вирок_після = запустити_власним(тека / "test_кошик.py")
print("\nнаш вирок після правки:", наш_вирок_після)

if PYTEST_Є:
    вирок_pytest_після = запустити_pytest("test_кошик.py")
else:
    вирок_pytest_після = наш_вирок_після

assert наш_вирок_після == вирок_pytest_після, "обидва запускачі мають бачити те саме"
assert наш_вирок_після == (4, 2), "очікуємо 4 зелених і 2 червоних"
print("\n✅ обидва запускачі однаково знайшли рівно дві поломки з однієї правки")
collected 6 items

✓ test_знижки_немає_на_малій_сумі
✗ test_знижка_5_на_середній_сумі — умова виявилась хибною
✓ test_знижка_10_на_великій_сумі
✓ test_підсумок_округлює_до_копійок
✗ test_до_сплати_зі_знижкою — умова виявилась хибною
✓ test_до_сплати_без_знижки

4 passed, 2 failed

наш вирок після правки: (4, 2)
.F..F.                                                                   [100%]
=================================== FAILURES ===================================
/tmp/кошик_c9cyqil_/test_кошик.py:9: assert 0.07 == 0.05
/tmp/кошик_c9cyqil_/test_кошик.py:21: assert 279.0 == 285.0
=========================== short test summary info ============================
FAILED test_кошик.py::test_знижка_5_на_середній_сумі - assert 0.07 == 0.05
FAILED test_кошик.py::test_до_сплати_зі_знижкою - assert 279.0 == 285.0
2 failed, 4 passed in 0.13s

✅ обидва запускачі однаково знайшли рівно дві поломки з однієї правки
In [16]:
# повертаємо правильний модуль на місце
(тека / "кошик.py").write_text(КОД_МОДУЛЯ, encoding="utf-8")

наш_вирок = запустити_власним(тека / "test_кошик.py")
assert наш_вирок == (6, 0), "після відкату файлів усе має бути зеленим"
print("\n✅ файли повернуто у робочий стан")
collected 6 items

✓ test_знижки_немає_на_малій_сумі
✓ test_знижка_5_на_середній_сумі
✓ test_знижка_10_на_великій_сумі
✓ test_підсумок_округлює_до_копійок
✓ test_до_сплати_зі_знижкою
✓ test_до_сплати_без_знижки

6 passed, 0 failed

✅ файли повернуто у робочий стан

10 · Перевірка винятків¶

У модулі кошик.py функція підсумок кидає ValueError на відʼємних аргументах — це обіцянка, і її теж треба зафіксувати тестом.

Спершу зробимо це вручну, без pytest: напишемо маленьку функцію, яка відповідає на питання «а чи справді кидає?».

In [17]:
from кошик import підсумок as підсумок_з_файлу


def кидає_ValueError(ціна, кількість):
    """Чи кидає підсумок() саме ValueError на цих аргументах."""
    try:
        підсумок_з_файлу(ціна, кількість)
    except ValueError:
        return True          # прилетів очікуваний виняток — це успіх
    except Exception:
        return False         # прилетів якийсь інший — не те, чого чекали
    return False             # функція відпрацювала мовчки — теж провал


assert кидає_ValueError(28.5, -1) is True, "на відʼємній кількості має бути ValueError"
assert кидає_ValueError(28.5, 2) is False, "на нормальних даних винятку бути не повинно"

print("підсумок(28.5, -1) кидає ValueError:", кидає_ValueError(28.5, -1))
print("підсумок(28.5,  2) кидає ValueError:", кидає_ValueError(28.5, 2))
print("✅ обидва випадки поводяться як обіцяно")
підсумок(28.5, -1) кидає ValueError: True
підсумок(28.5,  2) кидає ValueError: False
✅ обидва випадки поводяться як обіцяно

Те саме мовою pytest¶

У pytest для цього є менеджер контексту pytest.raises. Ось як виглядає той самий тест — і ось чому не можна писати його через голий try/except без else: такий тест зелений завжди, навіть коли функція нічого не кинула.

In [18]:
КОД_ВИНЯТКІВ = textwrap.dedent("""
    import pytest

    from кошик import підсумок


    def test_відʼємна_кількість_кидає_помилку():
        with pytest.raises(ValueError):
            підсумок(28.5, -1)


    def test_повідомлення_згадує_причину():
        with pytest.raises(ValueError, match="відʼємн"):
            підсумок(-1, 3)


    def test_нормальні_дані_винятку_не_кидають():
        assert підсумок(28.5, 2) == 57.0
""").strip() + "\n"

print(КОД_ВИНЯТКІВ)

if PYTEST_Є:
    (тека / "test_винятки.py").write_text(КОД_ВИНЯТКІВ, encoding="utf-8")
    вирок = запустити_pytest("test_винятки.py")
    assert вирок == (3, 0), "усі три тести на винятки мають бути зеленими"
    print("\n✅ pytest.raises: 3 зелених")
else:
    print("↑ саме так це пишеться в pytest.")
    print("Читається дослівно: «усередині цього блоку має статися ValueError».")
    print("Якщо винятку не буде, тест впаде з повідомленням DID NOT RAISE.")
    print("Нашу ручну версію ми вже перевірили в попередній клітинці.")
import pytest

from кошик import підсумок


def test_відʼємна_кількість_кидає_помилку():
    with pytest.raises(ValueError):
        підсумок(28.5, -1)


def test_повідомлення_згадує_причину():
    with pytest.raises(ValueError, match="відʼємн"):
        підсумок(-1, 3)


def test_нормальні_дані_винятку_не_кидають():
    assert підсумок(28.5, 2) == 57.0

...                                                                      [100%]
3 passed in 0.06s

✅ pytest.raises: 3 зелених

А що, якщо виняток прибрати?¶

Змоделюємо «спрощення»: приберемо з підсумок перевірку аргументів. Тест на виняток має негайно почервоніти — і саме це відрізняє його від зеленого-завжди try/except.

In [19]:
МОДУЛЬ_БЕЗ_ПЕРЕВІРКИ = КОД_МОДУЛЯ.replace(
    '    if ціна < 0 or кількість < 0:\n'
    '        raise ValueError("ціна й кількість не можуть бути відʼємними")\n',
    "")

assert "raise ValueError" not in МОДУЛЬ_БЕЗ_ПЕРЕВІРКИ, "перевірку справді прибрано"
(тека / "кошик.py").write_text(МОДУЛЬ_БЕЗ_ПЕРЕВІРКИ, encoding="utf-8")

if PYTEST_Є:
    вирок_без = запустити_pytest("test_винятки.py")
    print("\nвирок:", вирок_без)
    assert вирок_без == (1, 2), "два тести на виняток мають упасти, третій — вижити"
    print("✅ два червоних із поясненням DID NOT RAISE — рівно те, що треба")
else:
    sys.modules.pop("кошик", None)
    sys.path.insert(0, str(тека))
    import кошик as кошик_без_перевірки
    importlib.reload(кошик_без_перевірки)
    print("підсумок(28.5, -1) тепер спокійно повертає:",
          кошик_без_перевірки.підсумок(28.5, -1))
    print("Виняток зник — і тест на нього почервонів би. Ручний try/except без")
    print("перевірки «а чи стався виняток» цієї поломки б НЕ помітив.")

# повертаємо правильний модуль
(тека / "кошик.py").write_text(КОД_МОДУЛЯ, encoding="utf-8")
print("\nмодуль відновлено")
FF.                                                                      [100%]
=================================== FAILURES ===================================
/tmp/кошик_c9cyqil_/test_винятки.py:7: Failed: DID NOT RAISE <class 'ValueError'>
/tmp/кошик_c9cyqil_/test_винятки.py:12: Failed: DID NOT RAISE <class 'ValueError'>
=========================== short test summary info ============================
FAILED test_винятки.py::test_відʼємна_кількість_кидає_помилку - Failed: DID N...
FAILED test_винятки.py::test_повідомлення_згадує_причину - Failed: DID NOT RA...
2 failed, 1 passed in 0.06s

вирок: (1, 2)
✅ два червоних із поясненням DID NOT RAISE — рівно те, що треба

модуль відновлено

11 · Межі, а не середина¶

Тепер найважливіша частина практики. Візьмемо зламану версію знижка — з <= замість < — і перевіримо її двома наборами тестів: спершу типовими сумами, потім із межами.

In [20]:
def знижка_зламана(сума):
    """Та сама знижка, але межі зсунуті на одиницю: <= замість <."""
    if сума <= 200:
        return 0.0
    if сума <= 500:
        return 0.05
    return 0.10


def очікувана_ставка(сума):
    """Правильна відповідь за специфікацією: 5% від 200, 10% від 500."""
    if сума < 200:
        return 0.0
    if сума < 500:
        return 0.05
    return 0.10


def порахувати_червоні(суми):
    """Скільки з переданих сум зламана функція обробляє неправильно."""
    червоних = 0
    for сума in суми:
        якщо_правильно = очікувана_ставка(сума)
        насправді = знижка_зламана(сума)
        if якщо_правильно != насправді:
            червоних += 1
            print(f"✗ знижка({сума}): чекали {якщо_правильно}, отримали {насправді}")
        else:
            print(f"✓ знижка({сума}) == {насправді}")
    return червоних


ТИПОВІ = [50, 150, 300, 450, 700, 850]

print("НАБІР 1 — лише типові суми:")
червоних_типові = порахувати_червоні(ТИПОВІ)
print(f"\n{len(ТИПОВІ) - червоних_типові} passed, {червоних_типові} failed")

assert червоних_типові == 0, "типові дані не бачать помилки на межі — у цьому й суть"
print("\n⚠️ Шість зелених тестів — і помилка жива. Такий набір дає впевненість без гарантій.")
НАБІР 1 — лише типові суми:
✓ знижка(50) == 0.0
✓ знижка(150) == 0.0
✓ знижка(300) == 0.05
✓ знижка(450) == 0.05
✓ знижка(700) == 0.1
✓ знижка(850) == 0.1

6 passed, 0 failed

⚠️ Шість зелених тестів — і помилка жива. Такий набір дає впевненість без гарантій.
In [21]:
МЕЖОВІ = [199, 200, 499, 500]
УСІ = ТИПОВІ + МЕЖОВІ

print("НАБІР 2 — типові плюс межі:")
червоних_усі = порахувати_червоні(УСІ)
print(f"\n{len(УСІ) - червоних_усі} passed, {червоних_усі} failed")

assert червоних_усі == 2, "межові тести мають знайти рівно дві помилки: на 200 і на 500"
print("\n✅ Ті самі чотири рядки коду — і помилку знайдено з першого запуску.")
print("   Червоні: 200 і 500. Сусідні 199 і 499 зелені — межа зсунута рівно на одиницю.")
НАБІР 2 — типові плюс межі:
✓ знижка(50) == 0.0
✓ знижка(150) == 0.0
✓ знижка(300) == 0.05
✓ знижка(450) == 0.05
✓ знижка(700) == 0.1
✓ знижка(850) == 0.1
✓ знижка(199) == 0.0
✗ знижка(200): чекали 0.05, отримали 0.0
✓ знижка(499) == 0.05
✗ знижка(500): чекали 0.1, отримали 0.05

8 passed, 2 failed

✅ Ті самі чотири рядки коду — і помилку знайдено з першого запуску.
   Червоні: 200 і 500. Сусідні 199 і 499 зелені — межа зсунута рівно на одиницю.

12 · parametrize: один тест, багато випадків¶

Чотири межові тести відрізняються лише числами. Замість копіпасту pytest пропонує декоратор @pytest.mark.parametrize: він розгортає одну функцію в стільки окремих тестів, скільки рядків у таблиці даних.

In [22]:
КОД_МЕЖ = textwrap.dedent("""
    import pytest

    from кошик import знижка


    @pytest.mark.parametrize("сума, очікувана", [
        (199, 0.0),
        (200, 0.05),
        (499, 0.05),
        (500, 0.10),
    ])
    def test_знижка_на_межах(сума, очікувана):
        assert знижка(сума) == очікувана
""").strip() + "\n"

print(КОД_МЕЖ)

if PYTEST_Є:
    (тека / "test_межі.py").write_text(КОД_МЕЖ, encoding="utf-8")
    вирок_меж = запустити_pytest("test_межі.py")
    print("\nвирок:", вирок_меж)
    assert вирок_меж == (4, 0), "правильний модуль має пройти всі чотири набори"
    print("✅ один запис у файлі — чотири окремі тести у звіті")
else:
    print("↑ pytest розгорнув би це у 4 окремі тести з іменами на кшталт")
    print("   test_знижка_на_межах[200-0.05] — кожен зі своїм результатом.")
    print("Без pytest ту саму таблицю проганяють циклом, як у клітинці вище;")
    print("різниця в тому, що цикл зупиняється на першому ж розходженні.")
import pytest

from кошик import знижка


@pytest.mark.parametrize("сума, очікувана", [
    (199, 0.0),
    (200, 0.05),
    (499, 0.05),
    (500, 0.10),
])
def test_знижка_на_межах(сума, очікувана):
    assert знижка(сума) == очікувана

....                                                                     [100%]
4 passed in 0.08s

вирок: (4, 0)
✅ один запис у файлі — чотири окремі тести у звіті

Чому саме parametrize, а не цикл¶

Порівняємо на зламаному коді. Цикл усередині одного тесту падає на першому розходженні — і решта наборів не перевіряється. parametrize проганяє всі, бо кожен набір для нього — окремий тест.

In [23]:
ВИПАДКИ = [(199, 0.0), (200, 0.05), (499, 0.05), (500, 0.10)]


def скільки_перевірив_цикл(функція, випадки):
    """Скільки наборів встиг перевірити тест із циклом, поки не впав."""
    перевірено = 0
    for сума, очікувана in випадки:
        перевірено += 1
        if функція(сума) != очікувана:
            return перевірено, "впав"
    return перевірено, "усе зелено"


перевірено, стан = скільки_перевірив_цикл(знижка_зламана, ВИПАДКИ)
print("цикл усередині одного тесту:")
print(f"  перевірено наборів: {перевірено} з {len(ВИПАДКИ)} — {стан}")
print("  у звіті pytest: 1 failed (який саме набір — не видно)")

assert перевірено == 2, "цикл зупиняється на другому наборі й далі не йде"

if PYTEST_Є:
    (тека / "кошик.py").write_text(КОД_МОДУЛЯ.replace("if сума < 200", "if сума <= 200")
                                              .replace("if сума < 500", "if сума <= 500"),
                                   encoding="utf-8")
    print("\nparametrize на тому самому зламаному коді:")
    вирок_зламаних_меж = запустити_pytest("test_межі.py")
    assert вирок_зламаних_меж == (2, 2), "чекаємо 2 зелених і 2 червоних"
    print("\n✅ перевірено всі 4 набори: 2 зелених, 2 червоних — і видно, які саме")
    (тека / "кошик.py").write_text(КОД_МОДУЛЯ, encoding="utf-8")
else:
    print("\nparametrize перевірив би всі 4 набори й показав 2 passed, 2 failed —")
    print("з іменами test_знижка_на_межах[200-0.05] і [500-0.1] у звіті.")
цикл усередині одного тесту:
  перевірено наборів: 2 з 4 — впав
  у звіті pytest: 1 failed (який саме набір — не видно)

parametrize на тому самому зламаному коді:
.F.F                                                                     [100%]
=================================== FAILURES ===================================
/tmp/кошик_c9cyqil_/test_межі.py:13: assert 0.0 == 0.05
/tmp/кошик_c9cyqil_/test_межі.py:13: assert 0.05 == 0.1
=========================== short test summary info ============================
FAILED test_межі.py::test_знижка_на_межах[200-0.05] - assert 0.0 == 0.05
FAILED test_межі.py::test_знижка_на_межах[500-0.1] - assert 0.05 == 0.1
2 failed, 2 passed in 0.06s

✅ перевірено всі 4 набори: 2 зелених, 2 червоних — і видно, які саме

13 · Прибираємо за собою¶

Тимчасова тека своє відпрацювала. Видаляємо її разом із файлами й прибираємо доданий шлях із sys.path — щоб зошит не лишив по собі слідів.

In [24]:
файли_перед_видаленням = sorted(файл.name for файл in тека.iterdir())
print("видаляємо:", файли_перед_видаленням)

shutil.rmtree(тека)
if str(тека) in sys.path:
    sys.path.remove(str(тека))
sys.modules.pop("кошик", None)

assert not тека.exists(), "тимчасова тека має зникнути"
print("\n✅ тека", тека, "видалена, sys.path чистий")
видаляємо: ['test_винятки.py', 'test_кошик.py', 'test_межі.py', 'кошик.py']

✅ тека /tmp/кошик_c9cyqil_ видалена, sys.path чистий

14 · Підсумок практики¶

Що ми зробили й що з цього забрати:

  • print перевіряє людина, assert — комп'ютер. Друге масштабується, перше ні.
  • Голий AssertionError не каже, що саме розійшлось; pytest дістає фактичні значення завдяки переписуванню байткоду.
  • Усередині запускача тестів немає магії: знайти функції з іменем test_*, викликати кожну, спіймати AssertionError. Наш саморобний запускач дав той самий вирок, що й pytest, — і на робочому коді, і на зламаному.
  • Регресія реальна: одна правка в знижка завалила два тести, другий із них — про функцію, якої ми не торкались.
  • Набір тестів лише на типових даних буває повністю зеленим при живій помилці. Межі знайшли її з першого запуску.
  • pytest.raises перевіряє обіцянку кинути виняток; ручний try/except без перевірки «а чи справді стався» дає тест, зелений завжди.

Завдання¶

🟢 Рівень 1 — База¶

Додай до нашого кошика функцію разом_за_чек(позиції), яка приймає список пар (ціна, кількість) і повертає загальну суму до сплати. Напиши до неї щонайменше чотири тести: порожній чек, один товар, кілька товарів, чек із знижкою.

Зроблено, якщо: усі тести проходять через перевірити() і дають 4 passed, 0 failed.

🟡 Рівень 2 — Плюс¶

Візьми знижка_зламана з розділу 11 і напиши набір межових тестів так, щоб він ловив помилку на обох порогах, але містив не більше шести перевірок. Потім полагодь функцію й переконайся, що набір позеленів.

Зроблено, якщо: до полагодження рівно 2 червоних, після — 0, а сам набір не перевищує шість тестів.

🔴 Рівень 3 — Виклик¶

Доведи, що покриття не гарантує якості. Напиши тест, який викликає всі три функції кошика, не має жодного assert — і покажи, що він зелений навіть на явно зламаному модулі. Потім поясни у два речення, чому так виходить.

Зроблено, якщо: той самий тест зелений і на правильному, і на зламаному кошик.py, а пояснення називає різницю між «рядок виконано» і «результат перевірено».