Откройте для себя тестирование на основе свойств с библиотекой 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
Простой пример: функция абсолютного значения
Рассмотрим простую функцию, которая должна вычислять абсолютное значение числа. Немного глючная реализация может выглядеть так:
# в файле с именем `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
Эти тесты проходят. Наша функция выглядит правильной на основе этих примеров. Но теперь давайте напишем тест на основе свойств с Hypothesis. Каково основное свойство функции абсолютного значения? Результат никогда не должен быть отрицательным.
# test_my_math.py (на основе свойств с 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
— это декоратор, который превращает обычную тестовую функцию в тест на основе свойств.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
или вернув определенное значение.
Еще лучше, что, если ошибка была связана с очень конкретным числом с плавающей запятой? Сокращатель 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()
позволяет взять значение из одной стратегии и преобразовать его во что-то другое. Это идеально подходит для создания объектов ваших пользовательских классов.
# Простой класс данных 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])
. Сокращатель обеспечит максимально простой сбойный пример, что сделает причину ошибки сразу очевидной.
Сценарий 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() используется для передачи данных между правилами 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 выводит начальное значение (например,
@reproduce_failure('version', 'seed')
). Если сервер CI обнаруживает ошибку, которую вы не можете воспроизвести локально, вы можете использовать этот декоратор с предоставленным начальным значением, чтобы заставить Hypothesis выполнить точно ту же последовательность примеров. - Интеграция с CI/CD: Hypothesis идеально подходит для любого конвейера непрерывной интеграции. Его способность находить малоизвестные ошибки до того, как они попадут в производство, делает его бесценной сетью безопасности.
Изменение мышления: мышление свойствами
Принятие Hypothesis — это больше, чем просто изучение новой библиотеки; это принятие нового способа мышления о правильности вашего кода. Вместо того чтобы спрашивать: "Какие входные данные мне следует протестировать?", вы начинаете спрашивать: "Каковы универсальные истины об этом коде?"
Вот несколько вопросов, которые помогут вам при попытке идентифицировать свойства:
- Существует ли обратная операция? (например, сериализация/десериализация, шифрование/дешифрование, сжатие/распаковка). Свойство состоит в том, что выполнение операции и ее обратной должно давать исходные входные данные.
- Является ли операция идемпотентной? (например,
abs(abs(x)) == abs(x)
). Применение функции более одного раза должно давать тот же результат, что и применение ее один раз. - Существует ли другой, более простой способ вычисления того же результата? Вы можете проверить, что ваша сложная, оптимизированная функция выдает те же выходные данные, что и простая, очевидно правильная версия (например, тестирование вашей причудливой сортировки по встроенной в Python
sorted()
). - Что всегда должно быть правдой о выводе? (например, вывод функции `find_prime_factors` должен содержать только простые числа, а их произведение должно равняться вводу).
- Как изменяется состояние? (Для тестирования с отслеживанием состояния) Какие инварианты необходимо поддерживать после любой допустимой операции? (например, количество товаров в корзине покупок никогда не может быть отрицательным).
Заключение: новый уровень уверенности
Тестирование на основе свойств с Hypothesis не заменяет тестирование на основе примеров. Вам по-прежнему нужны конкретные, написанные от руки тесты для критически важной бизнес-логики и хорошо понятных требований (например, "Пользователь из страны X должен видеть цену Y").
Hypothesis предоставляет мощный, автоматизированный способ изучения поведения вашего кода и защиты от непредвиденных крайних случаев. Он действует как неутомимый партнер, генерируя тысячи тестов, которые более разнообразны и коварны, чем любой человек мог бы реально написать. Определяя фундаментальные свойства вашего кода, вы создаете надежную спецификацию, которую Hypothesis может протестировать, что дает вам новый уровень уверенности в вашем программном обеспечении.
В следующий раз, когда вы напишете функцию, найдите время, чтобы подумать за пределами примеров. Спросите себя: "Каковы правила? Что всегда должно быть правдой?" Затем позвольте Hypothesis выполнить тяжелую работу по попытке их нарушить. Вы будете удивлены тем, что он найдет, и ваш код станет лучше благодаря этому.