Ваши юнит-тесты покрывают только те случаи, которые вы вспомнили

Вы написали функцию reverse. Вы протестировали её с [1, 2, 3] и [4, 5, 6, 7]. Тест проходит. Вы выкатываете в продакшн.

Пользователь передаёт срез из одного элемента. Ваша функция теряет его. Он создаёт issue. Вы смотрите на файл с тестами и удивляетесь, как вы могли пропустить что-то такое очевидное.

Вы пропустили его, потому что тестирование на примерах ловит только те баги, которые вы предвидите. Каждый assert_eq! в вашем тестовом наборе — это догадка о том, где может скрываться ошибка. Тестирование на основе свойств заменяет эти догадки случайными данными и математическими инвариантами. Оно найдёт баг с одним элементом. Оно также найдёт баг с пустым вектором, граничный случай переполнения целого числа и случай с Unicode, о существовании которого вы не знали.

Случайные входные данные и инварианты, а не примеры и ожидания

Тестирование на основе свойств меняет подход. Вместо того чтобы выбирать входные данные и проверять точные выходные, вы определяете свойство, которое должно выполняться для всех входных данных, а тестовый фреймворк генерирует сотни случайных входных данных, чтобы попытаться его нарушить.

Классический пример — реверс списка. Свойство звучит не так: «реверс [1, 2, 3] равен [3, 2, 1]». Свойство звучит так: «двойной реверс списка возвращает исходный список». Этот инвариант выполняется для любого возможного списка, поэтому фреймворк кидает в него случайные списки, пока что-то не сломается или не достигнут лимит итераций.

В Rust есть две зрелые библиотеки для этого. quickcheck была первой популярной опцией, портированной из Haskell. proptest — более новый подход с решающей фичей: automatic shrinking. Когда proptest находит входные данные, нарушающие свойство, он не просто выводит вектор из сотни элементов и оставляет вас с отладкой. Он упрощает входные данные, пока не найдёт минимально возможный контрпример. Это разница между чтением вывода упавшего теста и реальным пониманием бага.

Как shrinking находит настоящий баг

Shrinking — это секретный соус. Допустим, proptest генерирует случайный вектор из 47 целых чисел, и ваше свойство падает. Баг не в 47-м элементе. Баг, скорее всего, связан с пустыми векторами, сменой знака или дублирующимися значениями.

proptest это знает. Он пробует удалять элементы. Он пробует обнулять значения. Он пробует делать вектор всё меньше и меньше. Если тест всё ещё падает с меньшими входными данными, он продолжает shrinking. Он останавливается, когда каждая более простая вариация проходит. Результат — минимальный контрпример.

Это не просто приятная фича. Это причина, по которой тестирование на основе свойств применимо на практике. Без shrinking вы снова отлаживаете стог сена. С ним вы получаете иголку.

Реальный баг, реальный тест

Вот функция reverse с тонким багом. Посмотрите, сможете ли вы заметить его раньше, чем тест.

fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = Vec::with_capacity(xs.len());
    for i in 1..xs.len() {
        rev.push(xs[xs.len() - i].clone());
    }
    rev
}

Диапазон начинается с 1 вместо 0. Для любого непустого списка первый элемент молча теряется. Юнит-тест с [1, 2, 3] мог пройти, если автор проверял только то, что выглядит как реверс, а не правильную длину. Но свойство ловит его мгновенно.

Добавьте proptest в ваш Cargo.toml:

[dev-dependencies]
proptest = "1.6"

Затем напишите свойство:

use proptest::prelude::*;

proptest! {
    #[test]
    fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
        let rev = reverse(&xs);
        let rev_rev = reverse(&rev);
        prop_assert_eq!(xs, rev_rev);
    }
}

Запустите cargo test, и вывод расскажет историю:

thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
  left: `[0]`,
 right: `[]`'

proptest сократил падающий случайный вектор до одного элемента, [0]. Первый вызов возвращает []. Второй вызов возвращает []. Свойство требует [] == [0], что и падает. Баг становится очевидным, как только входные данные минимальны.

Вы также можете напрямую тестировать структурные свойства:

proptest! {
    #[test]
    fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
        prop_assert_eq!(xs.len(), reverse(&xs).len());
    }
}

Это падает с тем же минимальным входом, но даёт ещё более понятную ошибку: длина вывода равна 0, а не 1.

Почему вам всё ещё нужны обычные юнит-тесты

Тестирование на основе свойств — не замена тестированию на примерах. Это дополнение.

Юнит-тесты документируют намерение. Когда новый разработчик читает, что reverse(&[1, 2, 3]) должен равняться [3, 2, 1], он понимает, что функция должна делать. Тест на свойство говорит «этот инвариант выполняется», но не говорит, как выглядит вывод для нормального случая.

Свойства также сложнее писать, чем примеры. Конечные автоматы, побочные эффекты и ввод-вывод не отображаются на чистые функции. У proptest есть стратегии для сложных типов, но их настройка требует больше размышлений, чем несколько вызовов assert_eq!.

Есть также временные затраты. Тест на свойство может выполнять тысячу итераций. В большой кодовой базе это накапливается. Вы запускаете тесты на свойства в CI, а не на каждое нажатие клавиши во время разработки.

Случайные тесты всё ещё могут быть воспроизводимыми

Случайность заставляет людей нервничать. Если тест падает только иногда, это код или тест?

proptest решает это, сохраняя падающие seed. Когда свойство падает, он сохраняет seed и сокращённые входные данные в файл в proptest-regressions/. Следующий запуск воспроизводит именно этот случай перед генерацией новых случайных данных. Flaky тест на свойство почти всегда означает баговое свойство, а не flaky фреймворк.

Если вам нужна строгая детерминированность, вы можете зафиксировать seed в конфиге теста. Большинство команд не заморачивается. Файл регрессий достаточен.

Где тесты на свойства окупаются

Тестирование на основе свойств стоит накладных расходов, когда ваш код имеет чёткие математические инварианты и большое пространство входных данных.

Сортировка — классический пример. Свойства просты: вывод отсортирован, является перестановкой входа и имеет ту же длину. Баговая сортировка может проходить рукописные примеры, но падать на случайных данных с дублирующимися элементами.

Парсеры и сериализаторы — ещё одно сладкое место. Round-trip свойства вроде parse(serialize(x)) == x ловят баги кодирования, которые проявляются только с определёнными последовательностями байтов.

Всё, что связано с арифметикой, коллекциями или переходами состояний, выигрывает. Всё, что связано с внешними API, строгой синхронизацией или человеческим суждением — нет.

Начните с одного свойства

Вам не нужно переписывать весь тестовый набор. Выберите одну чистую функцию с чётким инвариантом. Добавьте proptest в dev-dependencies. Напишите одно свойство. Запустите его, посмотрите, как оно проходит, затем внесите тонкий баг и посмотрите, как оно сокращает падение до того, что можно отладить за секунды.

Как только вы увидите, как случайный тест находит баг, для которого вы бы никогда не написали пример, вы начнёте чаще использовать свойства. Не для каждого теста. Только для тех, которые имеют значение.