Python з нуля · Блок 4 · Тема 19

Тестування: сітка під канатом

Ти правиш одну функцію — і ламається сусідня, про яку ти навіть не згадував. Дізнаєшся про це через тиждень, від користувача. Тест ловить це за секунду, ще до того, як ти встиг закрити редактор.

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

Ця тема — про єдиний спосіб бути певним, який не залежить від твоєї памʼяті, уважності й кількості кави. Ти напишеш другу програму, яка перевіряє першу, і запускатимеш її щоразу за пів секунди. Називається це автоматичні тести, а інструмент, яким їх запускають у Python, — pytest.

Наскрізний приклад лишається тим самим кошиком покупок, з яким ми працюємо з теми 06. У модулі кошик.py живуть три функції — знайомі за темою 14, тільки тепер вони працюють разом:

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)

01 / БільНавіщо взагалі тести

Уяви звичайний вівторок. Приходить завдання: підняти середню знижку з 5% до 7%. Робота на десять секунд — знайти в знижка рядок return 0.05 й написати 0.07. Ти правиш, запускаєш програму, дивишся на екран: знижка справді стала 7%. Готово.

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

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

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

Інтерактив 1 · Тест ловить регресію

Шість тестів на три функції. Обери зміну в коді — і побачиш, що почервоніє.

зелених6
червоних0
що зламалось
Найважливіший варіант — «знижка 5% → 7%». Ти правив одну функцію, а червоних тестів два: разом зі знижкою впала до_сплати, яка нічого про твою правку не знає. Саме цю пару тобі й довелось би шукати руками — і саме її тест знаходить за 0.03 секунди.

Крім ловлі регресій, тести дають ще три речі, і кожна цінна сама по собі:

02 / Основаprint проти assert

Як новачок перевіряє функцію? Дописує print і дивиться на екран:

print(знижка(150)) # має бути 0.0 print(знижка(300)) # має бути 0.05 print(знижка(700)) # має бути 0.1

Це нормальний перший крок, і він працює — рівно один раз. Проблема починається, коли перевірок стає багато:

Заміна знайома тобі ще з теми 14 — інструкція assert:

assert знижка(150) == 0.0 assert знижка(300) == 0.05 assert знижка(700) == 0.10

assert вираз означає буквально «стверджую, що це істина». Якщо вираз істинний, не відбувається нічого — жодного виводу, жодної затримки. Якщо хибний, Python кидає AssertionError і програма зупиняється. Тобто мовчання тут і є повідомленням «усе гаразд», а дивитись треба тільки тоді, коли щось закричало.

питанняprintassert
хто порівнюєлюдина, очимаPython, сам
200 перевірокстіна текстутиша або одна помилка
очікувана відповідьу коментарі або в головіу коді, поруч
чи лишається в проєктіні, видаляютьтак, накопичується
можна запускати автоматомнітак
Для тих, хто вже пише код. assert — не заміна перевіркам вхідних даних. Запуск python3 -O програма.py (від optimize) викидає всі assert з байткоду, і рядок просто зникає. Тому для поганих даних від користувача пиши raise ValueError(...), як у темі 18, а assert лишай для тестів і внутрішніх припущень, порушення яких означає помилку програміста, а не користувача.

03 / ІнтроспекціяЩо показує pytest, коли падає

У голого assert є одна незручність, і вона суттєва. Коли він падає, повідомлення виглядає так:

Traceback (most recent call last): File "чек.py", line 12, in <module> assert підсумок(19.99, 7) == 139.93 AssertionError

Ти бачиш який рядок не спрацював — і все. Скільки функція повернула насправді, не написано ніде. Щоб дізнатись, доводиться йти в код, дописувати 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 сам обходить теки й збирає їх за іменами. Правил три, і вони прості:

Сам запуск — pytest у терміналі або, надійніше, python3 -m pytest (тоді точно спрацює той pytest, що стоїть у активному віртуальному середовищі).

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

Інтерактив 3 · Що саме потрапить у збірку

