Тесты на основе свойств, которые выявляют ошибки, о существовании которых вы не знали.
Делиться

Как разработчик, вы должны серьёзно относиться к тестированию своего кода. Вы можете писать модульные тесты с помощью pytest, использовать фиктивные зависимости и стремиться к высокому покрытию кода. Однако, если вы похожи на меня, у вас может возникнуть вопрос, который будет мучить вас после завершения написания тестового набора.
«Учел ли я все крайние случаи?»
Вы можете проверять входные данные положительными и отрицательными числами, нулем и пустыми строками. Но как насчёт странных символов Unicode? Или чисел с плавающей точкой, которые равны NaN или бесконечности? А как насчёт списка списков пустых строк или сложного вложенного JSON? Пространство возможных входных данных огромно, и сложно представить себе множество различных причин, по которым ваш код может сломаться, особенно если вы ограничены во времени.
Тестирование на основе свойств перекладывает эту нагрузку с вас на инструменты. Вместо того, чтобы вручную выбирать примеры, вы формулируете свойство — истину, которая должна выполняться для всех входных данных. Затем библиотека Hypothesis генерирует входные данные (при необходимости несколько сотен), ищет контрпримеры и, если находит, сжимает их до простейшего ложного случая.
В этой статье я познакомлю вас с мощной концепцией тестирования на основе свойств и её реализацией в Hypothesis. Мы не будем ограничиваться простыми функциями и покажем, как тестировать сложные структуры данных и классы с отслеживанием состояния, а также как настроить Hypothesis для надёжного и эффективного тестирования.
Так что же такое тестирование на основе свойств?
Тестирование на основе свойств — это методология, в которой вместо написания тестов для конкретных, жёстко заданных примеров вы определяете общие «свойства» или «инварианты» кода. Свойство — это высокоуровневое утверждение о поведении кода, которое должно выполняться для всех допустимых входных данных. Затем вы используете фреймворк тестирования, например, Hypothesis, который интеллектуально генерирует широкий диапазон входных данных и пытается найти «контрпример» — конкретный входной параметр, для которого указанное вами свойство ложно.
Некоторые ключевые аспекты тестирования на основе свойств с помощью Hypothesis включают в себя:
- Генеративное тестирование. Hypothesis генерирует для вас тестовые случаи — от простых до необычных, исследуя пограничные случаи, которые вы, вероятно, могли бы пропустить.
- На основе свойств. Это меняет ваше мышление с вопроса «каков выходной результат для этих конкретных входных данных?» на вопрос «каковы универсальные истины о поведении моей функции?»
- Сжатие. Это убийственная функция Hypothesis. Когда система обнаруживает неудавшийся тестовый случай (который может быть большим и сложным), она не просто сообщает об этом. Она автоматически «сжимает» входные данные до минимально возможного и простого примера, который всё ещё вызывает сбой, что часто значительно упрощает отладку.
- Тестирование с отслеживанием состояния. Гипотезы позволяют тестировать не только чистые функции, но и взаимодействия и изменения состояний сложных объектов посредством последовательности вызовов методов.
- Расширяемые стратегии. Hypothesis предоставляет надежную библиотеку «стратегий» для генерации данных и позволяет вам составлять их или создавать совершенно новые в соответствии с моделями данных вашего приложения.
Почему гипотезы важны / Распространенные случаи использования
Главное преимущество тестирования на основе свойств — его способность обнаруживать малозаметные ошибки и повышать вашу уверенность в правильности кода гораздо сильнее, чем при тестировании на основе примеров. Оно заставляет вас глубже продумывать контракты и допущения вашего кода.
Гипотеза особенно эффективна для проверки:
- Сериализация/десериализация. Классическое свойство заключается в том, что для любого объекта x функция decode(encode(x)) должна быть равна x. Это идеально подходит для тестирования функций, работающих с JSON или пользовательскими двоичными форматами.
- Сложная бизнес-логика. Любая функция со сложной условной логикой — отличный кандидат. Гипотеза исследует пути в вашем коде, которые вы, возможно, не рассматривали.
- Системы с отслеживанием состояния. Тестирование классов и объектов для гарантии того, что никакая последовательность допустимых операций не может привести объект в повреждённое или недопустимое состояние.
- Тестирование на основе эталонной реализации. Вы можете сформулировать свойство, согласно которому ваша новая оптимизированная функция всегда должна давать тот же результат, что и более простая, известная, образцовая эталонная реализация.
- Функции, принимающие сложные модели данных. Тестирование функций, принимающих в качестве входных данных модели Pydantic, классы данных или другие пользовательские объекты.
Настройка среды разработки
Всё, что вам нужно, — это Python и pip. Мы установим pytest в качестве инструмента для запуска тестов, саму гипотезу и pydantic для одного из наших продвинутых примеров.
(база) tom@tpr-desktop:~$ python -m venv hyp-env (база) tom@tpr-desktop:~$ source hyp-env/bin/activate (hyp-env) (база) tom@tpr-desktop:~$ # Установка pytest, hypothesis и pydantic (hyp-env) (база) tom@tpr-desktop:~$ pip install pytest hypothesis pydantic # создание новой папки для хранения кода Python (hyp-env) (база) tom@tpr-desktop:~$ mkdir hyp-project
Гипотезу лучше всего проверять с помощью проверенного инструмента для запуска тестов, например pytest, так мы и сделаем.
Пример кода 1 — Простой тест
В этом простейшем примере у нас есть функция, вычисляющая площадь прямоугольника. Она должна принимать два целочисленных параметра, оба больше нуля, и возвращать их произведение.
Проверка гипотез определяется с помощью двух элементов: декоратора @given и стратегии , которая передаётся декоратору. Представьте стратегию как типы данных, которые Hypothesis сгенерирует для проверки вашей функции. Вот простой пример. Сначала мы определяем функцию, которую хотим протестировать.
# my_geometry.py def calculate_rectangle_area(length: int, width: int) -> int: «»» Вычисляет площадь прямоугольника по его длине и ширине. Эта функция возвращает ValueError, если хотя бы одно из измерений не является положительным целым числом. «»» если не isinstance(length, int) или не isinstance(width, int): raise TypeError(«Длина и ширина должны быть целыми числами.») если длина <= 0 или ширина <= 0: raise ValueError("Длина и ширина должны быть положительными.") return длина * ширина
Далее следует функция тестирования.
# test_rectangle.py from my_geometry import calculate_rectangle_area from hypothesis import given, strategies as st import pytest # Используя st.integers(min_value=1) для обоих аргументов, мы гарантируем, # что Hypothesis сгенерирует только допустимые входные данные для нашей функции. @given( length=st.integers(min_value=1), width=st.integers(min_value=1) ) def test_rectangle_area_with_valid_inputs(length, width): «»» Свойство: Для любых положительных целых чисел length и width площадь должна быть равна их произведению. Этот тест гарантирует правильность базовой логики умножения. «»» print(f»Testing with valid inputs: length={length}, width={width}») # Проверяемое нами свойство — это математическое определение площади. assert calculate_rectangle_area(length, width) == length * width
Добавление декоратора @given к функции превращает её в проверку гипотезы. Передача стратегии (st.integers) декоратору означает, что гипотеза должна генерировать случайные целые числа для аргумента n при проверке, но мы накладываем дополнительные ограничения, гарантируя, что ни одно целое число не может быть меньше единицы.
Мы можем запустить этот тест, вызвав его следующим образом.
(hyp-env) (база) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_geometry.py == … длина=6541, ширина=1 Тестирование с допустимыми входными данными: длина=6541, ширина=28545 Тестирование с допустимыми входными данными: длина=1295885530, ширина=1 Тестирование с допустимыми входными данными: длина=1295885530, ширина=25191 Тестирование с допустимыми входными данными: длина=14538, ширина=1 Тестирование с допустимыми входными данными: длина=14538, ширина=15503 Тестирование с допустимыми входными данными: длина=7997, ширина=1 … … Тестирование с допустимыми входными данными: длина=19378, ширина=22512 Тестирование с допустимыми входными данными: длина=22512, ширина=22512 Тестирование с допустимыми входными данными: длина=3392, ширина=44 Тестирование с допустимыми входными данными: длина=44, ширина=44 . ================================================ 1 пройдено за 0,10 с ==================================================
По умолчанию Hypothesis выполнит 100 проверок вашей функции с различными входными данными. Вы можете увеличить или уменьшить это число, используя декоратор настроек . Например,
из гипотезы импортируется заданное, стратегии как st,settings … … @given( длина=st.integers(min_value=1), ширина=st.integers(min_value=1) ) @settings(max_examples=3) def test_rectangle_area_with_valid_inputs(длина, ширина): … … # # Выходы # (hyp-env) (база) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_geometry.py ================================================= запуск тестовой сессии ===================================================== платформа linux — Python 3.11.10, pytest-8.4.0, pluggy-1.6.0 корневой каталог: /home/tom/hypothesis_project плагины: hypothesis-6.135.9, anyio-4.9.0 собрано 1 элемент test_my_geometry.py Тестирование с допустимыми входными данными: длина = 1, ширина = 1 Тестирование с допустимыми входными данными: длина = 1870, ширина = 5773964720159522347 Тестирование с допустимыми входными данными: длина = 61, ширина = 25429 . =================================================== 1 пройдено за 0,06 с ===================================================
Пример кода 2 — Тестирование классического свойства «кругового обхода»
Давайте рассмотрим классическое свойство: сериализация и десериализация должны быть обратимы. Короче говоря, decode(encode(X)) должен возвращать X.
Мы напишем функцию, которая берет словарь и кодирует его в строку URL-запроса.
Создайте в папке вашего hyp-проекта файл с именем my_encoders.py.
# my_encoders.py import urllib.parse def encode_dict_to_querystring(data: dict) -> str: # Здесь есть ошибка: он плохо обрабатывает вложенные структуры return urllib.parse.urlencode(data) def decode_querystring_to_dict(qs: str) -> dict: return dict(urllib.parse.parse_qsl(qs))
Это две элементарные функции. Что с ними может пойти не так? Теперь давайте проверим их в test_encoders.py:
# test_encoders.py
# test_encoders.py из гипотезы import given, strategies as st # Стратегия для генерации словарей с простыми текстовыми ключами и значениями simple_dict_strategy = st.dictionaries(keys=st.text(), values=st.text()) @given(data=simple_dict_strategy) def test_querystring_roundtrip(data): «»»Свойство: декодирование закодированного словаря должно дать исходный словарь.»»» encoded = encode_dict_to_querystring(data) decoded = decode_querystring_to_dict(encoded) # Мы должны быть осторожны с типами: parse_qsl возвращает строковые значения # Поэтому мы преобразуем наши исходные значения в строки для честного сравнения original_as_str = {k: str(v) for k, v in data.items()} assert decoded == original_as_st
Теперь мы можем запустить наш тест.
(hyp-env) (база) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_encoders.py == … == … _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ data = {'': {}} @given(data=st.recursive( # Базовый случай: Плоский словарь текстовых ключей и простых значений (текст или целые числа). st.dictionaries(st.text(), st.integers() | st.text()), # Рекурсивный шаг: Разрешить значениям быть самими словарями. lambda children: st.dictionaries(st.text(), children) )) def test_for_nesting_limitation(data): «»» Этот тест утверждает, что декодированная структура данных соответствует исходной. Он завершится ошибкой, поскольку urlencode сглаживает вложенные структуры. «»» encoded = encode_dict_to_querystring(data) decoded = decode_querystring_to_dict(encoded) # Это намеренно упрощённое утверждение. Оно не сработает для вложенных # словарей, потому что версия `decoded` будет иметь # строковый внутренний словарь, в то время как версия `data` будет иметь настоящий внутренний словарь. # Так мы обнаруживаем ошибку. > assert decoded == data E AssertionError: assert {'': '{}'} == {'': {}} EE Различающиеся элементы: E {'': '{}'} != {'': {}} E Используйте -v для получения дополнительных различий E Пример фальсификации: test_for_nesting_limitation( E data={'': {}}, E ) test_encoders.py:24: AssertionError ============================================= краткая сводка теста ===================================================== ПРОВАЛ test_encoders.py::test_for_nesting_limitation — AssertionError: assert {'': '{}'} == {'': {}}
Ладно, это было неожиданно. Давайте попробуем разобраться, что пошло не так в этом тесте. Если коротко, то этот тест показывает, что функции кодирования/декодирования работают некорректно для вложенных словарей.
- Пример фальсификации. Самая важная подсказка находится в самом низу. Гипотеза сообщает нам точные входные данные, которые взламывают код.
test_for_nesting_limitation(данные={'': {}}, )
- Входные данные — словарь, где ключ — пустая строка, а значение — пустой словарь. Это классический случай, который человек может упустить из виду.
- Ошибка утверждения: тест не пройден из-за неверного утверждения утверждения:
AssertionError: assert {'': '{}'} == {'': {}}
В этом и заключается суть проблемы. Исходные данные, использованные для теста, были {'': {}}. Декодированный результат, полученный вашими функциями, был {'': '{}'}. Это показывает, что для ключа '' значения различаются:
- В декодированном виде значение представляет собой строку '{}'.
- В данных значением является словарь {}.
Строка не равна словарю, поэтому утверждение assert decoded == data равно False , и тест не пройден.
Пошаговое отслеживание ошибки
Наша функция encode_dict_to_querystring использует urllib.parse.urlencode. Когда urlencode встречает значение, являющееся словарём (например, {}), она не знает, как его обработать, поэтому просто преобразует его в строковое представление ('{}').
Информация об исходном типе значения (о том, что это был словарь) теряется навсегда .
Когда функция decode_querystring_to_dict считывает данные обратно, она корректно декодирует значение как строку '{}'. Она не может знать, что изначально это был словарь.
Решение: кодировать вложенные значения как строки JSON
Решение простое,
- Кодирование. Перед URL-кодированием проверьте каждое значение в словаре. Если значение — это словарь или список, сначала преобразуйте его в строку JSON.
- Декодируйте. После декодирования URL проверьте каждое значение. Если значение похоже на строку JSON (например, начинается с { или [), преобразуйте его обратно в объект Python.
- Сделать наше тестирование более полным . Наш декоратор стал сложнее. Проще говоря, он предписывает Hypothesis генерировать словари, которые могут содержать другие словари в качестве значений, что позволяет создавать вложенные структуры данных любой глубины. Например,
- Простой, плоский словарь: {'name': 'Alice', 'city': 'London'}
- Одноуровневый вложенный словарь: {'user': {'id': '123', 'name': 'Tom'}}
- Двухуровневый вложенный словарь: {'config': {'database': {'host': 'localhost'}}}
- И так далее…
Вот исправленный код.
# test_encoders.py from my_encoders import encode_dict_to_querystring, decode_querystring_to_dict from hypothesis import given, strategies as st # ============================================================================= # ТЕСТ 1: Этот тест доказывает правильность логики ВЛОЖЕНИЯ. # Он использует стратегию, которая генерирует ТОЛЬКО строки, поэтому нам не нужно # беспокоиться о преобразовании типов. Этот тест ПРОЙДЁТ. # ========================================================================= @given(data=st.recursive( st.dictionaries(st.text(), st.text()), lambda children: st.dictionaries(st.text(), children) )) def test_roundtrip_preserves_nested_structure(data): «»»Свойство: Цикл кодирования/декодирования должен сохранять вложенные структуры.»»» encoded = encode_dict_to_querystring(data) decoded = decode_querystring_to_dict(encoded) assert decoded == data # ============================================================================== # ТЕСТ 2: Этот тест доказывает корректность логики ПРЕОБРАЗОВАНИЯ ТИПА # для простых словарей FLAT. Этот тест также будет ПРОЙДЕН. # ========================================================================== @given(data=st.dictionaries(st.text(), st.integers() | st.text())) def test_roundtrip_stringifies_simple_values(data): «»» Свойство: Цикл должен преобразовывать простые значения (например, целые числа) в строки. «»» encoded = encode_dict_to_querystring(data) decoded = decode_querystring_to_dict(encoded) # Создаем модель того, что ожидаем: словарь со строковыми значениями. expected_data = {k: str(v) for k, v in data.items()} assert decoded == expected_data
Теперь, если мы снова запустим наш тест, мы получим следующее:
(hyp-env) (база) tom@tpr-desktop:~/hypothesis_project$ pytest == … [100%] =============================================== 1 пройдено за 0,16 с ==================================================
То, что мы там проработали, — классический пример, демонстрирующий, насколько полезным может быть тестирование с помощью Hypothesis. То, что мы считали двумя простыми и безошибочными функциями, оказалось совсем не так.
Пример кода 3 — Создание собственной стратегии для модели Pydantic
Многие реальные функции принимают не только простые словари, но и структурированные объекты, такие как пидантические модели. Гипотеза также может строить стратегии для этих пользовательских типов.
Давайте определим модель в my_models.py.
# my_models.py из pydantic import BaseModel, Field из typing import List class Product(BaseModel): id: int = Field(gt=0) name: str = Field(min_length=1) tags: List[str] def calculate_shipping_cost(product: Product, weight_kg: float) -> float: # Ошибочный калькулятор стоимости доставки cost = 10.0 + (weight_kg * 1.5) if «fragile» in product.tags: cost *= 1.5 # Доплата за хрупкие предметы if weight_kg > 10: cost += 20 # Доплата за тяжелые предметы # Ошибка: что делать, если cost отрицательный? return cost
Теперь в test_shipping.py мы создадим стратегию для генерации экземпляров Product и протестируем нашу ошибочную функцию.
# test_shipping.py из my_models import Product, calculate_shipping_cost из hypothesis import given, strategies as st # Построить стратегию для нашей модели Product product_strategy = st.builds( Product, id=st.integers(min_value=1), name=st.text(min_size=1), tags=st.lists(st.sampled_from([«electronics», «books», «fragile», «clothing»])) ) @given( product=product_strategy, weight_kg=st.floats(min_value=-10, max_value=100, allow_nan=False, allow_infinity=False) ) def test_shipping_cost_is_always_positive(product, weight_kg): «»»Свойство: Стоимость доставки никогда не должна быть отрицательной.»»» cost = calculate_shipping_cost(product, weight_kg) assert cost >= 0
А что показал тест?
(hyp-env) (база) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_shipping.py == … == … _ … calculate_shipping_cost(product, weight_kg) > assert cost >= 0 E assert -0.5 >= 0 E Пример фальсификации: test_shipping_cost_is_always_positive( E product=Product(id=1, name='0', tags=[]), E weight_kg=-7.0, E ) test_shipping.py:19: AssertionError ============================================================== краткий обзор теста информация ===================================================================== ПРОВАЛ test_shipping.py::test_shipping_cost_is_always_positive — assert -0.5 >= 0 ================================================================= 1 ошибка за 0,12 с ====================================================================
При запуске этого теста с помощью pytest Hypothesis быстро найдёт пример, опровергающий это: товар с отрицательным значением weight_kg может иметь отрицательную стоимость доставки. Это пограничный случай, который мы могли не учесть, но Hypothesis нашёл его автоматически.
Пример кода 4 — Тестирование классов с сохранением состояния
Hypothesis может делать больше, чем просто тестировать чистые функции. Он может тестировать классы с внутренним состоянием, генерируя последовательности вызовов методов, чтобы попытаться их разрушить. Давайте протестируем простой пользовательский класс LimitedCache.
my_cache.py
# my_cache.py class LimitedCache: def __init__(self, capacity: int): if capacity <= 0: raise ValueError("Capacity must be positive") self._cache = {} self._capacity = capacity # Ошибка: для правильного LRU это, вероятно, должна быть deque или упорядоченный словарь self._keys_in_order = [] def put(self, key, value): if key not in self._cache and len(self._cache) >= self._capacity: # Вытесняем самый старый элемент key_to_evict = self._keys_in_order.pop(0) del self._cache[key_to_evict] if key not in self._keys_in_order: self._keys_in_order.append(key) self._cache[key] = value def get(self, key): return self._cache.get(key) @property def размер(self): вернуть len(self._cache)
В этом кэше есть несколько потенциальных ошибок, связанных с политикой вытеснения. Давайте проверим его с помощью конечного автомата на основе правил гипотез, который предназначен для тестирования объектов с внутренним состоянием путём генерации случайных последовательностей вызовов методов для выявления ошибок, которые проявляются только после определённых взаимодействий.
Создайте файл test_cache.py.
from hypothesis import strategies as st from hypothesis.stateful import RuleBasedStateMachine, rule, precondition from my_cache import LimitedCache class CacheMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.cache = LimitedCache(capacity=3) # Это правило добавляет 3 начальных элемента для заполнения кэша @rule( k1=st.just('a'), k2=st.just('b'), k3=st.just('c'), v1=st.integers(), v2=st.integers(), v3=st.integers() ) def fill_cache(self, k1, v1, k2, v2, k3, v3): self.cache.put(k1, v1) self.cache.put(k2, v2) self.cache.put(k3, v3) # Это правило может выполняться только ПОСЛЕ заполнения кэша. # Он проверяет основную логику LRU и FIFO. @precondition(lambda self: self.cache.size == 3) @rule() def test_update_behavior(self): «»» Свойство: Обновление самого старого элемента ('a') должно сделать его самым новым, поэтому следующее вытеснение должно удалить второй по возрасту элемент ('b'). Наш глючный кэш FIFO в любом случае неправильно удалит 'a'. «»» # На этом этапе keys_in_order равен ['a', 'b', 'c']. # 'a' — самый старый. # Мы «используем» 'a' снова, обновив его. В правильном кэше LRU # это сделало бы 'a' последним использованным элементом. self.cache.put('a', 999) # Теперь мы добавляем новый ключ, что должно вызвать вытеснение. self.cache.put('d', 4) # Корректный кэш LRU вытеснит 'b'. # Наш ошибочный кэш FIFO вытеснит 'a'. # Это утверждение проверяет состояние 'a'. # В нашем ошибочном кэше get('a') будет None, поэтому это завершится ошибкой. assert self.cache.get('a') is not None, «Элемент 'a' был неправильно вытеснен» # Это сообщает pytest о необходимости запустить тест конечного автомата TestCache = CacheMachine.TestCase
Гипотеза будет генерировать длинные последовательности операций put и get. Она быстро определит последовательность операций put, которая приводит к превышению размера кэша или к тому, что его вытеснение происходит не так, как в нашей модели, тем самым выявляя ошибки в нашей реализации.
(hyp-env) (база) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_cache.py == … == … ../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:476: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:258: в run_state_machine_as_test state_machine_test(state_machine_factory) ../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:115: в run_state_machine @given(st.data()) ^^^^^^^^ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = CacheMachine({}) @precondition(lambda self: self.cache.size == 3) @rule() def test_update_behavior(self): «»» Свойство: Обновление самого старого элемента ('a') должно сделать его самым новым, поэтому следующее вытеснение должно удалить второй по возрасту элемент ('b'). Наш ошибочный кэш FIFO в любом случае неправильно удалит 'a'. «»» # На данный момент keys_in_order равен ['a', 'b', 'c']. # 'a' — самый старый. # Мы снова «используем» 'a', обновив его. В правильном кэше LRU # это сделало бы 'a' последним использованным элементом. self.cache.put('a', 999) # Теперь мы добавляем новый ключ, что должно вызвать вытеснение. self.cache.put('d', 4) # Корректный кэш LRU вытеснил бы 'b'. # Наш ошибочный кэш FIFO вытеснит 'a'. # Это утверждение проверяет состояние 'a'. # В нашем ошибочном кэше get('a') будет None, поэтому это не сработает. > assert self.cache.get('a') is not None, «Элемент 'a' был неправильно вытеснен» E AssertionError: Элемент 'a' был неправильно вытеснен E assert None is not None E + where None = get('a') E + where get =
Вывод выше выявляет ошибку в коде. Проще говоря, этот вывод показывает, что кэш Не является полноценным кэшем «наименее давно использованных» (LRU) . У него есть следующий существенный недостаток:
При обновлении элемента, который уже находится в кэше, кэш не запоминает, что он теперь «самый новый». Он по-прежнему считает его самым старым, поэтому он преждевременно удаляется из кэша.
Пример кода 5 — Тестирование с использованием более простой эталонной реализации
В нашем последнем примере мы рассмотрим типичную ситуацию. Часто программисты пишут функции, которые должны заменить старые, более медленные, но в остальном абсолютно корректные функции. Ваша новая функция должна выдавать те же выходные данные, что и старая, при тех же входных данных. Гипотеза может значительно упростить тестирование в этом отношении.
Предположим, у нас есть простая функция sum_list_simple и новая, «оптимизированная» sum_list_fast, в которой есть ошибка.
my_sums.py
# my_sums.py def sum_list_simple(data: list[int]) -> int: # Это наша простая и правильная эталонная реализация return sum(data) def sum_list_fast(data: list[int]) -> int: # Новая «быстрая» реализация с ошибкой (например, переполнением целочисленных значений для больших чисел) # или, в данном случае, простой ошибкой. total = 0 for x in data: # Ошибка: это должно быть += total = x return total
test_sums.py
# test_sums.py from my_sums import sum_list_simple, sum_list_fast from hypothesis import given, strategies as st @given(st.lists(st.integers())) def test_fast_sum_matches_simple_sum(data): «»» Свойство: Результат новой, быстрой функции всегда должен совпадать с результатом простой, ссылочной функции. «»» assert sum_list_fast(data) == sum_list_simple(data)
Гипотеза быстро обнаружит, что для любого списка с более чем одним элементом новая функция не работает. Давайте проверим это.
(hyp-env) (база) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_sums.py == … == … _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ data = [1, 0] @given(st.lists(st.integers())) def test_fast_sum_matches_simple_sum(data): «»» Свойство: Результат новой, быстрой функции всегда должен совпадать с результатом простой, ссылочной функции. «»» > assert sum_list_fast(data) == sum_list_simple(data) E assert 0 == 1 E + где 0 = sum_list_fast([1, 0]) E + и 1 = sum_list_simple([1, 0]) E Пример фальсификации: test_fast_sum_matches_simple_sum( E data=[1, 0], E ) test_my_sums.py:11: AssertionError ============================================== краткий обзор теста == …
Итак, тест не пройден, потому что «быстрая» функция суммы дала неправильный ответ (0) для входного списка [1, 0], тогда как правильный ответ, предоставленный «простой» функцией суммы, был 1. Теперь, когда вы знаете, в чем проблема, вы можете предпринять шаги для ее исправления.
Краткое содержание
В этой статье мы глубоко погрузились в мир тестирования на основе свойств с помощью Hypothesis, выйдя за рамки простых примеров и показав, как его можно применять к решению реальных задач. Мы увидели, что, определяя инварианты кода, мы можем обнаружить малозаметные ошибки, которые традиционное тестирование, скорее всего, пропустит. Мы узнали, как:
- Протестируйте свойство «кругового обхода» и посмотрите, как более сложные стратегии обработки данных могут выявить ограничения в нашем коде.
- Создавайте индивидуальные стратегии для генерации экземпляров сложных моделей Pydantic для тестирования бизнес-логики.
- Используйте RuleBasedStateMachine для проверки поведения классов с сохранением состояния путем генерации последовательностей вызовов методов.
- Проверьте сложную оптимизированную функцию, протестировав ее на более простой и заведомо хорошей эталонной реализации.
Добавление тестов на основе свойств в ваш инструментарий не заменит все существующие тесты. Тем не менее, оно значительно их дополнит, заставив вас более чётко продумывать контракты вашего кода и значительно повысив уверенность в его корректности. Я рекомендую вам выбрать функцию или класс в вашей кодовой базе, подумать о его фундаментальных свойствах и позволить Hypothesis постараться доказать вашу неправоту. Благодаря этому вы станете лучшим разработчиком.
Я лишь поверхностно рассказал о возможностях Hypothesis для вашего тестирования. Подробнее см. в официальной документации, доступной по ссылке ниже.
https://hypothesis.readthedocs.io/en/latest
Источник: towardsdatascience.com



























