proptest для Rust: полное руководство с примерами

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

proptest для Rust: полное руководство по property-based тестированию

1. Что это и зачем нужна

Библиотека proptest — это мощный фреймворк для property-based тестирования (тестирования на основе свойств) в языке Rust. В отличие от традиционного unit-тестирования, где вы вручную задаёте конкретные входные данные и ожидаемый результат, property-based тестирование предлагает описать свойства (инварианты), которые должны выполняться для любых корректных входных данных. Затем библиотека автоматически генерирует множество случайных тестовых случаев и проверяет, не нарушается ли указанное свойство.

Зачем это нужно? Во-первых, это позволяет находить "краевые случаи" (edge cases), о которых разработчик мог даже не подумать: пустые строки, отрицательные числа, очень большие значения, null-подобные состояния. Во-вторых, property-based тесты самодокументируемы: они описывают контракт функции ("для любого числа x функция f(x) возвращает число больше 0"), а не один конкретный пример. В-третьих, при изменении кода такие тесты часто продолжают работать без изменений, так как они проверяют общие свойства, а не конкретные значения.

proptest — это аналог библиотек QuickCheck (Haskell), Hypothesis (Python) и FsCheck (.NET). Она предоставляет богатый набор стратегий генерации данных (от простых чисел до сложных структур), механизм сжатия (shrinkage) для минимизации падающих примеров, и интеграцию с основным тестовым фреймворком Rust — cargo test.

2. Установка

Для использования proptest добавьте его в раздел [dev-dependencies] вашего файла Cargo.toml:

[dev-dependencies]
proptest = "1.4"

Затем выполните команду:

cargo build

Библиотека будет загружена и скомпилирована. Теперь вы можете использовать её в тестовых модулях.

3. Быстрый старт — минимальный рабочий пример

Давайте напишем простой тест, который проверяет, что функция reverse (разворот строки) является инволюцией: если применить её дважды, мы получим исходную строку.

// src/lib.rs
pub fn reverse(s: &str) -> String {
    s.chars().rev().collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn test_reverse_involution(s in ".*") {
            let reversed = reverse(&s);
            let double_reversed = reverse(&reversed);
            assert_eq!(s, double_reversed);
        }
    }
}

Запустите тест командой cargo test. proptest сгенерирует 256 случайных строк (по умолчанию) и проверит, что свойство выполняется. Если тест упадёт, библиотека попытается найти минимальный пример, который воспроизводит ошибку.

4. Основные методы и функции

Ниже приведены ключевые компоненты библиотеки proptest. Все примеры предполагают, что в начале тестового модуля добавлен use proptest::prelude::*;.

4.1. Макрос proptest!

Сигнатура: proptest! { ... }

Что делает: Основной макрос, который определяет блок property-based тестов. Внутри него можно объявлять тестовые функции с автоматической генерацией данных.

proptest! {
    #[test]
    fn test_addition_commutative(a in 0..100i32, b in 0..100i32) {
        assert_eq!(a + b, b + a);
    }
}

4.2. Стратегия any::<T>()

Сигнатура: fn any<T: Arbitrary>() -> BoxedStrategy<T>

Что делает: Генерирует произвольное значение любого типа, реализующего трейт Arbitrary. Для стандартных типов (числа, строки, векторы) трейт уже реализован.

proptest! {
    #[test]
    fn test_any_integer(x in any::<i32>()) {
        // x может быть любым i32, включая 0, отрицательные, MAX и MIN
        assert!(x.overflowing_add(1).0 != x); // Проверка, что +1 меняет значение
    }
}

4.3. Стратегия prop::collection::vec

Сигнатура: pub fn vec<T, S>(strategy: S, size: impl Into<SizeRange>) -> VecStrategy<T, S::Value>

Что делает: Генерирует вектор, элементы которого создаются указанной стратегией. size задаёт диапазон длины вектора (например, 0..10).

proptest! {
    #[test]
    fn test_vec_of_positive_numbers(v in prop::collection::vec(1..100i32, 1..10)) {
        // v — вектор длины от 1 до 10, каждый элемент от 1 до 99
        for &item in &v {
            assert!(item > 0);
        }
    }
}

4.4. Комбинатор prop_oneof!

Сигнатура: prop_oneof! [ strategy_1, strategy_2, ... ]

Что делает: Выбирает случайную стратегию из списка и генерирует значение с её помощью. Полезно для тестирования функций, которые принимают значения разных типов (например, enum).

#[derive(Debug, Clone)]
enum MyEnum {
    Int(i32),
    Text(String),
}

proptest! {
    #[test]
    fn test_enum_variants(val in prop_oneof![
        any::<i32>().prop_map(MyEnum::Int),
        ".*".prop_map(MyEnum::Text),
    ]) {
        match val {
            MyEnum::Int(x) => assert!(x != 0 || true), // Любое значение допустимо
            MyEnum::Text(s) => assert!(s.len() >= 0),
        }
    }
}

4.5. Комбинатор prop_map

Сигнатура: fn prop_map<O: Debug, F: Fn(T) -> O>(self, fun: F) -> Map<Self, F>

Что делает: Трансформирует значение, сгенерированное стратегией, с помощью заданной функции. Позволяет создавать сложные структуры из простых стратегий.