Перемикай імена файлів і функцій. Праворуч від кожного рядка — причина.

зібрано3
проігноровано0
рядок у звіті
Зніми першу галочку. Зникне не один тест, а два — разом з файлом випаде все, що в ньому лежить, включно з бездоганно названим test_межі(). Функція допоміжна() не збирається ніколи, і це нормально: у тестовому файлі можна тримати службові функції, pytest їх не чіпає.
Звідки тест бере твій код. Тестовий файл починається зі звичайного імпорту: from кошик import знижка, підсумок, до_сплати. Працює це за правилами теми 16: pytest додає теку тесту до шляху пошуку модулів, тому запускати команду треба з кореня проєкту, а не зсередини теки tests/. Якщо бачиш ModuleNotFoundError: No module named 'кошик' — майже завжди справа саме в тому, звідки ти запустив.

05 / ФормаСтруктура одного тесту

Тест — це звичайна функція без параметрів і без return. Усередині неї три частини, і корисно розділяти їх порожнім рядком:

def test_знижка_5_відсотків_на_середній_сумі(): сума = 300 # 1. підготувати ставка = знижка(сума) # 2. виконати assert ставка == 0.05 # 3. перевірити

Англійською цю схему звуть arrange – act – assert, і сенс поділу в тому, що кожна частина відповідає на своє питання: що в нас є, що ми з цим робимо, чого чекаємо. Коли тест падає, одразу видно, у якій частині дивитись.

Три поради про форму, кожна з причиною:

06 / РитмЧервоно-зелений цикл

Тепер про порядок дій, який з першого разу здається дивним: спершу пишемо тест, який падає, і лише потім код, який його зеленить. Пройди сім кроків — і подивись, навіщо це.

Інтерактив 4 · Червоний → зелений → рефакторинг

Вимога: на сумі рівно 200 знижка вже 5%, на 500 — уже 10%.

фаза
тестів0
зелених0
червоних0
Крок 5 і крок 6 — сенс усього циклу. На кроці 5 код переписано повністю: замість двох if — таблиця порогів. Тести зелені, отже поведінка не змінилась і рефакторинг безпечний. На кроці 6 у тій самій таблиці переплутано порядок рядків — і сітка ловить це негайно, не чекаючи бухгалтерії.

Чому важливо побачити тест червоним хоча б один раз? Бо тест — це теж код, і в ньому теж бувають помилки. Тест, який жодного разу не падав, може не перевіряти нічого. Класика жанру:

def test_знижка(): знижка(300) == 0.05 # ← забули assert: це просто вираз, він нічого не робить def test_підсумок(): очікуване = підсумок(19.99, 7) assert підсумок(19.99, 7) == очікуване # ← порівнюємо функцію саму з собою

Обидва «тести» зелені завжди — і при правильному коді, і при будь-якому зламаному. Побачити такий обман можна одним способом: спершу зробити так, щоб тест мусив упасти. Якщо він при цьому не впав — перевіряє він не те, що ти думаєш.

Цикл «червоний → зелений → рефакторинг» називається розробкою через тести (test-driven development, TDD). Як релігію її приймати не обовʼязково — багато хорошого коду написано в іншому порядку. Але одну її частину варто взяти беззастережно: коли знайшов баг, спершу відтвори його тестом, і тільки потім лагодь. Тоді ти точно знаєш, що виправив саме те, і що цей самий баг більше не повернеться непоміченим.

07 / ВибірЩо саме перевіряти

Найпоширеніша помилка початківця — набір тестів лише на «щасливий шлях» (happy path): типові дані, типовий результат, усе зелено. Такий набір заспокоює, але майже нічого не гарантує, бо помилки живуть не посередині діапазону.

Перевіряти треба три різні речі:

Чому саме межі. Найчастіша помилка в умовах — переплутати < з <= (англійською в неї навіть є назва: off-by-one, помилка на одиницю). Вона зачіпає рівно одне значення з усього діапазону — і саме тому випадкові типові дані її не бачать. Перемкни набір тестів і подивись.

