Практика · Тестування з pytest¶
Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md
Тема 19 блоку «Організація коду». Тут ми не будемо читати про тести — ми їх напишемо й запустимо, причому двома способами.
Що зробимо по кроках:
- Перевіримо, чи є в цьому середовищі
pytest— і домовимось, що робити, якщо його немає. - Візьмемо три функції кошика з лекції й перевіримо їх спершу очима (
print), потімassert. - Напишемо власний міні-запускач тестів на десять рядків — щоб побачити, що всередині pytest немає магії.
- Відтворимо регресію з інтерактиву 1: підіймемо знижку з 5% до 7% і подивимось, які тести почервоніють.
- Створимо справжні файли
кошик.pyіtest_кошик.pyу тимчасовій теці, запустимо справжнійpytestі переконаємось, що наш саморобний запускач і pytest дають однаковий вирок. - Розберемо
pytest.raises,parametrizeі межові випадки. - Приберемо за собою тимчасову теку.
Усе, що нижче, виконується згори вниз без жодних правок.
1 · Що є в цьому середовищі¶
Перше правило роботи з чужим інструментом — спершу перевір, чи він узагалі є.
importlib.util.find_spec шукає модуль, не імпортуючи його: якщо модуля немає,
повертається None, а не летить ImportError.
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 · Наскрізний приклад: кошик покупок¶
Ті самі три функції, що в лекції. до_сплати викликає обидві інші — саме через цей
звʼязок далі й станеться регресія.
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 не масштабується¶
Ось як перевіряють функцію в перший день. Числа праворуч у коментарях — те, що ми очікуємо; порівнювати доводиться самому.
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 вираз не друкує нічого, поки все гаразд, і зупиняє виконання, щойно вираз
виявився хибним. Очікувана відповідь тепер записана в коді, а не в коментарі.
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
своєю інтроспекцією.
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. Напишемо своє — на пʼятнадцять рядків.
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.
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%. Правка на один символ — і ми перевизначаємо знижка.
Зверни увагу, чому це працює: до_сплати шукає імʼя знижка у момент виклику,
а не в момент свого визначення. Тому нова версія автоматично підставиться і туди —
саме так і виникають регресії в реальному коді.
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), тому повернути стару версію можна простим присвоєнням: ми ж завбачливо зберегли на неї друге посилання.
знижка = знижка_вихідна # чіпляємо старий обʼєкт назад на імʼя «знижка»
зелених, червоних = перевірити(НАБІР)
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(). Наприкінці зошита ми її приберемо.
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 їх не збере.
КОД_ТЕСТІВ = 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, тільки без звітів і без інтроспекції.
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 не встановлений, ця клітинка це чесно скаже й піде далі.
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.
assert наш_вирок == вирок_pytest, "вироки розійшлись — шукай різницю в збиранні тестів!"
print("наш запускач:", наш_вирок)
print("pytest: ", вирок_pytest)
print("✅ збігається — 6 зелених, 0 червоних в обох випадках")
наш запускач: (6, 0) pytest: (6, 0) ✅ збігається — 6 зелених, 0 червоних в обох випадках
9 · Регресія у файлах¶
Повторимо поломку, але тепер по-справжньому: перезапишемо кошик.py зі ставкою 7%
і запустимо обидва запускачі знову.
(тека / "кошик.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 ✅ обидва запускачі однаково знайшли рівно дві поломки з однієї правки
# повертаємо правильний модуль на місце
(тека / "кошик.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: напишемо маленьку функцію, яка відповідає на питання «а чи справді кидає?».
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:
такий тест зелений завжди, навіть коли функція нічого не кинула.
КОД_ВИНЯТКІВ = 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.
МОДУЛЬ_БЕЗ_ПЕРЕВІРКИ = КОД_МОДУЛЯ.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 · Межі, а не середина¶
Тепер найважливіша частина практики. Візьмемо зламану версію знижка — з <= замість
< — і перевіримо її двома наборами тестів: спершу типовими сумами, потім із межами.
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 ⚠️ Шість зелених тестів — і помилка жива. Такий набір дає впевненість без гарантій.
МЕЖОВІ = [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: він розгортає одну функцію в стільки окремих
тестів, скільки рядків у таблиці даних.
КОД_МЕЖ = 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 проганяє всі,
бо кожен набір для нього — окремий тест.
ВИПАДКИ = [(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 — щоб зошит не лишив по собі слідів.
файли_перед_видаленням = 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, а пояснення називає різницю між «рядок виконано» і «результат перевірено».