Property-based тестирование в Rust: искусство ловить баги, которые вы не искали
Вы когда-нибудь писали юнит-тесты, запускали их, всё зелёное, а потом приложение падало на первом же нестандартном вводе от пользователя? Знакомо. Классические тесты — это как проверять, работает ли замок, вставляя в него только ваш собственный ключ. А что, если злоумышленник (или просто случайность) принесёт отвёртку, скрепку или кусок проволоки?
В Rust, языке, который славится своей безопасностью, есть мощный инструмент, позволяющий не просто проверять конкретные сценарии, а доказывать, что код ведёт себя корректно для всех возможных входных данных. Это — property-based тестирование. И сегодня мы разберём его на практике, с реальными примерами, чтобы вы могли сразу применить эти знания в своих проектах.
Что такое property-based тестирование и чем оно отличается от обычного?
Представьте, что вы тестируете функцию сортировки. Обычный unit-тест выглядит так:
fn test_sort() {
let mut arr = vec![3, 1, 2];
arr.sort();
assert_eq!(arr, vec![1, 2, 3]);
}
Работает? Работает. Но что, если массив уже отсортирован? Что, если он пустой? Что, если в нём 10 000 элементов, идущих в обратном порядке? Писать тесты на каждый случай вручную — путь к бесконечности.
Property-based тестирование переворачивает подход. Вместо того чтобы задавать конкретные входные данные, вы описываете свойство (property), которое должно выполняться для любого корректного ввода. А специальная библиотека (например, proptest или quickcheck) сама генерирует сотни и тысячи случайных тестовых случаев, пытаясь найти тот единственный, который нарушит ваше свойство.
Для сортировки свойство может звучать так: «Результат сортировки всегда имеет ту же длину, что и исходный массив, и каждый элемент исходного массива присутствует в результате ровно один раз». Или проще: «Результат отсортирован по возрастанию».
Почему Rust — идеальная среда для property-based тестирования
Rust и так уже перехватывает множество ошибок на этапе компиляции (владение памятью, гонки данных). Но есть класс багов, которые компилятор не видит: логические ошибки, некорректная обработка граничных случаев, неожиданное поведение при специфических комбинациях данных. Вот здесь и вступает в игру property-based тестирование Rust.
Оно идеально дополняет систему типов: типы гарантируют, что данные имеют правильную структуру, а property-based тесты — что логика обработки этих данных верна для всего пространства возможных значений.
Инструментарий: proptest vs quickcheck
В экосистеме Rust есть два главных фреймворка: proptest и quickcheck. Оба делают одно и то же, но с разной философией.
- quickcheck — более старый, минималистичный. Работает через трейт
Arbitrary. Вы пишете функцию, которая принимает случайные данные, и тест запускается много раз. - proptest — современный, мощный, с кучей стратегий генерации данных. Позволяет тонко настраивать, какие именно данные генерировать, и умеет «сужать» (shrink) неудачный пример до минимального воспроизводимого случая. Это золотой стандарт на сегодня.
Мы будем использовать proptest, так как он даёт больше контроля и лучше документирован. Установим его:
# Cargo.toml
[dev-dependencies]
proptest = "1.4"
Пишем первый property-based тест на Rust
Давайте вернёмся к нашей сортировке. Напишем тест, который проверяет, что результат действительно отсортирован:
use proptest::prelude::*;
fn is_sorted<T: Ord>(slice: &[T]) -> bool {
slice.windows(2).all(|w| w[0] <= w[1])
}
proptest! {
#[test]
fn test_sort_property(mut v in prop::collection::vec(any::<i32>(), 0..100)) {
v.sort();
prop_assert!(is_sorted(&v));
// Дополнительное свойство: длина не меняется
prop_assert_eq!(v.len(), v.len()); // тривиально, но для примера
}
}
Что здесь происходит? proptest! — макрос, который запускает тест многократно. prop::collection::vec(any::<i32>(), 0..100) — стратегия, генерирующая вектор случайной длины от 0 до 100 элементов, каждый элемент — случайное целое число. Для каждого сгенерированного вектора выполняется тело теста. Если хоть один случай провалится, proptest покажет минимальный вектор, на котором тест упал.
Пример: тестирование разбора URL
Предположим, у нас есть функция, которая парсит строку и возвращает структуру Uri. Обычный тест: проверить, что parse("https://pythonlib.ru") возвращает правильный хост. Но что, если строка содержит недопустимые символы? А что, если это просто "http://a"?
С помощью proptest мы можем генерировать случайные строки и проверять, что функция не паникует:
use proptest::prelude::*;
use std::str::FromStr;
// Допустим, у нас есть своя структура Uri
struct Uri { /* ... */ }
impl FromStr for Uri {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// наша реализация
if s.is_empty() {
return Err("empty string".to_string());
}
// ... сложный парсинг
Ok(Uri { /* ... */ })
}
}
proptest! {
#[test]
fn test_uri_parsing_doesnt_crash(s in ".*") {
// Проверяем, что парсинг любой строки не вызывает паники
let _ = Uri::from_str(&s);
}
}
Этот тест сгенерирует тысячи строк — от пустых до длинных с кучей спецсимволов — и проверит, что функция не упадёт с panic!. Конечно, строки могут быть невалидными, и это окей — мы проверяем именно свойство «не паниковать».
Продвинутые стратегии: не только случайные числа
Property-based тестирование в Rust становится по-настоящему мощным, когда вы начинаете конструировать сложные стратегии. proptest позволяет комбинировать генераторы:
any::<u32>()— любое 32-битное беззнаковое числоprop::collection::vec(any::<u8>(), 1..256)