Інтерактив 5 · Межі, а не середина

У коді <= замість <. Знайдуть це тести чи ні — залежить від набору.

тестів6
червоних0
помилку знайдено
Шість зелених тестів — і помилка жива. Код помиляється рівно на двох значеннях із дев'ятисот: 200 і 500. Шість точок, обраних «на око», промахуються повз них майже напевно. Чотири межові точки знаходять обидві помилки з першого запуску.
Робоче правило. На кожну умову в коді припадає щонайменше дві перевірки — по одній з кожного боку межі. У нашій знижка дві умови, отже мінімум чотири межові тести: 199, 200, 499, 500. Це звучить як багато роботи, але саме ці тести й ловлять справжні помилки — а писати їх десятками ми навчимось у розділі про parametrize.

08 / ВиняткиВиняток — теж поведінка

У темі 18 ми домовились: на погані дані функція має чесно кидати виняток, а не повертати щось дивне. Додамо таку перевірку в підсумок:

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

Тепер це — обіцянка, і її теж треба зафіксувати тестом. Але як? Звичайний assert не годиться: виклик просто впаде, і тест почервоніє замість того, щоб позеленіти. Для цього в pytest є менеджер контексту pytest.raises:

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

Читається дослівно: «усередині цього блоку має статися ValueError». Якщо він стався — тест зелений. Якщо не стався або стався інший клас — червоний. Перемикай варіанти й дивись на звіт pytest.

Інтерактив 6 · pytest.raises у чотирьох станах

Той самий тест, різна поведінка функції. Останній варіант — без raises взагалі.

тест
виняток
що каже pytest
Порівняй перший і четвертий варіанти. Функція поводиться однаково — кидає ValueError, як і задумано. Але без pytest.raises той самий виняток означає провал тесту. Обгортка не ловить помилку — вона перевертає її значення: тепер відсутність винятку є провалом.

Уточнити перевірку можна параметром match — це шматок тексту повідомлення (насправді регулярний вираз, але для початку достатньо звичайних слів):

with pytest.raises(ValueError, match="відʼємн"): підсумок(28.5, -1)
Не пиши це через try/except. Спокуслива самодіяльність виглядає так: try: підсумок(28.5, -1) / except ValueError: pass. Такий тест зелений завжди — і коли виняток стався, і коли функція спокійно повернула −28.5, бо в другому випадку теж не відбувається нічого поганого. Це рівно та пастка, про яку йшлось у розділі про червону фазу: тест, який не може впасти.

09 / ДаніОдин тест, багато випадків

Межових тестів у нас уже чотири, і всі вони — копії одне одного з різними числами:

def test_межа_199(): assert знижка(199) == 0.0 def test_межа_200(): assert знижка(200) == 0.05 def test_межа_499(): assert знижка(499) == 0.05 def test_межа_500(): assert знижка(500) == 0.10

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

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

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

Інтерактив 7 · Цикл проти parametrize

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

тестів у звіті
перевірено наборів
видно, який набір впав
Постав повзунок на 2 і порівняй режими. Цикл перевірив два набори з чотирьох і зупинився: чи працюють 499 і 500, ти так і не дізнався. parametrize перевірив усі чотири, показав три зелених і один червоний — та ще й назвав його в звіті: test_знижка[200-0.05].

Коли parametrize доречний: одна поведінка, багато вхідних даних. Коли ні: різні поведінки, які просто хочеться скоротити. Якщо в списку кортежів доводиться тримати ще й прапорець «а на цьому наборі чекаємо виняток» — це вже дві різні перевірки, і краще написати дві функції.

10 / ПідготовкаФікстури коротко

Часто кільком тестам потрібна та сама заготовка — приклад кошика, підключення, тимчасова тека. Копіювати підготовку в кожен тест не хочеться, а класти в глобальну змінну не можна: тести псуватимуть її один одному. Розвʼязок pytest — фікстура (fixture): функція з декоратором @pytest.fixture, результат якої тест просить за іменем параметра.

