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Интегрированные алгоритмы редукцииОтлично
QuickCheckShrinking на основе типаОтлично
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 приходит от простого начала, инкрементальной идентификации значимых свойств и комбинирования тестов, основанных на свойствах, с тщательно выбранными тестами, основанными на примерах, для покрытия регрессий и читаемости.