Property-Based Testing (PBT) революционизирует то, как мы думаем о тестовых случаях. Вместо ручного создания отдельных примеров, PBT определяет свойства, которые всегда должны быть истинными, и автоматически генерирует сотни тестовых случаев для проверки этих свойств. Этот подход обнаруживает граничные случаи, которые разработчики редко предвидят.
Что такое Property-Based Testing?
Property-Based Testing фокусируется на спецификации инвариантов—правил, которые всегда должны быть истинными независимо от входных данных—вместо проверки конкретных пар вход-выход. Фреймворк property-based testing генерирует случайные входные данные, запускает тестируемый код и проверяет, что свойства выполняются.
Основные Концепции
Свойство: Утверждение о системе, которое должно выполняться для всех корректных входных данных.
Генератор: Функция, которая производит случайные тестовые данные, соответствующие специфическим ограничениям.
Shrinking: Когда обнаруживается неудачный тест, фреймворк автоматически упрощает вход, чтобы найти минимальный неудачный случай.
Инвариант: Условие, которое остается истинным на протяжении выполнения программы или через трансформации.
Свойства vs. Тесты Основанные на Примерах
Традиционное Тестирование Основанное на Примерах
def test_reverse_list():
assert reverse([1, 2, 3]) == [3, 2, 1]
assert reverse([]) == []
assert reverse([42]) == [42]
Ограничения: Только проверяет три конкретных случая; может упустить граничные случаи, такие как большие списки, дубликаты или необычные значения.
Подход Property-Based
from hypothesis import given
import hypothesis.strategies as st
@given(st.lists(st.integers()))
def test_reverse_property(lst):
# Свойство: Разворот дважды возвращает оригинал
assert reverse(reverse(lst)) == lst
# Свойство: Длина сохраняется
assert len(reverse(lst)) == len(lst)
# Свойство: Первый элемент становится последним
if lst:
assert reverse(lst)[0] == lst[-1]
Преимущества: Проверяет сотни случайных списков автоматически; обнаруживает неожиданные граничные случаи.
Фреймворки Property-Based Testing
Hypothesis (Python)
Hypothesis - самый зрелый PBT фреймворк для Python, предлагающий сложные стратегии и превосходный shrinking.
from hypothesis import given, strategies as st, assume
@given(st.integers(), st.integers())
def test_addition_commutative(a, b):
# Свойство: Сложение коммутативно
assert a + b == b + a
@given(st.lists(st.integers(), min_size=1))
def test_max_is_in_list(lst):
# Свойство: Максимальное значение должно быть в списке
assert max(lst) in lst
@given(st.text())
def test_encode_decode(s):
# Свойство: Кодирование затем декодирование возвращает оригинал
encoded = s.encode('utf-8')
decoded = encoded.decode('utf-8')
assert s == decoded
QuickCheck (Haskell)
Оригинальный фреймворк property-based testing, который вдохновил все остальные.
-- Свойство: Reverse - свой собственный обратный
prop_reverseInverse :: [Int] -> Bool
prop_reverseInverse xs = reverse (reverse xs) == xs
-- Свойство: Отсортированный список имеет все исходные элементы
prop_sortPreservesElements :: [Int] -> Bool
prop_sortPreservesElements xs =
sort xs `sameElements` xs
where
sameElements a b = sort a == sort b
-- Свойство: Добавление затем взятие длины суммирует длины
prop_appendLength :: [Int] -> [Int] -> Bool
prop_appendLength xs ys =
length (xs ++ ys) == length xs + length ys
fast-check (JavaScript/TypeScript)
Property-based testing для экосистемы JavaScript с поддержкой TypeScript.
import fc from 'fast-check';
// Свойство: Array map сохраняет длину
fc.property(
fc.array(fc.integer()),
fc.func(fc.integer()),
(arr, f) => arr.map(f).length === arr.length
);
// Свойство: Round-trip сериализации JSON
fc.property(
fc.anything(),
(value) => {
const serialized = JSON.stringify(value);
const deserialized = JSON.parse(serialized);
return fc.stringify(value) === fc.stringify(deserialized);
}
);
JSVerify (JavaScript)
Более ранняя библиотека JavaScript PBT, проще, но менее богатая по возможностям чем fast-check.
const jsc = require('jsverify');
// Свойство: String split затем join возвращает оригинал
const splitJoinProperty = jsc.forall(
jsc.string,
jsc.string,
(str, sep) => {
if (sep === '') return true; // Пропустить пустой разделитель
return str.split(sep).join(sep) === str;
}
);
jsc.assert(splitJoinProperty);
Генераторы и Стратегии
Генераторы производят случайные тестовые данные, ограниченные специфическими типами и диапазонами.
Встроенные Генераторы
from hypothesis import strategies as st
# Базовые типы
st.integers() # Любое целое
st.integers(min_value=0, max_value=100) # Диапазон 0-100
st.floats() # Любой float
st.text() # Unicode строки
st.booleans() # True/False
# Коллекции
st.lists(st.integers()) # Списки целых
st.sets(st.text(), min_size=1) # Непустые множества строк
st.dictionaries(st.text(), st.integers()) # Словари String->Int
st.tuples(st.integers(), st.text()) # Кортежи фиксированного размера
# Опционалы и выборы
st.none() # Всегда None
st.one_of(st.integers(), st.text()) # Либо int либо string
Пользовательские Генераторы
from hypothesis import strategies as st
from hypothesis.strategies import composite
# Генерация корректных email адресов
@composite
def email_strategy(draw):
username = draw(st.text(
alphabet=st.characters(whitelist_categories=('Ll', 'Nd')),
min_size=1,
max_size=20
))
domain = draw(st.text(
alphabet=st.characters(whitelist_categories=('Ll',)),
min_size=1,
max_size=15
))
tld = draw(st.sampled_from(['com', 'org', 'net', 'edu']))
return f"{username}@{domain}.{tld}"
@given(email_strategy())
def test_email_validation(email):
assert '@' in email
assert '.' in email.split('@')[1]
Композиция Генераторов
# Генерация корзины покупок с реалистичными ограничениями
@composite
def shopping_cart_strategy(draw):
num_items = draw(st.integers(min_value=0, max_value=50))
items = draw(st.lists(
st.tuples(
st.text(min_size=1), # Название продукта
st.integers(min_value=1, max_value=10), # Количество
st.floats(min_value=0.01, max_value=1000.00) # Цена
),
min_size=num_items,
max_size=num_items
))
return {'items': items}
Shrinking: Минимальные Неудачные Случаи
Когда свойство не выполняется, shrinking автоматически уменьшает вход до наименьшего случая, который все еще не выполняется.
Пример: Shrinking в Действии
@given(st.lists(st.integers()))
def test_no_duplicates(lst):
# Это свойство ложно - списки МОГУТ иметь дубликаты
assert len(lst) == len(set(lst))
Начальный сбой: [0, -1, 3, 0, -5, 2]
После shrinking: [0, 0]
Фреймворк автоматически уменьшает неудачный случай со списка из 6 элементов до минимального дубликата из 2 элементов.
Стратегии Shrinking
Фреймворк | Подход Shrinking | Качество |
---|---|---|
Hypothesis | Интегрированные алгоритмы редукции | Отлично |
QuickCheck | Shrinking на основе типа | Отлично |
fast-check | Пользовательский shrinking на генератор | Очень Хорошо |
JSVerify | Базовый shrinking | Хорошо |
Общие Паттерны Свойств
Обратные Функции
Функции, которые отменяют друг друга, должны выполнять идеальный round-trip.
@given(st.text())
def test_base64_roundtrip(s):
import base64
encoded = base64.b64encode(s.encode('utf-8'))
decoded = base64.b64decode(encoded).decode('utf-8')
assert s == decoded
Идемпотентность
Применение операции несколько раз имеет тот же эффект, что и применение один раз.
@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
# Сортировка дважды равна сортировке один раз
assert sorted(sorted(lst)) == sorted(lst)
@given(st.sets(st.integers()))
def test_set_idempotent(s):
# Преобразование в set дважды равно один раз
assert set(set(s)) == set(s)
Инварианты
Определенные свойства остаются истинными через трансформации.
@given(st.lists(st.integers()))
def test_filter_preserves_order(lst):
filtered = [x for x in lst if x > 0]
# Порядок отфильтрованных элементов совпадает с исходным
original_positives = [x for x in lst if x > 0]
assert filtered == original_positives
@given(st.dictionaries(st.text(), st.integers()))
def test_dict_keys_values_match(d):
# Keys и values поддерживают соответствие
assert len(d.keys()) == len(d.values())
for key in d.keys():
assert key in d
Сравнение с Оракулом
Сравнение реализации с более простой (но более медленной) эталонной.
def quicksort(lst):
# Быстрая, но сложная реализация
if len(lst) <= 1:
return lst
pivot = lst[0]
left = [x for x in lst[1:] if x < pivot]
right = [x for x in lst[1:] if x >= pivot]
return quicksort(left) + [pivot] + quicksort(right)
@given(st.lists(st.integers()))
def test_quicksort_matches_builtin(lst):
# Сравнить со встроенной sort Python
assert quicksort(lst) == sorted(lst)
Метаморфические Отношения
Связывание выходных данных для различных входных без знания точного ожидаемого выхода.
@given(st.lists(st.integers()), st.integers())
def test_search_after_insert(lst, value):
# Если мы вставим значение, поиск его должен быть успешным
lst_with_value = lst + [value]
assert value in lst_with_value
Property Testing с Состоянием
Тестирование систем с состоянием путем генерации последовательностей операций.
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
import hypothesis.strategies as st
class BankAccountMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.balance = 0
@rule(amount=st.integers(min_value=1, max_value=1000))
def deposit(self, amount):
self.balance += amount
@rule(amount=st.integers(min_value=1, max_value=1000))
def withdraw(self, amount):
if amount <= self.balance:
self.balance -= amount
@invariant()
def balance_never_negative(self):
assert self.balance >= 0
# Запустить тесты с состоянием
TestBankAccount = BankAccountMachine.TestCase
Пример Hypothesis Stateful Testing
class QueueMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.queue = []
@rule(value=st.integers())
def enqueue(self, value):
self.queue.append(value)
@rule()
def dequeue(self):
if self.queue:
return self.queue.pop(0)
@invariant()
def queue_fifo_order(self):
# Элементы поддерживают FIFO порядок
assert self.queue == self.queue # Упрощенный инвариант
Assumptions и Предусловия
Использование assume()
для фильтрации сгенерированных входных данных до корректных сценариев.
from hypothesis import given, assume
import hypothesis.strategies as st
@given(st.integers(), st.integers())
def test_division(a, b):
assume(b != 0) # Пропустить случаи где b равно нулю
result = a / b
assert result * b == a # В пределах точности с плавающей точкой
Предупреждение: Чрезмерное использование assume()
может сделать тесты неэффективными, отбрасывая много сгенерированных входных данных.
Преимущества Property-Based Testing
Обнаруживает Неожиданные Граничные Случаи
PBT находит баги, которые разработчики не предвидят.
Пример из Практики: Hypothesis обнаружил баг обработки Unicode в продакшн JSON парсере, который имел 95% покрытие строк из тестов, основанных на примерах.
Служит Исполняемой Спецификацией
Свойства документируют поведение системы более всесторонне, чем примеры.
Уменьшает Обслуживание Тестов
Свойства остаются корректными, когда меняются детали реализации.
Пример ROI: 50% сокращение обновлений тестов во время рефакторинга по сравнению с тестами, основанными на примерах.
Дополняет Тесты Основанные на Примерах
Использовать PBT для сложной логики; использовать примеры для конкретных регрессионных тестов и читаемости.
Вызовы и Ограничения
Написание Хороших Свойств
Идентификация значимых свойств требует практики и глубокого понимания.
Смягчение: Начинать с простых свойств (round-trip, идемпотентность); добавлять сложные инварианты постепенно.
Производительность
Генерация и запуск сотен тестовых случаев занимает больше времени, чем тесты, основанные на примерах.
Смягчение: Настроить количество примеров; использовать CI для всесторонних прогонов, меньше примеров локально.
from hypothesis import settings
@settings(max_examples=1000) # По умолчанию 100
@given(st.lists(st.integers()))
def test_with_more_examples(lst):
assert len(lst) >= 0
Недетерминированные Системы
Системы с внешними зависимостями или случайностью сложнее тестировать со свойствами.
Смягчение: Использовать stateful тестирование с замокированными зависимостями; тестировать на границах интеграции.
Лучшие Практики
Начинать с Простых Свойств
Начинать с универсально применимых свойств.
# Простое: Сохранение типа
@given(st.lists(st.integers()))
def test_map_preserves_length(lst):
assert len(list(map(lambda x: x * 2, lst))) == len(lst)
Комбинировать Множественные Свойства
Тестировать несколько свойств в одном тесте для всестороннего покрытия.
@given(st.lists(st.integers()))
def test_sorting_properties(lst):
sorted_lst = sorted(lst)
# Свойство 1: Длина сохранена
assert len(sorted_lst) == len(lst)
# Свойство 2: Все элементы присутствуют
assert set(sorted_lst) == set(lst)
# Свойство 3: Отсортировано корректно
for i in range(len(sorted_lst) - 1):
assert sorted_lst[i] <= sorted_lst[i + 1]
Использовать Декораторы Example для Регрессий
Закреплять конкретные неудачные случаи как примеры, сохраняя property тесты.
from hypothesis import given, example
import hypothesis.strategies as st
@given(st.lists(st.integers()))
@example([]) # Гарантировать что пустой список всегда тестируется
@example([1, 2, 3]) # Закрепить конкретный регрессионный случай
def test_reverse(lst):
assert reverse(reverse(lst)) == lst
Настроить Таймауты Соответственно
from hypothesis import settings
import hypothesis.strategies as st
@settings(deadline=500) # 500ms на тестовый случай
@given(st.lists(st.integers(), max_size=10000))
def test_large_lists(lst):
process(lst)
Применение в Реальном Мире
Библиотека Парсинга JSON
JSON библиотека использует PBT для проверки round-trip сериализации для всех типов данных.
Результаты: Обнаружено 3 граничных случая в обработке Unicode и точности с плавающей точкой.
Валидация Алгоритма Сортировки
Сортировка на основе сравнения, протестированная против встроенной сортировки как оракула.
Результаты: 100% уверенность в корректности через 10,000 сгенерированных входных данных на прогон теста.
Валидация API Request
Валидатор REST API протестирован со сгенерированными payload, соответствующими ограничениям схемы.
Результаты: Найдено 5 граничных случаев в валидации вложенных объектов, которые упустили ручные тесты.
Заключение
Property-Based Testing смещает фокус тестирования с отдельных примеров на универсальные истины о поведении системы. Хотя требуется другой образ мышления, чем традиционное тестирование, основанное на примерах, PBT обеспечивает превосходное обнаружение граничных случаев и служит живой документацией инвариантов системы.
Успех с PBT приходит от простого начала, инкрементальной идентификации значимых свойств и комбинирования тестов, основанных на свойствах, с тщательно выбранными тестами, основанными на примерах, для покрытия регрессий и читаемости.