Пʼять тем тому ми навчились різати програму на функції. Потім розібрались з аргументами, розклали функції по модулях і пакетах, поставили залежності в окреме віртуальне середовище й навчились ловити винятки. Кожен із цих кроків робив програму більшою. А чим більша програма, тим менше ти певен, що вона й досі працює вся — а не тільки той шматок, який ти щойно дивився очима.
Ця тема — про єдиний спосіб бути певним, який не залежить від твоєї памʼяті, уважності й кількості кави. Ти напишеш другу програму, яка перевіряє першу, і запускатимеш її щоразу за пів секунди. Називається це автоматичні тести, а інструмент, яким їх запускають у Python, — pytest.
Наскрізний приклад лишається тим самим кошиком покупок, з яким ми працюємо з
теми 06. У модулі кошик.py живуть
три функції — знайомі за темою 14, тільки тепер вони працюють разом:
01 / БільНавіщо взагалі тести
Уяви звичайний вівторок. Приходить завдання: підняти середню знижку з 5% до 7%.
Робота на десять секунд — знайти в знижка рядок return 0.05
й написати 0.07. Ти правиш, запускаєш програму, дивишся на екран: знижка
справді стала 7%. Готово.
У пʼятницю приходить лист від бухгалтерії: у чеках неправильні суми. Не всі — тільки
ті, де є знижка. Ти сідаєш розбиратись і за годину знаходиш, що зламалась
не та функція, яку ти правив. Зламалась до_сплати, яка
викликає знижка всередині себе. Ти її навіть не відкривав.
Це називається регресія (regression) — поломка того, що вчора працювало, як побічний ефект сьогоднішньої зміни. Її неможливо уникнути силою волі: у програмі на тисячу рядків просто немає способу тримати в голові, хто кого викликає. Ручна перевірка тут не рятає з простої причини: ти перевіряєш те, що правив. А ламається те, що ти не чіпав.
Тест — це запис перевірки в коді. Один раз описав, що функція має повертати, і далі перевіряєш усе разом однією командою. Обери в інтерактиві правку, яку ти нібито щойно зробив, і подивись, що покажуть шість тестів нашого кошика.
Інтерактив 1 · Тест ловить регресію
Шість тестів на три функції. Обери зміну в коді — і побачиш, що почервоніє.
до_сплати, яка нічого
про твою правку не знає. Саме цю пару тобі й довелось би шукати руками —
і саме її тест знаходить за 0.03 секунди.Крім ловлі регресій, тести дають ще три речі, і кожна цінна сама по собі:
- Швидкий зворотний звʼязок. Не «запусти програму, введи дані, поклацай, подивись» — а одна команда й відповідь через частку секунди.
- Сміливість міняти код. Найдорожчий код — той, який страшно чіпати. Зелений набір тестів перетворює «краще не займатиму» на «спробую й перевірю». Останній варіант в інтерактиві саме про це: перейменування параметра нічого не зламало, і тести це підтвердили за секунду.
- Опис поведінки. Тест — це приклад використання функції з відомою відповіддю. Читаючи тести, розумієш, що функція обіцяє, — часто швидше, ніж читаючи саму функцію.
02 / Основаprint проти assert
Як новачок перевіряє функцію? Дописує print і дивиться на екран:
Це нормальний перший крок, і він працює — рівно один раз. Проблема починається, коли перевірок стає багато:
- Дивитись мусиш ти. Комп'ютер надрукував три числа й на цьому його робота закінчилась. Правильні вони чи ні — вирішує людина, порівнюючи з тим, що памʼятає.
- Не масштабується. Три рядки прочитати легко. Сорок рядків чисел — це стіна, у якій одна неправильна цифра в дванадцятому рядку не впадає в око. А сорок перевірок для програми середнього розміру — це мало.
- Живе не в коді, а в голові. Очікувана відповідь записана в коментарі — тобто ніде. Завтра її ніхто не перевірить.
- Треба прибирати. Ці
printне можна лишити в програмі, і після кожної перевірки їх видаляють. Наступного разу пишуть знову.
Заміна знайома тобі ще з теми 14 —
інструкція assert:
assert вираз означає буквально «стверджую, що це істина». Якщо вираз
істинний, не відбувається нічого — жодного виводу, жодної затримки.
Якщо хибний, Python кидає AssertionError і програма зупиняється. Тобто
мовчання тут і є повідомленням «усе гаразд», а дивитись треба тільки тоді, коли
щось закричало.
| питання | assert | |
|---|---|---|
| хто порівнює | людина, очима | Python, сам |
| 200 перевірок | стіна тексту | тиша або одна помилка |
| очікувана відповідь | у коментарі або в голові | у коді, поруч |
| чи лишається в проєкті | ні, видаляють | так, накопичується |
| можна запускати автоматом | ні | так |
assert — не заміна
перевіркам вхідних даних. Запуск python3 -O програма.py (від
optimize) викидає всі assert з байткоду, і рядок просто зникає.
Тому для поганих даних від користувача пиши raise ValueError(...), як у
темі 18, а assert лишай для
тестів і внутрішніх припущень, порушення яких означає помилку програміста, а не
користувача.03 / ІнтроспекціяЩо показує pytest, коли падає
У голого assert є одна незручність, і вона суттєва. Коли він падає,
повідомлення виглядає так:
Ти бачиш який рядок не спрацював — і все. Скільки функція повернула
насправді, не написано ніде. Щоб дізнатись, доводиться йти в код, дописувати
print(підсумок(19.99, 7)) і запускати ще раз.
Ось тут і зʼявляється pytest. Головна його ідея — не вигадувати власних перевірок,
а зробити розумнішим той самий assert, який ти вже знаєш. Перед запуском
pytest переписує байткод твоїх тестових файлів (це називається
assertion rewriting) так, щоб у момент падіння він міг дістати й
показати обидві сторони порівняння. Такий погляд програми всередину себе називають
інтроспекцією (introspection).
Інтерактив 2 · Голий assert проти pytest
Один і той самий рядок коду, два способи запустити. Перемикай тип даних.
AssertionError не залежить від того, що саме розійшлось, — воно
ніколи нічого не каже. Права колонка щоразу різна, бо pytest дістає фактичні
значення й для списків навіть показує, на якому індексі почалась розбіжність.Практичний наслідок: тобі не треба вчити нову мову перевірок.
У бібліотеці unittest зі стандартної поставки для кожного виду
порівняння є свій метод — assertEqual, assertTrue,
assertIn, assertAlmostEqual і ще десятки. У pytest усе це
робить один assert зі звичайними операторами ==,
in, <. Це і є головна причина, чому pytest витіснив решту.
04 / ДомовленістьЯк pytest знаходить тести
Ти ніде не перелічуєш свої тести — pytest сам обходить теки й збирає їх за іменами. Правил три, і вони прості:
- файли, що звуться
test_*.pyабо*_test.py; - усередині них — функції, чиє імʼя починається з
test_; - обхід рекурсивний: усі підтеки від тієї, де ти запустив команду.
Сам запуск — pytest у терміналі або, надійніше,
python3 -m pytest (тоді точно спрацює той pytest, що стоїть у
активному віртуальному середовищі).
Домовленість замість списку — рішення навмисне: додав файл із правильною назвою, і він уже в наборі, нічого не треба реєструвати. Зворотний бік теж є: помилився на одну літеру в імені — і файл просто мовчки не потрапить у збірку. Тест не впаде, він не існуватиме. Пограйся з іменами й подивись, скільки тестів збереться.
Інтерактив 3 · Що саме потрапить у збірку
Перемикай імена файлів і функцій. Праворуч від кожного рядка — причина.
test_межі(). Функція допоміжна() не збирається ніколи,
і це нормально: у тестовому файлі можна тримати службові функції, pytest їх
не чіпає.from кошик import знижка, підсумок, до_сплати.
Працює це за правилами теми 16: pytest
додає теку тесту до шляху пошуку модулів, тому запускати команду треба з кореня
проєкту, а не зсередини теки tests/. Якщо бачиш
ModuleNotFoundError: No module named 'кошик' — майже завжди справа
саме в тому, звідки ти запустив.05 / ФормаСтруктура одного тесту
Тест — це звичайна функція без параметрів і без return. Усередині
неї три частини, і корисно розділяти їх порожнім рядком:
Англійською цю схему звуть arrange – act – assert, і сенс поділу в тому, що кожна частина відповідає на своє питання: що в нас є, що ми з цим робимо, чого чекаємо. Коли тест падає, одразу видно, у якій частині дивитись.
Три поради про форму, кожна з причиною:
- Імʼя тесту — це речення про поведінку. Не
test_1, аtest_знижка_5_відсотків_на_середній_сумі. Довге імʼя тут не вада, а перевага: саме воно зʼявиться у звіті, коли тест почервоніє, і має бути зрозумілим без відкривання файлу. - Один тест — одна поведінка. Той самий принцип, що й «одна
функція — одна річ» з теми 14. Тест
із десятьма
assertна різні теми впаде на першому й нічого не розкаже про решту дев'ять. - Тест нічого не друкує. Його результат — це зелено або
червоно, а не текст на екрані.
printусередині тесту — ознака, що ти досі перевіряєш очима.
06 / РитмЧервоно-зелений цикл
Тепер про порядок дій, який з першого разу здається дивним: спершу пишемо тест, який падає, і лише потім код, який його зеленить. Пройди сім кроків — і подивись, навіщо це.
Інтерактив 4 · Червоний → зелений → рефакторинг
Вимога: на сумі рівно 200 знижка вже 5%, на 500 — уже 10%.
if — таблиця порогів. Тести зелені, отже
поведінка не змінилась і рефакторинг безпечний. На кроці 6 у тій самій таблиці
переплутано порядок рядків — і сітка ловить це негайно, не чекаючи бухгалтерії.Чому важливо побачити тест червоним хоча б один раз? Бо тест — це теж код, і в ньому теж бувають помилки. Тест, який жодного разу не падав, може не перевіряти нічого. Класика жанру:
Обидва «тести» зелені завжди — і при правильному коді, і при будь-якому зламаному. Побачити такий обман можна одним способом: спершу зробити так, щоб тест мусив упасти. Якщо він при цьому не впав — перевіряє він не те, що ти думаєш.
Цикл «червоний → зелений → рефакторинг» називається розробкою через тести (test-driven development, TDD). Як релігію її приймати не обовʼязково — багато хорошого коду написано в іншому порядку. Але одну її частину варто взяти беззастережно: коли знайшов баг, спершу відтвори його тестом, і тільки потім лагодь. Тоді ти точно знаєш, що виправив саме те, і що цей самий баг більше не повернеться непоміченим.
07 / ВибірЩо саме перевіряти
Найпоширеніша помилка початківця — набір тестів лише на «щасливий шлях» (happy path): типові дані, типовий результат, усе зелено. Такий набір заспокоює, але майже нічого не гарантує, бо помилки живуть не посередині діапазону.
Перевіряти треба три різні речі:
- Типові випадки. Найзвичайніші дані. Один-два тести, щоб зафіксувати основну поведінку.
- Межі. Значення на самому краю кожної умови й по обидва боки
від нього. Якщо в коді написано
if сума < 200, то межа — це 200, і перевіряти треба 199 і 200. - Помилкові дані. Порожній список, нуль, відʼємне число, не той тип. Тут функція має або повернути щось розумне, або чесно кинути виняток — і те, і те є поведінкою, яку варто зафіксувати.
Чому саме межі. Найчастіша помилка в умовах — переплутати <
з <= (англійською в неї навіть є назва: off-by-one, помилка
на одиницю). Вона зачіпає рівно одне значення з усього діапазону — і саме тому
випадкові типові дані її не бачать. Перемкни набір тестів і подивись.
Інтерактив 5 · Межі, а не середина
У коді <= замість <. Знайдуть це тести чи ні — залежить від набору.
знижка дві умови,
отже мінімум чотири межові тести: 199, 200, 499, 500. Це звучить як багато роботи,
але саме ці тести й ловлять справжні помилки — а писати їх десятками ми навчимось
у розділі про parametrize.08 / ВиняткиВиняток — теж поведінка
У темі 18 ми домовились: на погані
дані функція має чесно кидати виняток, а не повертати щось дивне. Додамо таку
перевірку в підсумок:
Тепер це — обіцянка, і її теж треба зафіксувати тестом. Але як? Звичайний
assert не годиться: виклик просто впаде, і тест почервоніє замість
того, щоб позеленіти. Для цього в pytest є менеджер контексту
pytest.raises:
Читається дослівно: «усередині цього блоку має статися ValueError».
Якщо він стався — тест зелений. Якщо не стався або стався інший клас — червоний.
Перемикай варіанти й дивись на звіт pytest.
Інтерактив 6 · pytest.raises у чотирьох станах
Той самий тест, різна поведінка функції. Останній варіант — без raises взагалі.
ValueError, як і задумано. Але без
pytest.raises той самий виняток означає провал тесту. Обгортка не
ловить помилку — вона перевертає її значення: тепер відсутність винятку є
провалом.Уточнити перевірку можна параметром match — це шматок тексту
повідомлення (насправді регулярний вираз, але для початку достатньо звичайних слів):
try/except. Спокуслива
самодіяльність виглядає так: try: підсумок(28.5, -1) /
except ValueError: pass. Такий тест зелений завжди —
і коли виняток стався, і коли функція спокійно повернула −28.5, бо в другому випадку
теж не відбувається нічого поганого. Це рівно та пастка, про яку йшлось у розділі
про червону фазу: тест, який не може впасти.09 / ДаніОдин тест, багато випадків
Межових тестів у нас уже чотири, і всі вони — копії одне одного з різними числами:
Копіпаст у чотирьох рядках терпимий, у двадцяти — ні. Напрошується цикл усередині
одного тесту, і саме тут ховається пастка: assert зупиняє функцію на
першому ж розходженні, тому решта наборів просто не перевіриться. Правильна
відповідь — декоратор @pytest.mark.parametrize, який каже pytest
згенерувати окремий тест на кожен набір даних:
Перший аргумент — імена параметрів одним рядком через кому; другий — список кортежів зі значеннями. Функція запускається чотири рази, кожен — як самостійний тест зі своїм результатом і своїм іменем у звіті. Порівняй два підходи на одному й тому самому зламаному коді.
Інтерактив 7 · Цикл проти parametrize
Обери, який набір даних ламається, і подивись, що покаже звіт.
parametrize перевірив усі чотири, показав три зелених і один
червоний — та ще й назвав його в звіті: test_знижка[200-0.05].Коли parametrize доречний: одна поведінка, багато вхідних даних.
Коли ні: різні поведінки, які просто хочеться скоротити. Якщо в списку кортежів
доводиться тримати ще й прапорець «а на цьому наборі чекаємо виняток» — це вже
дві різні перевірки, і краще написати дві функції.
10 / ПідготовкаФікстури коротко
Часто кільком тестам потрібна та сама заготовка — приклад кошика, підключення,
тимчасова тека. Копіювати підготовку в кожен тест не хочеться, а класти в глобальну
змінну не можна: тести псуватимуть її один одному. Розвʼязок pytest —
фікстура (fixture): функція з декоратором
@pytest.fixture, результат якої тест просить за іменем параметра.
Магія тут одна: pytest бачить, що параметр тесту зветься так само, як фікстура, викликає фікстуру й підставляє її результат. Ключова властивість — фікстура виконується заново для кожного тесту. Тому перший тест не може зіпсувати словник другому, навіть якщо щось у нього додасть.
Якщо після тесту треба прибрати за собою, фікстура пише yield
замість return: усе до нього — підготовка, усе після — прибирання.
А для найчастішого випадку — тимчасової теки під файли — фікстуру писати взагалі не
треба, у pytest є вбудована tmp_path. Вона знадобиться нам уже
в темі 20, коли програма почне читати з диска.
11 / МіраМежі розумного
Тести коштують часу, і писати їх на все підряд — така сама помилка, як не писати взагалі. Ось чого тестувати не варто.
- Чужі бібліотеки. Перевіряти, що
roundокруглює, аpandasчитає CSV, — марна робота: у них є свої тести, написані їхніми авторами. Твої тести перевіряють твій код, зокрема те, як він користується чужим. - Тривіальні обгортки. Функція з одного рядка
return self.цінане має що ламати. Тест на неї — це рядок, який доведеться правити при кожному перейменуванні, і жодної знайденої помилки. - Саму мову.
assert 2 + 2 == 4смішно, але в реальних наборах трапляється версія тонша: тест, який перевіряє, щоlenсписку з трьох елементів дорівнює трьом. - Точний текст повідомлень. Прив'язка до формулювання ламає
тести при кожній правці мови інтерфейсу. Перевіряй факт винятку й ключове слово
в ньому (
match), а не речення цілком.
Тести не повинні залежати одне від одного
Найпідступніша хвороба великого набору — коли тест B працює лише тому, що перед ним відпрацював тест A і щось після себе лишив. Ознака: «у мене локально зелено, а на сервері червоно» або «падає, якщо запустити тільки цей тест». Правило звучить просто: будь-який тест має проходити, якщо запустити його окремо. Перевірити це можна прямо:
Джерело залежності майже завжди одне — спільний змінюваний стан: глобальний список, файл на диску, база. Ліки — фікстури, які створюють усе заново, і жодних глобальних змінних, які тести правлять.
Покриття: корисна цифра, шкідлива мета
Покриття коду (code coverage) — частка рядків програми, які
виконались хоча б раз під час прогону тестів. Міряють його інструментом
pytest-cov. Цифра корисна в один бік: якщо покриття 20%, чотири пʼятих
коду ніхто ніколи не запускав, і там може бути що завгодно.
А от 100% не гарантує нічого, і причина проста: покриття рахує
виконані рядки, а не перевірені твердження.
Ось тест, який дає повне покриття функції знижка й не перевіряє жодної
відповіді:
Тому 100% — не мета, а орієнтир навпаки: низьке покриття точно погано, високе покриття само по собі ще нічого не означає. Дивись не на відсоток, а на те, чи є серед тестів межі й помилкові дані.
Швидкість — це теж вимога
Набір тестів, який іде пʼять хвилин, перестають запускати — і він перетворюється на музейний експонат. Тому тести тримають швидкими: без мережі, без сну, без справжньої бази. Усе повільне замінюють на заготовку, а справжню взаємодію лишають для окремих, рідкісних тестів.
12 / ПідсумокЩо забрати з теми
Тест — це друга програма, яка перевіряє першу. Усе решта випливає звідси:
- Тести ловлять регресію. Ти правиш одну функцію — червоніє сусідня, яка її викликає. Знайти це руками неможливо, бо ти перевіряєш те, що правив, а ламається те, чого не чіпав.
assertзамістьprint. Очікувана відповідь живе в коді, а не в голові; порівнює комп'ютер, а не людина; мовчання означає «все гаразд».- pytest не вигадує своїх перевірок. Він переписує твої
assertтак, щоб при падінні показати фактичні значення обох сторін. Учити треба одну конструкцію, яку ти вже знаєш. - Тести знаходяться за іменами. Файли
test_*.py, функціїtest_*, обхід рекурсивний. Помилка в імені означає не червоний тест, а відсутній тест. - Три частини: підготувати → виконати → перевірити. Одна
поведінка на тест, довге зрозуміле імʼя, жодного
print. - Спершу червоний, потім зелений. Тест, який жодного разу не падав, може не перевіряти нічого. Знайшов баг — відтвори його тестом і тільки тоді лагодь.
- Межі важливіші за середину. На кожну умову — дві точки по боках межі. Набір лише на типових даних буває зелений при живій помилці.
pytest.raisesперевіряє винятки. Відсутність винятку всередині блоку — це провал тесту. Ручнийtry/exceptдає тест, який зелений завжди.parametrizeрозгортає один тест у багато. Кожен набір даних — окремий результат у звіті; цикл усередині тесту зупиняється на першому розходженні.- Покриття — не мета. Воно рахує виконані рядки, а не перевірені твердження.
На цьому блок 4 «Організація коду» закінчено. Подивись, що в ньому зібралось: модулі розклали код по файлах, віртуальні середовища відділили залежності одного проєкту від іншого, винятки навчили програму гідно поводитись, коли щось пішло не так, а тести зробили всі ці зміни оборотними. Чотири інструменти для коду, який має прожити довше одного вечора.
Далі — блок 5 «Робота з реальністю», і починається він з
теми 20: файли. Досі всі наші дані жили в коді — списки й словники,
записані руками. Реальні дані лежать на диску, і як тільки програма почне їх читати,
одразу виникне питання, яке ми щойно навчились ставити: а як це протестувати?
Функція, що читає файл, залежить від того, який файл лежить поруч, — тобто вже не
чиста. Відповідь ти бачив у розділі про фікстури: tmp_path, тимчасова
тека, яку pytest створює для кожного тесту й прибирає після нього. Так що
знайомство з файлами почнеться не з нуля.
practice.ipynb ти напишеш
власний міні-запускач тестів на десять рядків — той, що ловить
AssertionError і рахує зелені й червоні, — потім створиш справжній
модуль і файл test_кошик.py у тимчасовій теці, запустиш pytest,
відтвориш регресію з інтерактиву 1 і переконаєшся, що твій саморобний запускач
і pytest дають однаковий вирок. Плюс pytest.raises,
parametrize і межові випадки.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.