Property-based тестирование в Rust: как ловить баги, которые вы не искали

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

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

Начать курс

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)

Блоги

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