100 прогонов — это ложь: как на самом деле определять размер property-based тестов
Если вы запускаете property-based тесты со стандартными 100 примерами, вы получаете худшее из двух миров. Ваш CI медленнее, чем нужно, и вы всё равно не ловите важные баги.
Это число не волшебное. Большинство библиотек, включая Hypothesis, по умолчанию используют 100, потому что это круглое число, которое кажется безопасным. Но «кажется безопасным» — это не стратегия тестирования.
Что на самом деле обещает property-based testing
Property-based testing переворачивает подход к юнит-тестам. Вместо ручного написания входных данных и ожидаемых результатов вы определяете property — правило, которое всегда должно выполняться. Фреймворк генерирует входные данные, чтобы его нарушить.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
assert lst == list(reversed(list(reversed(lst))))
Фреймворк запускает эту функцию много раз со случайными списками целых чисел. Если он находит контрпример, он выполняет shrinking входных данных до минимальной версии, которая всё ещё падает. Список из 47 элементов, вызывающий баг, бесполезен для отладки. Список из 3 элементов — золото.
Это мощно. Но это также вероятностно. Property-based testing не может доказать корректность. Он может только повысить вашу уверенность в том, что бага не существует, или найти баг, если он есть. Именно эта вероятностная природа делает количество прогонов таким важным.
Почему 100 — произвольное число
Давайте честно о том, откуда взялась 100. В Hypothesis это значение по умолчанию было выбрано в 2015 году, потому что это было красивое круглое число, которое ловило большинство багов, не делая тесты невыносимо медленными. Это был социальный компромисс, а не статистический.
Вероятность нахождения бага зависит от двух вещей. Насколько часто он встречается в пространстве входных данных, и сколько образцов вы берёте. Если баг срабатывает только тогда, когда входные данные — палиндром длиной больше 20, а палиндромы составляют 0,01% от всех списков, 100 прогонов дают примерно 1% шанс поймать его. Это не тест. Это лотерейный билет.
Большинство багов не такие редкие. Многие properties ломаются на пустых списках, одиночных элементах или простых дубликатах. Хорошо настроенный генератор быстро их ловит. Но значение по умолчанию в 100 предполагает, что ваши генераторы идеальны, а баги мелкие. Обе предпосылки неверны.
Что количество прогонов реально даёт вам статистически
Если мы моделируем обнаружение бага как выборку с возвращением из пространства входных данных, где баг имеет вероятность p появления, вероятность пропустить баг после n прогонов равна (1 - p)^n.
При p = 0,01 100 прогонов дают 37% шанс пропустить баг. При p = 0,001 100 прогонов дают 90% шанс пропустить его. Чтобы получить 99% уверенности в ловле бага с вероятностью 0,1%, нужно около 4600 прогонов.
import math
def runs_for_confidence(p, confidence=0.99):
"""Returns the runs needed to catch a bug with probability `p`
at the given confidence level."""
return math.ceil(math.log(1 - confidence) / math.log(1 - p))
print(runs_for_confidence(0.01)) # 459
print(runs_for_confidence(0.001)) # 4603
print(runs_for_confidence(0.0001)) # 46050
Это та часть, которая заставляет людей чувствовать дискомфорт. Если вы хотите высокой уверенности в редких багах, вам нужны десятки тысяч прогонов. Никто не хочет ждать так долго в CI.
Shrinking меняет уравнение стоимости
Значение по умолчанию в 100 прогонов было установлено до того, как shrinking стал таким хорошим, как сегодня. Современные фреймворки property-based testing не просто находят баги. Они находят минимальные баги.
Это значит, что вы можете думать в терминах бюджета, а не только количества. Если вы запускаете 1000 примеров и находите баг на 847-м прогоне, shrinking может занять ещё 200–300 выполнений, чтобы минимизировать контрпример. Общая стоимость — 1100 и более прогонов на один баг. Но если вы запускаете 10 000 примеров и ничего не находите, вы тратите 10 000 прогонов на спокойствие души.
Фокус в том, чтобы разделить обнаружение и валидацию. Запускайте маленький быстрый набор в CI для немедленной обратной связи. Запускайте большой медленный набор ночью или на release-ветках для более глубокой уверенности.
from hypothesis import given, settings, strategies as st
import json
# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
assert json.loads(json.dumps(d)) == d
# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
assert json.loads(json.dumps(d)) == d
Это не только о скорости. Это о плотности информации. Тест из 100 прогонов, который проходит, почти ничего не говорит. Тест из 5000 прогонов, который проходит, говорит чуть больше. Тест из 100 прогонов, который падает, точно говорит, где искать.
Как мы разбиваем property-based тесты на уровни
По нашему опыту, лучший подход — перестать считать все properties равными. Мы разбиваем их на три уровня.
Быстрые properties запускаются на каждом pull request. Это механические проверки. Round-trip сериализация, идемпотентность дедупликации, базовые инварианты структур данных. Мы запускаем 100–200 примеров. Они завершаются менее чем за секунду.
Глубокие properties запускаются на каждом merge в main. Они нацелены на сложные state machines, конвейеры обработки событий и всё, где есть комбинаторный взрыв. Мы запускаем 2000–10 000 примеров. Они занимают минуты, а не часы.
Исследовательские properties запускаются вручную перед релизами. Это те, где мы выкручиваем max_examples до 50 000 и более и позволяем машине работать, пока мы изучаем changelog. Таким образом мы находили race conditions и крайние случаи integer overflow, которые никакое количество юнит-тестов не поймало бы.
Что делать вместо угадывания
Перестаньте относиться к max_examples как к ручке, которую вы один раз настроили и забыли. Относитесь к ней как к конфигурации, принадлежащей property, а не фреймворку.
Задавайте три вопроса для каждого property, которое вы пишете.
Насколько дорого запускать этот тест? Если каждый пример занимает 50 мс, 10 000 прогонов — это 8 минут. Если 5 мс — меньше минуты.
Насколько плох баг, если мы его пропустим? Баг форматирования в сообщении лога — это не то же самое, что баг повреждения данных в платёжном конвейере.
Насколько редко срабатывает условие? Если баг проявляется только в високосные годы, или когда два UUID сталкиваются, или точно на INT_MAX, вам нужно больше прогонов или более умный генератор.
Умные генераторы почти всегда побеждают большее количество прогонов. Если вы тестируете JSON-парсер, не генерируйте случайные строки в надежде, что они распарсятся. Генерируйте валидные объекты, а затем мутируйте их.
from hypothesis import given, settings, strategies as st
import json
# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
try:
json.loads(s)
except json.JSONDecodeError:
pass # Most inputs hit this immediately
# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
assert json.loads(json.dumps(d)) == d
500 прогонов с хорошим генератором побеждают 10 000 прогонов с плохим. Всегда.
Частые вопросы о размере property-based тестов
Не означает ли большее количество прогонов всегда лучшее покрытие?
Не совсем. Property-based testing не имеет метрики покрытия в традиционном смысле. Больше прогонов увеличивает вероятность нахождения бага, но эффект убывающей отдачи наступает быстро. Удвоение с 100 до 200 прогонов имеет смысл. Удвоение с 10 000 до 20 000 — редко.
А как насчёт fuzzing? Разве это не просто property-based testing с миллионами прогонов?
Fuzzing близок, но отличается. Фаззеры обычно запускают миллионы входных данных без семантического понимания предметной области. Property-based testing использует структурированные генераторы и shrinking. Можно думать о PBT как о smart fuzzing, или о fuzzing как о brute-force PBT. Исчисление количества прогонов другое, потому что стоимость за прогон и информация за прогон не одинаковы.
Должен ли я выставлять max_examples выше для CI или ниже?
Выше для CI, ниже для локальной разработки. Ваш ноутбук — для скорости. Ваш CI — для уверенности. Используйте профиль настроек или переменную окружения, чтобы переключаться между ними.
import os
from hypothesis import settings
CI = os.environ.get("CI", "false").lower() == "true"
settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)
settings.load_profile("ci" if CI else "dev")
Как понять, достаточно ли хорош мой генератор?
Запустите тест с очень высоким max_examples, скажем, 50 000, и следите за отчётом о покрытии. Если ветки отсутствуют, ваш генератор не покрывает их. Исправьте генератор, прежде чем снижать количество прогонов.
Перестаньте искать идеальное количество прогонов и начните измерять
Нет универсально правильного количества прогонов тестов для property-based testing. Есть только правильное число для вашего property, ваших генераторов, вашего бюджета CI и стоимости бага, который вы пытаетесь предотвратить.
Начинайте с 100, если нужно. Но увеличивайте для properties, которые охраняют критические пути, и уменьшайте для properties, которые являются просто sanity checks. Измеряйте, сколько времени занимают ваши тесты. Профилируйте ваши генераторы. И помните: property-based test, который проходит 100 раз, — это не доказательство. Это лишь свидетельство.
Если хотите углубиться, стоит прочитать документацию Hypothesis по статистике тестов и targeted property-based testing. CLI hypothesis может показать, на каких примерах ваши тесты тратят время. Это первое место, куда стоит заглянуть, когда вы решаете, крутить ручку вверх или вниз.