import pytest @pytest.fixture def кошик_зразок(): """Готовий кошик для тестів: три позиції.""" return {"хліб": (28.5, 2), "молоко": (32.0, 1), "мед": (145.0, 1)} def test_у_кошику_три_позиції(кошик_зразок): assert len(кошик_зразок) == 3 def test_хліб_коштує_57(кошик_зразок): ціна, кількість = кошик_зразок["хліб"] assert підсумок(ціна, кількість) == 57.0

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

Якщо після тесту треба прибрати за собою, фікстура пише yield замість return: усе до нього — підготовка, усе після — прибирання. А для найчастішого випадку — тимчасової теки під файли — фікстуру писати взагалі не треба, у pytest є вбудована tmp_path. Вона знадобиться нам уже в темі 20, коли програма почне читати з диска.

11 / МіраМежі розумного

Тести коштують часу, і писати їх на все підряд — така сама помилка, як не писати взагалі. Ось чого тестувати не варто.

Тести не повинні залежати одне від одного

Найпідступніша хвороба великого набору — коли тест B працює лише тому, що перед ним відпрацював тест A і щось після себе лишив. Ознака: «у мене локально зелено, а на сервері червоно» або «падає, якщо запустити тільки цей тест». Правило звучить просто: будь-який тест має проходити, якщо запустити його окремо. Перевірити це можна прямо:

python3 -m pytest test_кошик.py::test_межа_200

Джерело залежності майже завжди одне — спільний змінюваний стан: глобальний список, файл на диску, база. Ліки — фікстури, які створюють усе заново, і жодних глобальних змінних, які тести правлять.

Покриття: корисна цифра, шкідлива мета

Покриття коду (code coverage) — частка рядків програми, які виконались хоча б раз під час прогону тестів. Міряють його інструментом pytest-cov. Цифра корисна в один бік: якщо покриття 20%, чотири пʼятих коду ніхто ніколи не запускав, і там може бути що завгодно.

А от 100% не гарантує нічого, і причина проста: покриття рахує виконані рядки, а не перевірені твердження. Ось тест, який дає повне покриття функції знижка й не перевіряє жодної відповіді:

def test_нічого_не_перевіряє(): знижка(150) знижка(300) знижка(700) # усі рядки виконані — покриття 100%, гарантій нуль

Тому 100% — не мета, а орієнтир навпаки: низьке покриття точно погано, високе покриття само по собі ще нічого не означає. Дивись не на відсоток, а на те, чи є серед тестів межі й помилкові дані.

Швидкість — це теж вимога

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

12 / ПідсумокЩо забрати з теми

Тест — це друга програма, яка перевіряє першу. Усе решта випливає звідси:

На цьому блок 4 «Організація коду» закінчено. Подивись, що в ньому зібралось: модулі розклали код по файлах, віртуальні середовища відділили залежності одного проєкту від іншого, винятки навчили програму гідно поводитись, коли щось пішло не так, а тести зробили всі ці зміни оборотними. Чотири інструменти для коду, який має прожити довше одного вечора.

Далі — блок 5 «Робота з реальністю», і починається він з теми 20: файли. Досі всі наші дані жили в коді — списки й словники, записані руками. Реальні дані лежать на диску, і як тільки програма почне їх читати, одразу виникне питання, яке ми щойно навчились ставити: а як це протестувати? Функція, що читає файл, залежить від того, який файл лежить поруч, — тобто вже не чиста. Відповідь ти бачив у розділі про фікстури: tmp_path, тимчасова тека, яку pytest створює для кожного тесту й прибирає після нього. Так що знайомство з файлами почнеться не з нуля.

Далі в практиці. У practice.ipynb ти напишеш власний міні-запускач тестів на десять рядків — той, що ловить AssertionError і рахує зелені й червоні, — потім створиш справжній модуль і файл test_кошик.py у тимчасовій теці, запустиш pytest, відтвориш регресію з інтерактиву 1 і переконаєшся, що твій саморобний запускач і pytest дають однаковий вирок. Плюс pytest.raises, parametrize і межові випадки.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.