Property-based тестирование с Hypothesis: инварианты, шринкинг и база примеров
Hypothesis генерирует входы под заявленные инварианты — roundtrip, идемпотентность, оракул — ужимает падение до минимума и реплеит его из базы .hypothesis. Считайте max_examples против времени сьюта, ослабляйте deadline на шумном CI и цельтесь в roundtrip, а не в CRUD-клей.
У функции ценообразования было три года зелёных тестов: 10.00, 19.99, 0.00, кое-где граничный случай — одиннадцать вручную подобранных примеров, проходивших со дня написания. Потом кто-то добавил property-тест на десять строк: float-путь обязан сходиться с Decimal-путём бухгалтерии для любой цены. Первый запуск, четыре секунды, красный. Falsifying example — после ужимания тысяч случайных флоатов до простейшего, который всё ещё падает: net=2.675. В двоичной плавающей точке 2.675 хранится как 2.67499999999999982…, так что round(net, 2) даёт 2.67, а бухгалтерия, считающая в Decimal с ROUND_HALF_UP, говорит 2.68. Один цент расхождения на затронутый инвойс — умноженный примерно на 31 000 инвойсов в квартал и обнаруженный не тестом, а финансовым отделом, поднявшим четырёхзначный дрейф сверки. Три года тестов-примеров ни разу не пробовали границу в полцента, потому что инженеры, писавшие их, не знали, что эта граница особенная. Property-тест тоже не знал — он просто отказался принимать «работает на одиннадцати входах» за «работает», а ужатое репро поместилось в заголовок бага.
Свойства вместо примеров
Когда ты берёшься за property-тест вместо очередного вручную подобранного примера, ты делаешь ставку: что можешь сформулировать гарантию функции точнее, чем перечислить входы, которые важны. Эта ставка окупается чаще, чем ожидает большинство инженеров.
Тест-пример фиксирует поведение в точке: f(19.99) == expected. Свойство фиксирует поведение на пространстве: для всех x из некоторой области выполняется инвариант. Ремесло — в знании переиспользуемых форм инварианта, потому что почти каждое хорошее свойство — одна из четырёх. Roundtrip: parse(dump(x)) == x — рабочая лошадка для сериализаторов, кодеков, ORM. Идемпотентность: normalize(normalize(x)) == normalize(x) — для канонизаторов и чистящих проходов. Коммутативность / нечувствительность к порядку: merge(a, b) == merge(b, a) — для CRDT-образных слияний, операций над множествами. Оракул: оптимизированная реализация сходится с медленной, очевидно корректной эталонной — float-путь против Decimal-пути из вступления, новый парсер против старого при переписывании. Формулировка свойства задаёт вопрос острее, чем подбор примеров: не «что вернётся для 19.99», а «что эта функция на самом деле гарантирует» — и функции, чьи гарантии не формулируются, обычно и прячут баги.
@given и стратегии
from hypothesis import given, strategies as st
@given(st.lists(st.tuples(st.text(), st.integers())))
def test_roundtrip(rows):
assert parse(dump(rows)) == rows # инвариант — на всём пространстве
@given(st.builds(Order,
id=st.integers(min_value=1),
qty=st.integers(min_value=0, max_value=10**6)))
def test_total_non_negative(order):
assert order.total() >= 0@given превращает тест в свойство: Hypothesis вызывает его многократно (по умолчанию 100 раз) с входами из стратегий. st.integers, st.text, st.lists, st.floats покрывают примитивы с ограничениями; st.builds строит объекты, вызывая callable со сгенерированными именованными аргументами; @st.composite позволяет писать собственные стратегии с внутренними инвариантами (отсортированная пара, синтаксически корректный идентификатор). Стратегии намеренно враждебны: st.text() выдаёт пустые строки и экзотический Юникод, st.floats() без границ выдаёт nan и бесконечности, списки приходят пустыми и огромными. Эта враждебность и есть смысл: у генератора нет интуиции о «разумных» входах — ровно той интуиции, что три года не подпускала 2.675 к тестам-примерам.
Вы владеете и dump(), и parse() для CSV-подобного формата. Какое свойство отбивает свою цену первым?
Шринкинг и база примеров
Когда пример падает, Hypothesis не останавливается — он начинает работать. Шринкер раз за разом мутирует падающий вход в сторону «проще» — меньшие числа, более короткие списки и строки, меньше структурных элементов, — перезапуская тест и оставляя любой кандидат, который всё ещё падает, пока не достигнет локального минимума. Поэтому отчёт говорит Falsifying example: rows=[('', 0)], а не вываливает список из 400 элементов юникодного супа: минимальный кейс — разница между баг-репортом и археологической экспедицией, и это единственная фича, делающая провалы свойств читаемыми. Минимизированное падение затем записывается в базу примеров — директорию .hypothesis/ рядом с тестами, — и при каждом следующем запуске Hypothesis реплеит известные падения первыми, до генерации чего-либо нового. Локально это значит, что красное свойство остаётся детерминированно красным до починки: удача для воспроизведения не нужна. Честная оговорка про CI: база локальна и обычно в gitignore, так что падение, найденное на CI, в вашей локальной базе отсутствует. Для этого Hypothesis печатает декоратор @reproduce_failure(...) с падением (при настройке print_blob=True); вставьте его на тест локально — и воспроизведёте точный вход с CI. По-настоящему важные случаи повышайте до постоянных регрессионных тестов через @example(...) — они выполняются безусловно и навсегда.
Бюджеты: max_examples, deadline и реальность CI
Каждое свойство выполняется max_examples раз за вызов — по умолчанию 100. Арифметику бюджета стоит проделать один раз: 80 свойств × 100 примеров × 3 мс чистой логики ≈ 24 секунды — незаметно. Одно свойство, трогающее базу данных по 50 мс на пример, стоит 5 секунд само по себе: свойствам место на чистой логике, а тем немногим, кому законно нужен I/O, положен пониженный max_examples. Профили институционализируют это: settings.register_profile("ci", max_examples=1000) для ночного перемалывания, профиль «dev» на 25 примеров для внутреннего цикла, выбор через settings.load_profile(...). Вторая ручка кусает больнее: deadline, по умолчанию 200 мс на пример, поднимает DeadlineExceeded, когда один прогон затянулся. На нагруженном CI-раннере паузы GC и шумные соседи прожигают этот бюджет на коде без единого бага — классический репорт «property-тест флачит на CI». Сначала проверьте falsifying example (deadline честно ловит и настоящие случайно-квадратичные взрывы); если вход обычный — поднимите deadline или поставьте deadline=None для этого теста, а не глобально.
Stateful-тестирование, в одном честном абзаце
RuleBasedStateMachine обобщает свойства с функций на последовательности: вы объявляете правила (операции с предусловиями) и инварианты; Hypothesis составляет случайные последовательности операций, проверяет инварианты после каждого шага и при падении ужимает всю последовательность. Это инструмент, находящий «put, истечение TTL, конкурентный delete — и get возвращает призрачную запись» в кэшах, планировщиках и пулах соединений — баги, которые не выразить свойством одного вызова. Это же самая дорогая техника урока: рядом с реальной системой вы поддерживаете модель (упрощённую эталонную реализацию), падения читаются дольше, прогоны медленнее. Берите её для stateful-ядер с настоящей инвариантной структурой; не делайте её дефолтом.
Где property-тесты окупаются — а где нет
Окупаются там, где инварианты чёткие, а входы структурированы: парсеры и сериализаторы (roundtrip — проверка пары «сериализация + десериализация» на всём пространстве входов), кодеки и сжатие (roundtrip), денежная и размерностная арифметика (оракул против Decimal), нормализаторы и санитайзеры (идемпотентность), математика дат и таймзон, всё, у чего есть обратная функция. Не окупаются на тонком CRUD-клее: единственное доступное свойство для «хендлер зовёт репозиторий и возвращает строку» — пересказ мока, и генерация добавляет время выполнения, не добавляя силы фальсификации. Рабочая эвристика: если не можете сформулировать инвариант проще реализации — пишите тесты-примеры и идите дальше; свойство, перевыводящее реализацию, не проверяет ничего. Вместе эти две категории означают, что property-тесты концентрируют ценность в математическом ядре — кодировании, арифметике, парсинге — тогда как тесты-примеры несут CRUD-поверхность; путаница в любую сторону тратит время, не находя больше багов.
Свойство проходит локально, но на CI периодически падает с DeadlineExceeded; вход в отчёте выглядит совершенно обычным. Что происходит?
- 01Назовите четыре переиспользуемые формы инварианта с примером для каждой и скажите, где property-тесты окупаются, а где нет.
- 02Объясните, что происходит после падения свойства: шринкинг, база примеров, воспроизведение на CI и две бюджетные ручки.
Property-based тестирование заменяет «работает на входах, которые мы придумали» на «заявленный инвариант держится на пространстве входов» — и четыре формы инварианта делают большую часть работы: roundtrip для всего с обратной функцией, идемпотентность для нормализаторов, коммутативность для слияний и оракул, когда существует медленная корректная реализация, с которой можно разойтись, — так десятистрочное свойство поймало полуцентовый дрейф на 2.675, который одиннадцать вручную подобранных примеров пропускали три года. Стратегии генерируют враждебные входы по дизайну — NaN, пустые строки, юникодный суп, — потому что у генератора нет ровно той интуиции о «разумных входах», которая и создаёт слепые зоны. Фича, делающая всё это пригодным к эксплуатации, — шринкинг: падения минимизируются до простейшего входа, который всё ещё падает, репортятся однострочным falsifying example, сохраняются в локальной базе примеров и реплеятся первыми при следующем запуске — детерминированное репро локально, @reproduce_failure для импорта падения с CI, @example для вечной фиксации регрессий. Операционно: проделайте арифметику бюджета — max_examples по умолчанию 100 на свойство и перемножается по сьюту, — держите свойства на чистой логике, ослабляйте 200-миллисекундный deadline, когда шум CI маскируется под провал, и приберегите stateful-машины для ядер, чьи инварианты оправдывают содержание модели. Тратьте свойства там, где инвариант проще реализации; везде остальном примеры дешевле и достаточно честны. Теперь, когда встречаешь расхождение float и Decimal в финансовом отчёте или кодек, молча ломающий экзотический Юникод, — первый шаг это десятистрочное roundtrip- или oracle-свойство, а не ещё один вручную подобранный пример, который пропустит ту же границу, что пропустили предыдущие одиннадцать.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.