Открийте property-based тестването с библиотеката Hypothesis за Python. Преминете отвъд тестовете, базирани на примери, за да намирате крайни случаи и да създавате по-стабилен и надежден софтуер.
Отвъд юнит тестовете: Задълбочен поглед върху property-based тестването с Hypothesis в Python
В света на софтуерната разработка, тестването е основата на качеството. В продължение на десетилетия доминиращата парадигма е била тестване, базирано на примери. Ние щателно изработваме входни данни, дефинираме очакваните резултати и пишем assertions, за да проверим дали кодът ни се държи според очакванията. Този подход, използван в рамки като unittest
и pytest
, е мощен и съществен. Но какво, ако ви кажа, че има допълващ подход, който може да открие бъгове, за които дори не сте се сетили да търсите?
Добре дошли в света на property-based тестването, парадигма, която измества фокуса от тестване на конкретни примери към проверка на общи свойства на вашия код. А в екосистемата на Python, безспорният шампион на този подход е библиотека, наречена Hypothesis.
Това изчерпателно ръководство ще ви преведе от напълно начинаещ до уверен практик на property-based тестването с Hypothesis. Ще разгледаме основните концепции, ще се потопим в практически примери и ще се научим как да интегрираме този мощен инструмент в ежедневния си работен процес, за да създаваме по-стабилен, надежден и устойчив на бъгове софтуер.
Какво е Property-Based тестване? Промяна в начина на мислене
За да разберем Hypothesis, първо трябва да схванем основната идея на property-based тестването. Нека го сравним с традиционното тестване, базирано на примери, което всички познаваме.
Тестване, базирано на примери: Познатият път
Представете си, че сте написали собствена функция за сортиране, 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 символи или сложни комбинации от данни, които водят до неочаквано поведение.
Property-Based тестване: Мислене в инварианти
Property-based тестването обръща сценария. Вместо да предоставяте конкретни примери, вие дефинирате свойствата, или инвариантите, на вашата функция — правила, които трябва да са верни за всякакви валидни входни данни. За нашата функция my_sort()
, тези свойства може да са:
- Изходът е сортиран: За всеки списък от числа, всеки елемент в изходния списък е по-малък или равен на този, който го следва.
- Изходът съдържа същите елементи като входа: Сортираният списък е просто пермутация на оригиналния списък; не се добавят или губят елементи.
- Функцията е идемпотентна: Сортирането на вече сортиран списък не трябва да го променя. Тоест,
my_sort(my_sort(some_list)) == my_sort(some_list)
.
С този подход вие не пишете тестовите данни. Вие пишете правилата. След това оставяте на рамка, като Hypothesis, да генерира стотици или хиляди произволни, разнообразни и често коварни входни данни, за да се опита да опровергае вашите свойства. Ако намери входни данни, които нарушават дадено свойство, значи е открила бъг.
Представяме ви Hypothesis: Вашият автоматизиран генератор на тестови данни
Hypothesis е водещата библиотека за property-based тестване за Python. Тя взима свойствата, които дефинирате, и върши тежката работа по генериране на тестови данни, за да ги предизвика. Това не е просто генератор на произволни данни; това е интелигентен и мощен инструмент, създаден да намира бъгове ефективно.
Ключови характеристики на Hypothesis
- Автоматично генериране на тестови случаи: Вие дефинирате формата на данните, от които се нуждаете (напр. "списък от цели числа", "низ, съдържащ само букви", "datetime в бъдещето"), и Hypothesis генерира голямо разнообразие от примери, съответстващи на тази форма.
- Интелигентно свиване (shrinking): Това е магическата функция. Когато Hypothesis намери провалящ се тестов случай (напр. списък от 50 комплексни числа, който срива вашата функция за сортиране), тя не просто докладва този огромен списък. Тя интелигентно и автоматично опростява входните данни, за да намери най-малкия възможен пример, който все още причинява грешката. Вместо списък от 50 елемента, тя може да докладва, че грешката възниква само с
[inf, nan]
. Това прави отстраняването на грешки невероятно бързо и ефективно. - Безпроблемна интеграция: Hypothesis се интегрира перфектно с популярни рамки за тестване като
pytest
иunittest
. Можете да добавяте property-based тестове заедно със съществуващите си тестове, базирани на примери, без да променяте работния си процес. - Богата библиотека от стратегии: Предлага се с огромна колекция от вградени "стратегии" за генериране на всичко – от прости цели числа и низове до сложни, вложени структури от данни, дати и часове, съобразени с часовите зони, и дори NumPy масиви.
- Stateful тестване: За по-сложни системи, Hypothesis може да тества последователности от действия, за да намери бъгове в преходите между състояния, нещо, което е notoriously трудно с тестването, базирано на примери.
Първи стъпки: Вашият първи тест с Hypothesis
Нека се захващаме за работа. Най-добрият начин да разберете Hypothesis е да я видите в действие.
Инсталация
Първо, ще трябва да инсталирате Hypothesis и избрания от вас test runner (ще използваме pytest
). Това е толкова просто, колкото:
pip install pytest hypothesis
Прост пример: Функция за абсолютна стойност
Нека разгледаме проста функция, която трябва да изчислява абсолютната стойност на число. Леко бъгава имплементация може да изглежда така:
# във файл с име `my_math.py` def custom_abs(x): """Персонализирана имплементация на функцията за абсолютна стойност.""" if x < 0: return -x return x
Сега, нека напишем тестов файл, test_my_math.py
. Първо, традиционният подход с pytest
:
# test_my_math.py (базиран на примери) 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
Тези тестове преминават успешно. Функцията ни изглежда коректна въз основа на тези примери. Но сега, нека напишем property-based тест с Hypothesis. Кое е основното свойство на функцията за абсолютна стойност? Резултатът никога не трябва да е отрицателен.
# test_my_math.py (Property-based с 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): """Свойство: Абсолютната стойност на всяко цяло число е винаги >= 0.""" assert custom_abs(x) >= 0
Нека разгледаме кода в детайли:
from hypothesis import given, strategies as st
: Импортираме необходимите компоненти.given
е декоратор, който превръща обикновена тестова функция в property-based тест.strategies
е модулът, в който намираме нашите генератори на данни.@given(st.integers())
: Това е ядрото на теста. Декораторът@given
казва на Hypothesis да изпълни тази тестова функция многократно. За всяко изпълнение тя ще генерира стойност, използвайки предоставената стратегия,st.integers()
, и ще я предаде като аргументx
на нашата тестова функция.assert custom_abs(x) >= 0
: Това е нашето свойство. Ние твърдим, че за каквото и цяло числоx
Hypothesis да измисли, резултатът от нашата функция трябва да бъде по-голям или равен на нула.
Когато изпълните това с pytest
, той вероятно ще премине успешно за много стойности. Hypothesis ще опита 0, -1, 1, големи положителни числа, големи отрицателни числа и други. Нашата проста функция се справя с всички тях правилно. Сега, нека опитаме различна стратегия, за да видим дали можем да намерим слабост.
# Нека тестваме с числа с плаваща запетая @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
или върнем конкретна стойност.
Още по-добре, ами ако бъгът беше с много специфично число с плаваща запетая? 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()
ви позволява да вземете стойност от една стратегия и да я трансформирате в нещо друго. Това е идеално за създаване на обекти от вашите собствени класове.
# Прост data class from dataclasses import dataclass @dataclass class User: user_id: int username: str # Стратегия за генериране на обекти User 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) # ... вашата тестова логика тук ...
assume()
казва на Hypothesis: "Ако това условие не е изпълнено, просто отхвърли този пример и опитай с нов." Това е по-директен и често по-ефективен начин да ограничите вашите тестови данни.
Използване на st.composite()
За наистина сложно генериране на данни, където една генерирана стойност зависи от друга, st.composite()
е инструментът, от който се нуждаете. Той ви позволява да напишете функция, която приема специална функция draw
като аргумент, която можете да използвате, за да изтегляте стойности от други стратегии стъпка по стъпка.
Класически пример е генерирането на списък и валиден индекс в този списък.
@st.composite def list_and_index(draw): # Първо, изтегляме непразен списък my_list = draw(st.lists(st.integers(), min_size=1)) # След това, изтегляме индекс, който е гарантирано валиден за този списък 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 # Този достъп е гарантирано безопасен заради начина, по който сме изградили стратегията element = my_list[index] assert element is not None # Просто твърдение
Hypothesis в действие: Сценарии от реалния свят
Нека приложим тези концепции към по-реалистични проблеми, с които разработчиците на софтуер се сблъскват всеки ден.
Сценарий 1: Тестване на функция за сериализация на данни
Представете си функция, която сериализира потребителски профил (речник) в URL-безопасен низ, и друга, която го десериализира. Ключово свойство е, че процесът трябва да бъде напълно обратим.
import json import base64 def serialize_profile(data: dict) -> str: """Сериализира речник в URL-безопасен base64 низ.""" json_string = json.dumps(data) return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8') def deserialize_profile(encoded_str: str) -> dict: """Десериализира низ обратно в речник.""" json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8') return json.loads(json_string) # Сега за теста # Нуждаем се от стратегия, която генерира JSON-съвместими речници 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): """Свойство: Десериализирането на кодиран профил трябва да върне оригиналния профил.""" encoded = serialize_profile(profile) decoded = deserialize_profile(encoded) assert profile == decoded
Този единствен тест ще подложи нашите функции на огромно разнообразие от данни: празни речници, речници с вложени списъци, речници с unicode символи, речници със странни ключове и други. Това е далеч по-задълбочено от писането на няколко ръчни примера.
Сценарий 2: Тестване на алгоритъм за сортиране
Нека се върнем към нашия пример за сортиране. Ето как бихте тествали свойствата, които дефинирахме по-рано.
from collections import Counter def my_buggy_sort(numbers): # Нека въведем фин бъг: премахва дубликати return sorted(list(set(numbers))) @given(st.lists(st.integers())) def test_sorting_properties(numbers): sorted_list = my_buggy_sort(numbers) # Свойство 1: Изходът е сортиран for i in range(len(sorted_list) - 1): assert sorted_list[i] <= sorted_list[i+1] # Свойство 2: Елементите са същите (това ще намери бъга) assert Counter(numbers) == Counter(sorted_list) # Свойство 3: Функцията е идемпотентна assert my_buggy_sort(sorted_list) == sorted_list
Когато изпълните този тест, Hypothesis бързо ще намери провалящ се пример за Свойство 2, като например numbers=[0, 0]
. Нашата функция връща [0]
, а Counter([0, 0])
не е равно на Counter([0])
. Shrinker-ът ще се погрижи провалящият се пример да е възможно най-прост, правейки причината за бъга веднага очевидна.
Сценарий 3: Stateful тестване
За обекти с вътрешно състояние, което се променя с времето (като връзка с база данни, количка за пазаруване или кеш), намирането на бъгове може да бъде изключително трудно. Може да е необходима специфична последователност от операции, за да се предизвика грешка. Hypothesis предоставя `RuleBasedStateMachine` точно за тази цел.
Представете си просто API за key-value хранилище в паметта:
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)
Можем да моделираме неговото поведение и да го тестваме със state machine:
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle class KeyValueStoreMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} self.sut = SimpleKeyValueStore() # Bundle() се използва за предаване на данни между правилата 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() # За да изпълните теста, просто наследявате от машината и unittest.TestCase # В pytest, можете просто да присвоите теста на класа на машината TestKeyValueStore = KeyValueStoreMachine.TestCase
Сега Hypothesis ще изпълнява произволни последователности от операции `set_key`, `delete_key`, `get_key` и `check_size`, неуморно опитвайки се да намери последователност, която причинява провал на някое от твърденията. Тя ще провери дали извличането на изтрит ключ се държи правилно, дали размерът е консистентен след множество задавания и изтривания, и много други сценарии, за които може би не бихте се сетили да тествате ръчно.
Добри практики и съвети за напреднали
- Базата данни с примери: Hypothesis е умна. Когато намери бъг, тя запазва провалящия се пример в локална директория (
.hypothesis/
). Следващия път, когато изпълните тестовете си, тя първо ще възпроизведе този провалящ се пример, давайки ви незабавна обратна връзка, че бъгът все още е налице. След като го поправите, примерът вече не се възпроизвежда. - Контролиране на изпълнението на тестовете с
@settings
: Можете да контролирате много аспекти на изпълнението на теста, като използвате декоратора@settings
. Можете да увеличите броя на примерите, да зададете краен срок за изпълнението на един пример (за улавяне на безкрайни цикли) и да изключите определени проверки за изправност.@settings(max_examples=500, deadline=1000) # Изпълни 500 примера, 1-секунден краен срок @given(...) ...
- Възпроизвеждане на грешки: Всяко изпълнение на Hypothesis отпечатва seed стойност (напр.
@reproduce_failure('version', 'seed')
). Ако CI сървър намери бъг, който не можете да възпроизведете локално, можете да използвате този декоратор с предоставения seed, за да принудите Hypothesis да изпълни точно същата последователност от примери. - Интегриране с CI/CD: Hypothesis е идеално допълнение към всеки процес на непрекъсната интеграция. Способността ѝ да намира скрити бъгове, преди да достигнат до продукция, я прави безценна предпазна мрежа.
Промяна в мисленето: Мислене в свойства
Приемането на Hypothesis е повече от просто научаване на нова библиотека; става въпрос за възприемане на нов начин на мислене за коректността на вашия код. Вместо да питате: "Какви входни данни трябва да тествам?", вие започвате да питате: "Кои са универсалните истини за този код?"
Ето някои въпроси, които да ви насочват, когато се опитвате да идентифицирате свойства:
- Има ли обратна операция? (напр. сериализация/десериализация, криптиране/декриптиране, компресиране/декомпресиране). Свойството е, че извършването на операцията и нейната обратна трябва да даде оригиналния вход.
- Идемпотентна ли е операцията? (напр.
abs(abs(x)) == abs(x)
). Прилагането на функцията повече от веднъж трябва да даде същия резултат като прилагането ѝ веднъж. - Има ли различен, по-прост начин за изчисляване на същия резултат? Можете да тествате дали вашата сложна, оптимизирана функция произвежда същия резултат като проста, очевидно коректна версия (напр. тестване на вашето сложно сортиране срещу вградената в Python функция
sorted()
). - Какво трябва винаги да е вярно за изхода? (напр. изходът от функция `find_prime_factors` трябва да съдържа само прости числа, а техният продукт трябва да е равен на входа).
- Как се променя състоянието? (За stateful тестване) Какви инварианти трябва да се поддържат след всяка валидна операция? (напр. Броят на артикулите в количката за пазаруване никога не може да бъде отрицателен).
Заключение: Ново ниво на увереност
Property-based тестването с Hypothesis не замества тестването, базирано на примери. Все още се нуждаете от специфични, ръчно написани тестове за критична бизнес логика и добре разбрани изисквания (напр. "Потребител от държава X трябва да види цена Y").
Това, което Hypothesis предоставя, е мощен, автоматизиран начин да изследвате поведението на вашия код и да се предпазите от непредвидени крайни случаи. Тя действа като неуморен партньор, генерирайки хиляди тестове, които са по-разнообразни и коварни, отколкото който и да е човек би могъл реално да напише. Като дефинирате основните свойства на вашия код, вие създавате стабилна спецификация, срещу която Hypothesis може да тества, давайки ви ново ниво на увереност във вашия софтуер.
Следващия път, когато пишете функция, отделете малко време да помислите отвъд примерите. Запитайте се: "Какви са правилата? Какво трябва винаги да е вярно?" След това оставете Hypothesis да свърши тежката работа по опитите да ги наруши. Ще бъдете изненадани от това, което ще намери, а кодът ви ще стане по-добър благодарение на това.