#[derive(Debug)]
struct Point {
    x: f64,
    y: f64,
}

proptest! {
    #[test]
    fn test_point_distance(p in (any::<f64>(), any::<f64>()).prop_map(|(x, y)| Point { x, y })) {
        let dist = (p.x * p.x + p.y * p.y).sqrt();
        assert!(dist >= 0.0);
    }
}

4.6. Комбинатор prop_filter

Сигнатура: fn prop_filter<F: Fn(&T) -> bool>(self, whence: &'static str, fun: F) -> Filter<Self, F>

Что делает: Отфильтровывает значения, не удовлетворяющие предикату. Если фильтр слишком строгий, может снизить производительность или привести к отбрасыванию всех значений.

proptest! {
    #[test]
    fn test_even_numbers(x in (0..100i32).prop_filter("x must be even", |x| x % 2 == 0)) {
        assert_eq!(x % 2, 0);
    }
}

4.7. Комбинатор prop_flat_map

Сигнатура: fn prop_flat_map<S: Strategy, F: Fn(T) -> S>(self, fun: F) -> Flatten<Self, F>

Что делает: Генерирует значение, которое используется для создания новой стратегии. Позволяет строить зависимые генераторы (например, размер вектора зависит от другого параметра).

proptest! {
    #[test]
    fn test_dependent_vec(size in 1..10usize) {
        // Генерируем вектор, длина которого равна size
        let v: Vec<i32> = prop::collection::vec(any::<i32>(), size).new_tree(&mut TestRunner::default()).unwrap().current();
        assert_eq!(v.len(), size);
    }
}

4.8. Трейт Arbitrary

Сигнатура: pub trait Arbitrary: Debug + Clone + 'static { fn arbitrary() -> Self::Strategy; type Strategy: Strategy<Value = Self>; }

Что делает: Позволяет реализовать кастомную стратегию генерации для вашего типа. Это основной способ интеграции proptest с пользовательскими структурами.

#[derive(Debug, Clone)]
struct User {
    name: String,
    age: u8,
}

impl Arbitrary for User {
    type Strategy = BoxedStrategy<Self>;
    fn arbitrary() -> Self::Strategy {
        ("[a-z]{3,10}", 0..120u8)
            .prop_map(|(name, age)| User { name, age })
            .boxed()
    }
}

proptest! {
    #[test]
    fn test_user_age(user: User) {
        assert!(user.age <= 120);
    }
}

4.9. Функция prop_assert!

Сигнатура: macro_rules! prop_assert { ($cond:expr) => { ... }; ($cond:expr, $($arg:tt)+) => { ... } }

Что делает: Аналог assert!, но с поддержкой сжатия (shrinkage). При падении теста proptest попытается найти минимальный пример, который вызывает ошибку. Используйте prop_assert! вместо assert! внутри proptest!.

proptest! {
    #[test]
    fn test_positive(x in 0..100i32) {
        prop_assert!(x >= 0, "x = {} is negative!", x);
    }
}

4.10. Комбинатор prop_union

Сигнатура: fn prop_union(self, other: Self) -> Union<Self>

Что делает: Объединяет две стратегии, выбирая одну из них случайным образом. Аналог prop_oneof! для двух стратегий.

proptest! {
    #[test]
    fn test_union(x in (0..10i32).prop_union(100..200i32)) {
        // x будет либо в диапазоне 0..10, либо 100..200
        assert!((0..10).contains(&x) || (100..200).contains(&x));
    }
}

5. Реальный практический пример

Рассмотрим более сложный пример: тестирование функции, которая вычисляет "сжатое" представление строки (run-length encoding). Функция compress преобразует строку в последовательность пар (символ, количество повторений).

// src/lib.rs

/// Сжимает строку: "aaabb" -> [('a', 3), ('b', 2)]
pub fn compress(s: &str) -> Vec<(char, usize)> {
    if s.is_empty() {
        return vec![];
    }

    let mut result = Vec::new();
    let mut chars = s.chars();
    let mut current_char = chars.next().unwrap();
    let mut count = 1;

    for ch in chars {
        if ch == current_char {
            count += 1;
        } else {
            result.push((current_char, count));
            current_char = ch;
            count = 1;
        }
    }
    result.push((current_char, count));
    result
}

/// Восстанавливает строку из сжатого представления
pub fn decompress(compressed: &[(char, usize)]) -> String {
    let mut result = String::new();
    for &(ch, count) in compressed {
        for _ in 0..count {
            result.push(ch);
        }
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    // Свойство 1: Компрессия + декомпрессия даёт исходную строку
    proptest! {
        #[test]
        fn test_compress_decompress_roundtrip(s in ".*") {
            let compressed = compress(&s);
            let decompressed = decompress(&compressed);
            prop_assert_eq!(s, decompressed);
        }
    }

    // Свойство 2: Сжатое представление не длиннее исходной строки (в символах)
    proptest! {
        #[test]
        fn test_compressed_not_longer(s in ".*") {
            let compressed = compress(&s);
            let compressed_len: usize = compressed.iter().map(|&(_, count)| count).sum();
            prop_assert!(compressed_len <= s.chars().count());
        }
    }

    // Свойство 3: В сжатом представлении нет последовательных                    

Рекомендаций