Відкрийте для себе тестування на основі властивостей з бібліотекою Hypothesis у Python. Вийдіть за межі тестування на прикладах, щоб знайти крайні випадки та створити надійніше програмне забезпечення.
За межами юніт-тестів: Глибоке занурення в тестування на основі властивостей з Hypothesis у Python
У світі розробки програмного забезпечення тестування є основою якості. Протягом десятиліть домінуючою парадигмою було тестування на основі прикладів. Ми ретельно створюємо вхідні дані, визначаємо очікувані вихідні дані та пишемо твердження, щоб перевірити, чи поводиться наш код так, як заплановано. Цей підхід, який використовується у фреймворках, таких як unittest
і pytest
, є потужним і важливим. Але що, якщо я скажу вам, що існує додатковий підхід, який може виявити помилки, які вам навіть не спадало на думку шукати?
Ласкаво просимо у світ тестування на основі властивостей, парадигми, яка переносить акцент з тестування конкретних прикладів на перевірку загальних властивостей вашого коду. А в екосистемі Python беззаперечним чемпіоном цього підходу є бібліотека під назвою Hypothesis.
Цей вичерпний посібник проведе вас від повного новачка до впевненого практика тестування на основі властивостей з Hypothesis. Ми розглянемо основні концепції, заглибимося в практичні приклади та навчимося інтегрувати цей потужний інструмент у ваш щоденний робочий процес розробки, щоб створювати більш надійне, стійке до помилок програмне забезпечення.
Що таке тестування на основі властивостей? Зміна мислення
Щоб зрозуміти Hypothesis, нам спочатку потрібно зрозуміти основну ідею тестування на основі властивостей. Давайте порівняємо його з традиційним тестуванням на основі прикладів, яке ми всі знаємо.
Тестування на основі прикладів: Знайомий шлях
Уявіть, що ви написали власну функцію сортування, my_sort()
. З тестуванням на основі прикладів ваш хід думок буде таким:
- "Давайте протестуємо її з простим, впорядкованим списком." ->
assert my_sort([1, 2, 3]) == [1, 2, 3]
- "А як щодо списку у зворотному порядку?" ->
assert my_sort([3, 2, 1]) == [1, 2, 3]
- "А як щодо порожнього списку?" ->
assert my_sort([]) == []
- "Список з дублікатами?" ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5]
- "І список з від'ємними числами?" ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
Це ефективно, але має фундаментальне обмеження: ви тестуєте лише ті випадки, про які можете подумати. Ваші тести настільки ж хороші, наскільки хороша ваша уява. Ви можете пропустити крайні випадки, що включають дуже великі числа, неточності з плаваючою комою, конкретні символи Unicode або складні комбінації даних, які призводять до несподіваної поведінки.
Тестування на основі властивостей: Мислення інваріантами
Тестування на основі властивостей перевертає сценарій. Замість надання конкретних прикладів ви визначаєте властивості, або інваріанти, вашої функції — правила, які повинні бути справедливими для будь-яких дійсних вхідних даних. Для нашої функції my_sort()
цими властивостями можуть бути:
- Вихідні дані відсортовані: Для будь-якого списку чисел кожен елемент у вихідному списку менший або дорівнює наступному.
- Вихідні дані містять ті самі елементи, що й вхідні: Відсортований список є лише перестановкою вихідного списку; жодні елементи не додаються і не втрачаються.
- Функція є ідемпотентною: Сортування вже відсортованого списку не повинно його змінювати. Тобто,
my_sort(my_sort(some_list)) == my_sort(some_list)
.
Завдяки цьому підходу ви не пишете тестові дані. Ви пишете правила. Потім ви дозволяєте фреймворку, такому як Hypothesis, генерувати сотні або тисячі випадкових, різноманітних і часто хитрих вхідних даних, щоб спробувати довести, що ваші властивості неправильні. Якщо він знаходить вхідні дані, які порушують властивість, він знайшов помилку.
Представляємо Hypothesis: Ваш автоматизований генератор тестових даних
Hypothesis — це провідна бібліотека тестування на основі властивостей для Python. Вона бере визначені вами властивості та виконує важку роботу з генерування тестових даних для їх випробування. Це не просто генератор випадкових даних; це інтелектуальний і потужний інструмент, призначений для ефективного пошуку помилок.
Основні можливості Hypothesis
- Автоматичне генерування тестових випадків: Ви визначаєте *форму* необхідних даних (наприклад, "список цілих чисел", "рядок, що містить лише літери", "дата й час у майбутньому"), і Hypothesis генерує широкий спектр прикладів, що відповідають цій формі.
- Інтелектуальне стиснення: Це магічна функція. Коли Hypothesis знаходить невдалий тестовий випадок (наприклад, список із 50 комплексних чисел, який аварійно завершує вашу функцію сортування), він не просто повідомляє про цей величезний список. Він інтелектуально та автоматично спрощує вхідні дані, щоб знайти найменший можливий приклад, який все ще викликає збій. Замість списку з 50 елементів він може повідомити, що збій виникає лише з
[inf, nan]
. Це робить налагодження неймовірно швидким і ефективним. - Безшовна інтеграція: Hypothesis ідеально інтегрується з популярними фреймворками тестування, такими як
pytest
іunittest
. Ви можете додавати тести на основі властивостей разом із існуючими тестами на основі прикладів, не змінюючи свій робочий процес. - Багата бібліотека стратегій: Він постачається з великою колекцією вбудованих "стратегій" для генерування всього, від простих цілих чисел і рядків до складних, вкладених структур даних, дат і часу з урахуванням часових поясів і навіть масивів NumPy.
- Тестування зі станом: Для більш складних систем Hypothesis може тестувати послідовності дій, щоб знайти помилки в переходах стану, що надзвичайно складно за допомогою тестування на основі прикладів.
Початок роботи: Ваш перший тест Hypothesis
Давайте забруднимо руки. Найкращий спосіб зрозуміти Hypothesis — побачити його в дії.
Встановлення
Спочатку вам потрібно встановити Hypothesis і обраний вами засіб запуску тестів (ми будемо використовувати pytest
). Це так само просто, як:
pip install pytest hypothesis
Простий приклад: Функція абсолютного значення
Розглянемо просту функцію, яка повинна обчислювати абсолютне значення числа. Злегка глючна реалізація може виглядати так:
# in a file named `my_math.py` def custom_abs(x): """A custom implementation of the absolute value function.""" if x < 0: return -x return x
Тепер давайте напишемо тестовий файл, test_my_math.py
. Спочатку традиційний підхід pytest
:
# test_my_math.py (Example-based) def test_abs_positive(): assert custom_abs(5) == 5 def test_abs_negative(): assert custom_abs(-5) == 5 def test_abs_zero(): assert custom_abs(0) == 0
Ці тести проходять. Наша функція виглядає правильно на основі цих прикладів. Але тепер давайте напишемо тест на основі властивостей з Hypothesis. Яка основна властивість функції абсолютного значення? Результат ніколи не повинен бути від'ємним.
# test_my_math.py (Property-based with Hypothesis) from hypothesis import given from hypothesis import strategies as st from my_math import custom_abs @given(st.integers()) def test_abs_property_is_non_negative(x): """Property: The absolute value of any integer is always >= 0.""" assert custom_abs(x) >= 0
Давайте розберемо це:
from hypothesis import given, strategies as st
: Ми імпортуємо необхідні компоненти.given
— це декоратор, який перетворює звичайну тестову функцію на тест на основі властивостей.strategies
— це модуль, де ми знаходимо наші генератори даних.@given(st.integers())
: Це ядро тесту. Декоратор@given
повідомляє Hypothesis запустити цю тестову функцію кілька разів. Для кожного запуску він генеруватиме значення, використовуючи надану стратегію,st.integers()
, і передаватиме його як аргументx
нашій тестовій функції.assert custom_abs(x) >= 0
: Це наша властивість. Ми стверджуємо, що для будь-якого цілого числаx
, яке придумає Hypothesis, результат нашої функції має бути більшим або дорівнювати нулю.
Коли ви запустите це з pytest
, воно, ймовірно, пройде для багатьох значень. Hypothesis спробує 0, -1, 1, великі додатні числа, великі від'ємні числа та багато іншого. Наша проста функція правильно обробляє всі ці значення. Тепер давайте спробуємо іншу стратегію, щоб побачити, чи зможемо ми знайти слабкість.
# Let's test with floating point numbers @given(st.floats()) def test_abs_floats_property(x): assert custom_abs(x) >= 0
Якщо ви запустите це, Hypothesis швидко знайде невдалий випадок!
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesis виявив, що наша функція, коли їй дають float('nan')
(Not a Number), повертає nan
. Твердження nan >= 0
є хибним. Ми щойно знайшли тонку помилку, яку ми, ймовірно, не подумали б перевірити вручну. Ми могли б виправити нашу функцію, щоб обробити цей випадок, можливо, викликавши ValueError
або повернувши певне значення.
Ще краще, що якщо помилка була з дуже специфічним float? Shrinker Hypothesis взяв би велике, складне число, яке не вдалося, і звів би його до найпростішої можливої версії, яка все ще викликає помилку.
Сила стратегій: Створення ваших тестових даних
Стратегії — це серце Hypothesis. Це рецепти генерування даних. Бібліотека включає великий набір вбудованих стратегій, і ви можете об'єднувати та налаштовувати їх для генерування практично будь-якої структури даних, яку ви можете собі уявити.
Загальні вбудовані стратегії
- Числові:
st.integers(min_value=0, max_value=1000)
: Генерує цілі числа, необов'язково в межах певного діапазону.st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
: Генерує числа з плаваючою комою з точним контролем над спеціальними значеннями.st.fractions()
,st.decimals()
- Текст:
st.text(min_size=1, max_size=50)
: Генерує рядки Unicode певної довжини.st.text(alphabet='abcdef0123456789')
: Генерує рядки з певного набору символів (наприклад, для шістнадцяткових кодів).st.characters()
: Генерує окремі символи.
- Колекції:
st.lists(st.integers(), min_size=1)
: Генерує списки, де кожен елемент є цілим числом. Зауважте, як ми передаємо іншу стратегію як аргумент! Це називається композицією.st.tuples(st.text(), st.booleans())
: Генерує кортежі з фіксованою структурою.st.sets(st.integers())
st.dictionaries(keys=st.text(), values=st.integers())
: Генерує словники з указаними типами ключів і значень.
- Тимчасові:
st.dates()
,st.times()
,st.datetimes()
,st.timedeltas()
. Їх можна зробити з урахуванням часового поясу.
- Різне:
st.booleans()
: ГенеруєTrue
абоFalse
.st.just('constant_value')
: Завжди генерує одне й те саме значення. Корисно для створення складних стратегій.st.one_of(st.integers(), st.text())
: Генерує значення з однієї з наданих стратегій.st.none()
: Генерує лишеNone
.
Об'єднання та перетворення стратегій
Справжня сила Hypothesis походить від його здатності створювати складні стратегії з простіших.
Використання .map()
Метод .map()
дає змогу взяти значення з однієї стратегії та перетворити його на щось інше. Це ідеально підходить для створення об'єктів ваших власних класів.
# A simple data class from dataclasses import dataclass @dataclass class User: user_id: int username: str # A strategy to generate User objects user_strategy = st.builds( User, user_id=st.integers(min_value=1), username=st.text(min_size=3, alphabet='abcdefghijklmnopqrstuvwxyz') ) @given(user=user_strategy) def test_user_creation(user): assert isinstance(user, User) assert user.user_id > 0 assert user.username.isalpha()
Використання .filter()
і assume()
Іноді потрібно відхиляти певні згенеровані значення. Наприклад, вам може знадобитися список цілих чисел, де сума не дорівнює нулю. Ви можете використовувати .filter()
:
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
Однак використання .filter()
може бути неефективним. Якщо умова часто хибна, Hypothesis може витратити багато часу, намагаючись згенерувати дійсний приклад. Кращим підходом часто є використання assume()
всередині вашої тестової функції:
from hypothesis import assume @given(st.lists(st.integers())) def test_something_with_non_zero_sum_list(numbers): assume(sum(numbers) != 0) # ... your test logic here ...
assume()
повідомляє Hypothesis: "Якщо ця умова не виконується, просто відкиньте цей приклад і спробуйте новий." Це більш прямий і часто більш продуктивний спосіб обмежити ваші тестові дані.
Використання st.composite()
Для справді складного генерування даних, де одне згенероване значення залежить від іншого, st.composite()
— це інструмент, який вам потрібен. Він дає змогу написати функцію, яка приймає спеціальну функцію draw
як аргумент, яку можна використовувати для отримання значень з інших стратегій крок за кроком.
Класичним прикладом є генерування списку та дійсного індексу в цей список.
@st.composite def list_and_index(draw): # First, draw a non-empty list my_list = draw(st.lists(st.integers(), min_size=1)) # Then, draw an index that is guaranteed to be valid for that list index = draw(st.integers(min_value=0, max_value=len(my_list) - 1)) return (my_list, index) @given(data=list_and_index()) def test_list_access(data): my_list, index = data # This access is guaranteed to be safe because of how we built the strategy element = my_list[index] assert element is not None # A simple assertion
Hypothesis в дії: Реальні сценарії
Давайте застосуємо ці концепції до більш реалістичних проблем, з якими щодня стикаються розробники програмного забезпечення.
Сценарій 1: Тестування функції серіалізації даних
Уявіть собі функцію, яка серіалізує профіль користувача (словник) у URL-безпечний рядок, і іншу, яка десеріалізує його. Ключовою властивістю є те, що процес має бути ідеально оборотним.
import json import base64 def serialize_profile(data: dict) -> str: """Serializes a dictionary to a URL-safe base64 string.""" json_string = json.dumps(data) return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8') def deserialize_profile(encoded_str: str) -> dict: """Deserializes a string back into a dictionary.""" json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8') return json.loads(json_string) # Now for the test # We need a strategy that generates JSON-compatible dictionaries json_dictionaries = st.dictionaries( keys=st.text(), values=st.recursive(st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(), lambda children: st.lists(children) | st.dictionaries(st.text(), children), max_leaves=10) ) @given(profile=json_dictionaries) def test_serialization_roundtrip(profile): """Property: Deserializing an encoded profile should return the original profile.""" encoded = serialize_profile(profile) decoded = deserialize_profile(encoded) assert profile == decoded
Цей єдиний тест буде перевіряти наші функції з величезною різноманітністю даних: порожні словники, словники з вкладеними списками, словники з символами Unicode, словники з дивними ключами та багато іншого. Це набагато ретельніше, ніж писати кілька прикладів вручну.
Сценарій 2: Тестування алгоритму сортування
Повернімося до нашого прикладу сортування. Ось як ви б перевірили властивості, які ми визначили раніше.
from collections import Counter def my_buggy_sort(numbers): # Let's introduce a subtle bug: it drops duplicates return sorted(list(set(numbers))) @given(st.lists(st.integers())) def test_sorting_properties(numbers): sorted_list = my_buggy_sort(numbers) # Property 1: The output is sorted for i in range(len(sorted_list) - 1): assert sorted_list[i] <= sorted_list[i+1] # Property 2: The elements are the same (this will find the bug) assert Counter(numbers) == Counter(sorted_list) # Property 3: The function is idempotent assert my_buggy_sort(sorted_list) == sorted_list
Коли ви запустите цей тест, Hypothesis швидко знайде невдалий приклад для властивості 2, наприклад numbers=[0, 0]
. Наша функція повертає [0]
, і Counter([0, 0])
не дорівнює Counter([0])
. Shrinker гарантуватиме, що невдалий приклад буде якомога простішим, роблячи причину помилки відразу очевидною.
Сценарій 3: Тестування зі станом
Для об'єктів з внутрішнім станом, який змінюється з часом (наприклад, підключення до бази даних, кошик для покупок або кеш), пошук помилок може бути неймовірно складним. Для запуску несправності може знадобитися певна послідовність операцій. Hypothesis надає `RuleBasedStateMachine` саме для цієї мети.
Уявіть собі простий API для вбудованого сховища ключ-значення:
class SimpleKeyValueStore: def __init__(self): self._data = {} def set(self, key, value): self._data[key] = value def get(self, key): return self._data.get(key) def delete(self, key): if key in self._data: del self._data[key] def size(self): return len(self._data)
Ми можемо змоделювати його поведінку та перевірити її за допомогою машини стану:
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle class KeyValueStoreMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} self.sut = SimpleKeyValueStore() # Bundle() is used to pass data between rules keys = Bundle('keys') @rule(target=keys, key=st.text(), value=st.integers()) def set_key(self, key, value): self.model[key] = value self.sut.set(key, value) return key @rule(key=keys) def delete_key(self, key): del self.model[key] self.sut.delete(key) @rule(key=st.text()) def get_key(self, key): model_val = self.model.get(key) sut_val = self.sut.get(key) assert model_val == sut_val @rule() def check_size(self): assert len(self.model) == self.sut.size() # To run the test, you simply subclass from the machine and unittest.TestCase # In pytest, you can simply assign the test to the machine class TestKeyValueStore = KeyValueStoreMachine.TestCase
Hypothesis тепер виконуватиме випадкові послідовності операцій `set_key`, `delete_key`, `get_key` і `check_size`, невтомно намагаючись знайти послідовність, яка призведе до збою одного з тверджень. Він перевірить, чи правильно поводиться отримання видаленого ключа, чи є розмір узгодженим після кількох наборів і видалень, і багато інших сценаріїв, які ви, можливо, не подумаєте перевірити вручну.
Найкращі практики та розширені поради
- База даних прикладів: Hypothesis розумний. Коли він знаходить помилку, він зберігає невдалий приклад у локальному каталозі (
.hypothesis/
). Наступного разу, коли ви запустите тести, він спочатку відтворить цей невдалий приклад, надаючи вам негайний зворотний зв'язок про те, що помилка все ще присутня. Після того, як ви її виправите, приклад більше не відтворюється. - Керування виконанням тесту за допомогою
@settings
: Ви можете контролювати багато аспектів виконання тесту за допомогою декоратора@settings
. Ви можете збільшити кількість прикладів, установити термін, протягом якого може працювати один приклад (щоб зловити нескінченні цикли), і вимкнути певні перевірки працездатності.@settings(max_examples=500, deadline=1000) # Run 500 examples, 1-second deadline @given(...) ...
- Відтворення збоїв: Кожне виконання Hypothesis друкує значення seed (наприклад,
@reproduce_failure('version', 'seed')
). Якщо сервер CI знаходить помилку, яку ви не можете відтворити локально, ви можете використати цей декоратор з наданим seed, щоб змусити Hypothesis запустити точно таку саму послідовність прикладів. - Інтеграція з CI/CD: Hypothesis ідеально підходить для будь-якого конвеєра безперервної інтеграції. Його здатність знаходити незрозумілі помилки до того, як вони потраплять у виробництво, робить його безцінною мережею безпеки.
Зміна мислення: Мислення властивостями
Прийняття Hypothesis — це більше, ніж просто вивчення нової бібліотеки; це прийняття нового способу мислення про правильність вашого коду. Замість запитувати: "Які вхідні дані я повинен перевірити?", ви починаєте запитувати: "Які універсальні істини про цей код?"
Ось кілька запитань, які допоможуть вам визначити властивості:
- Чи існує зворотна операція? (наприклад, серіалізація/десеріалізація, шифрування/розшифрування, стиснення/розпакування). Властивість полягає в тому, що виконання операції та її зворотного ходу має дати вихідні вхідні дані.
- Чи є операція ідемпотентною? (наприклад,
abs(abs(x)) == abs(x)
). Застосування функції більше одного разу має дати той самий результат, що й застосування її один раз. - Чи існує інший, простіший спосіб обчислити той самий результат? Ви можете перевірити, чи ваша складна, оптимізована функція дає ті самі вихідні дані, що й проста, очевидно правильна версія (наприклад, тестування вашого модного сортування проти вбудованого в Python
sorted()
). - Що завжди має бути правдою про вихідні дані? (наприклад, вихідні дані функції `find_prime_factors` повинні містити лише прості числа, а їхній добуток має дорівнювати вхідним даним).
- Як змінюється стан? (Для тестування зі станом) Які інваріанти повинні підтримуватися після будь-якої дійсної операції? (наприклад, кількість товарів у кошику для покупок ніколи не може бути від'ємною).
Висновок: Новий рівень впевненості
Тестування на основі властивостей з Hypothesis не замінює тестування на основі прикладів. Вам все ще потрібні конкретні, написані вручну тести для критичної бізнес-логіки та добре зрозумілих вимог (наприклад, "Користувач з країни X повинен бачити ціну Y").
Hypothesis надає потужний, автоматизований спосіб дослідити поведінку вашого коду та захиститися від непередбачених крайніх випадків. Він діє як невтомний партнер, генеруючи тисячі тестів, які є більш різноманітними та хитрими, ніж будь-яка людина могла б реально написати. Визначаючи основні властивості вашого коду, ви створюєте надійну специфікацію, яку Hypothesis може перевірити, надаючи вам новий рівень впевненості у вашому програмному забезпеченні.
Наступного разу, коли ви напишете функцію, знайдіть час, щоб подумати за межами прикладів. Запитайте себе: "Які правила? Що завжди має бути правдою?" Потім дозвольте Hypothesis виконати важку роботу з спроб їх зламати. Ви будете здивовані тим, що він знайде, і ваш код стане кращим завдяки цьому.