Property-тест упал, и в отчёте — список из 847 случайных чисел, половина отрицательные, и где-то среди них одно проблемное. Отлаживать по такому невозможно, непонятно, что ломает функцию, а что просто шум. Но Hypothesis показывает не его, а крошечный пример — скажем, [0, 0] или строку “0”. Между падением на грязном входе и этим аккуратным минимумом работает shrinking, и устроен он очень интересно.
Посмотрим, как: почему наивный способ плох, что такое интегрированный shrinking, и заодно соберём его маленькую версию строк на двадцать.
Зачем вообще ужимать
В случайном контрпримере полно лишнего. В списке из 847 элементов баг могут вызывать два, а остальные 845 — балласт, который только мешает читать. Ужать контрпример — значит найти простейший вход, на котором свойство всё ещё падает: выкинуть лишнее, прижать числа к нулю, строки к пустым, оставить ровно то, без чего падение пропадает.
По хорошему минимуму обычно сразу видно, в чём дело: [0, 0] для функции, спотыкающейся на дубликатах, или пустая строка для забытого края.
Наивный способ и где он буксует
Классический QuickCheck ужимает значения по типу. Для каждого типа пишется функция shrink, которая возвращает упрощённые версии: для целого — числа поближе к нулю, для списка — варианты с выкинутыми и укороченными элементами. Фреймворк подставляет этих кандидатов, пока находит падающих.
Буксует это на композиции. Генератор почти никогда не отдаёт сырой тип напрямую, его преобразуют: целое превращают в дату, список пар собирают в объект, строку фильтруют по формату. А shrink написан для исходного типа и про преобразование ничего не знает. Стоит наложить map, и связь между значением и его shrinker рвётся: либо минимизация производного значения теряется, либо под каждое преобразование приходится писать новый shrinker руками. Отсюда и вечная возня с ручными shrink-функциями, и то, что они ломаются при рефакторинге генераторов.
Идея интегрированного shrinking: ужимать источник случайности
Hypothesis может зайти с другой стороны. Любое сгенерированное значение — это функция от внутреннего потока байтов, который тянет генератор: и целое, и список, и объект строятся, читая «примитивы» из этого буфера. Раз так, ужимать можно не значение, а сам буфер: сделать его проще — короче и с меньшими байтами, потом заново прогнать генератор и проверить, падает ли свойство всё ещё.
Главное следствие: минимизация композируется сама собой. Какие бы map, filter и builds ни стояли поверх генератора, значение остаётся функцией того же буфера, поэтому упрощение буфера упрощает и производное значение. Ручных shrinker нет вовсе, есть один алгоритм, работающий на уровне источника случайности.
Маленькая реализация
Соберём интегрированную минимизацию целиком. Источник случайности — буфер байтов, из которого генератор тянет примитивы; генератор строит список целых, читая «байт-продолжатель» и значения.
class Source: """Источник случайности: генератор тянет байты отсюда.""" def __init__(self, buf): self.buf, self.i = list(buf), 0 def draw(self): b = self.buf[self.i] if self.i < len(self.buf) else 0 self.i += 1 return b def gen_list(src): """Список целых: пока байт-продолжатель нечётный — рисуем ещё элемент.""" xs = [] while src.draw() % 2 == 1: xs.append(src.draw()) return xs
Свойство, которое мы нечаянно нарушим: в списке не должно быть элемента не меньше пяти.
def fails(xs): return any(x >= 5 for x in xs) # баг: значение >= 5 встречаться не должно
Минимизация работает только с буфером. Кандидаты «проще» — это удалить байт, уменьшить байт на единицу или обнулить его; каждый кандидат прогоняется через генератор заново.
def candidates(buf): for i in range(len(buf)): # удалить байт yield buf[:i] + buf[i + 1:] for i in range(len(buf)): # уменьшить байт на единицу if buf[i] > 0: yield buf[:i] + [buf[i] - 1] + buf[i + 1:] for i in range(len(buf)): # обнулить байт if buf[i] > 0: yield buf[:i] + [0] + buf[i + 1:] def simpler(a, b): # порядок shortlex return (len(a), a) < (len(b), b) def still_fails(buf): return fails(gen_list(Source(buf))) def shrink(buf): progress = True while progress: # жадный спуск до фикспоинта progress = False for cand in candidates(buf): if simpler(cand, buf) and still_fails(cand): buf, progress = cand, True break # начинаем проходы заново от более простого буфера return buf
Запустим на грязном случайном буфере:
import random random.seed(0) start = [random.randint(0, 9) for _ in range(40)] print(gen_list(Source(start))) # длинный список со случайными значениями minimal = shrink(start) print(gen_list(Source(minimal))) # [5] — минимальный список, всё ещё нарушающий свойство
Спуск удаляет лишние байты, и список укорачивается до одного элемента; уменьшает байт значения, пока остаётся не меньше пяти — на четвёрке падение пропадает, и шаг откатывается. На выходе [5]. Мы ни разу не написали shrinker для списка: ужали байты, регенерируя значение, и минимальное значение получилось само. Накинь поверх gen_list любое преобразование — минимизация продолжит работать, потому что упрощается источник, а не результат.
Полезно увидеть спуск по шагам. Если логировать принятые упрощения, выходит примерно так:
исходный буфер длины 40 -> список из 11 элементов, среди них есть 8 удаление байтов -> [8] (буфер сжался до пары байт) понижение значения 8 -> 7 -> [7] понижение 7 -> 6 -> [6] понижение 6 -> 5 -> [5] понижение 5 -> 4 -> свойство НЕ нарушается — шаг откатывается
Сначала удаляются целые куски буфера, и список схлопывается до одного элемента, потом единственное значение жадно понижается, пока остаётся не меньше пяти. Откат на четвёрке фиксирует границу: [5] — локальный минимум, из которого ни одно простое упрощение не ведёт к более простому падающему входу.
В реальном Hypothesis проходов больше, чем три в нашем мелком примере, но идея та же — структурные правки буфера, применяемые жадно до фикспоинта. Удаление участков выкидывает элементы списков и опциональные части, обнуление гонит числа к нулю, а строки к пустоте, понижение уменьшает отдельные числовые блоки, есть сортировка и дедупликация блоков, замена участка на меньший, удаление крупными кусками с откатом, если перестало падать. Каждый проход — это «поправить буфер, перегенерировать значение, принять, если всё ещё падает и стало проще, иначе откатить». В сумме получается спуск по буферам к локальному минимуму в порядке shortlex.
Плюсом минимизация не бесплатна: каждый кандидат — это отдельный прогон свойства, и на пути к минимуму их бывают сотни. Для быстрой функции это незаметно, для медленной — съедает большую часть времени теста, и отсюда растут deadline и ограничения на усилие минимизации.
Фазы движка: повтор, генерация, минимизация, объяснение
Минимизация в Hypothesis — лишь одна из фаз, и их порядок объясняет, почему падения стабильны, а минимум приходит не сразу. Сначала фаза повтора проигрывает контрпримеры из локальной базы и заданные через @example, так что уже найденный баг ловится мгновенно, не дожидаясь удачи генератора. Потом фаза генерации перебирает новые случайные входы. Нашли падение — включается минимизация, тот самый спуск по буферу.
Если задан target, отдельная фаза двигает генерацию к экстремальным значениям метрики. Наконец, фаза объяснения пытается показать, какая часть входа отвечает за падение, помечая участки буфера, варьирование которых падение сохраняет.
База контрпримеров связывает фазы между запусками: ужатый буфер сохраняется, и в следующий раз фаза повтора поднимает уже минимальный вход. Один раз пойманный и сжатый баг не теряется и не возвращается в грязном виде, он становится детерминированным регрессионным входом, который проигрывается первым.
Если совсем коротко
Весь shrinking держится на одном решении — ужимать не значение, а буфер случайности, из которого оно собирается. Поэтому минимизация бесплатно проходит сквозь любые map и filter, а простые буферы дают понятные минимумы: нули, пустые строки, короткие списки.
Алгоритм при этом простой — жадный спуск к простейшему падающему буферу, с оглядкой на то, чтобы не соскользнуть в другой баг, и с бюджетом на повторные прогоны. А если минимум получился странный, чините стратегию: дело почти всегда в ней.
Больше о тестировании читайте в статьях:
Почему классический подход к QA больше не работает (и виновата ли в этом эпоха ИИ)
Claude против краевых случаев: как LLM-агент нашёл баги в NumPy и других Python-библиотеках
Автоматический поиск ошибок полезен не только в property-based testing: похожие идеи работают в фаззинге, тестировании API и других подходах, где входные данные становятся способом находить редкие и трудноуловимые баги. На бесплатных открытых уроках можно посмотреть эти инструменты в деле, познакомиться с преподавателями-практиками и задать вопросы по своим кейсам.
2 июля, 20:00. «REST Assured & JSON Schema Validator: автоматизация тестирования API на практике». Записаться
Разберём, как автоматизировать проверки API и валидировать ответы сервисов с помощью JSON Schema.23 июля, 20:00. «Фаззинг и реверс: как понять, что делает программа, найти в ней ошибки». Записаться
Поговорим о поиске уязвимостей и скрытых дефектов через фаззинг и анализ поведения программ.
Больше бесплатных уроков июня смотрите в дайджесте.
