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: В сжатом представлении нет последовательных