Avatar ·

Как создавать параметризованные тесты в Rust для проверки множества входных данных?

Привет, коллеги! Я недавно начал изучать Rust и пишу тесты для своего проекта. У меня есть функция, которая вычисляет факториал числа: ```rust fn factorial(n: u64) -> u64 { match n { 0 | 1 => 1, _ => n * factorial(n - 1), } } ``` Мне нужно проверить её работу для разных входных значений: 0, 1, 5, 10 и т.д. Сейчас я пишу отдельный тест для каждого случая: ```rust #[test] fn test_factorial_0() { assert_eq!(factorial(0), 1); } #[test] fn test_factorial_1() { assert_eq!(factorial(1), 1); } #[test] fn test_factorial_5() { assert_eq!(factorial(5), 120); } ``` Это очень неудобно, когда нужно проверить десятки или сотни вариантов. Я привык в других языках (например, в Python с `pytest.mark.parametrize`) писать один параметризованный тест, который принимает список входных данных и ожидаемых результатов. В документации Rust я нашёл только простые примеры с `#[test]`. Подскажите, есть ли в Rust встроенные средства для параметризованных тестов? Или нужно использовать внешние крейты? Как лучше организовать проверку множества случаев без дублирования кода? Буду благодарен за примеры кода и рекомендации по best practices. <<>>

Параметризованные тесты в Rust: встроенные методы и крейты

В Rust нет встроенного атрибута для параметризованных тестов (как `@pytest.mark.parametrize` в Python). Однако есть несколько эффективных способов реализовать эту функциональность. Рассмотрим лучшие подходы.

1. Использование крейта rstest (рекомендуется)

Крейт rstest — самое популярное и удобное решение. Он предоставляет атрибут #[rstest] с поддержкой параметризации. Добавьте в Cargo.toml:

[dev-dependencies]
rstest = "0.18"

Пример использования:

use rstest::rstest;

#[rstest]
#[case(0, 1)]
#[case(1, 1)]
#[case(5, 120)]
#[case(10, 3628800)]
fn test_factorial(#[case] input: u64, #[case] expected: u64) {
    assert_eq!(factorial(input), expected);
}

Если нужно много кейсов, можно использовать списки:

use rstest::rstest;

#[rstest]
#[case::zero(0, 1)]
#[case::one(1, 1)]
#[case::five(5, 120)]
#[case::ten(10, 3628800)]
fn test_factorial_named(#[case] input: u64, #[case] expected: u64) {
    assert_eq!(factorial(input), expected);
}

Для генерации тестов из файлов или конфигураций используйте #[rstest(case(...))] с динамическими данными.

2. Использование макроса test_case из крейта test-case

Альтернативный популярный крейт:

[dev-dependencies]
test-case = "3.1"
use test_case::test_case;

#[test_case(0, 1)]
#[test_case(1, 1)]
#[test_case(5, 120)]
#[test_case(10, 3628800)]
fn test_factorial(input: u64, expected: u64) {
    assert_eq!(factorial(input), expected);
}

Крейт test-case поддерживает вложенные тесты, игнорирование отдельных кейсов и генерацию описаний.

3. Использование табличных тестов (без внешних крейтов)

Если вы хотите избежать зависимостей, можно использовать макрос macro_rules! или просто итерацию:

#[test]
fn test_factorial_table() {
    let test_cases = vec![
        (0, 1),
        (1, 1),
        (5, 120),
        (10, 3628800),
        (7, 5040),
    ];

    for (input, expected) in test_cases {
        assert_eq!(
            factorial(input),
            expected,
            "Тест не пройден для input = {}",
            input
        );
    }
}

Недостаток: при первом же провале тест остановится, и вы не увидите результаты для остальных кейсов. Для лучшего отчёта можно использовать rstest или test-case — они запускают каждый кейс как отдельный тест.

Советы по организации

  • Используйте rstest — он наиболее гибкий и популярный (более 5 млн загрузок).
  • Давайте осмысленные имена кейсам с помощью #[case::имя(...)].
  • Для больших наборов данных можно вынести кейсы в константу или читать из файла.
  • Комбинируйте с другими атрибутами — например, #[ignore] или #[should_panic].

Пример с комбинированными параметрами:

#[rstest]
#[case(0, 1)]
#[case(1, 1)]
#[should_panic]
#[case(100, 0)]  // ожидаем панику
fn test_factorial_edge(#[case] input: u64, #[case] expected: u64) {
    assert_eq!(factorial(input), expected);
}

Итог

Рекомендую начать с rstest — он простой, мощный и хорошо документирован. Если проект требует минимум зависимостей — используйте табличный подход. В любом случае, параметризованные тесты значительно упрощают поддержку кода и повышают покрытие.

<<>>

Альтернативный подход: макросы для параметризованных тестов

Если вы хотите полностью избежать внешних крейтов, можно написать собственный макрос. Это даёт полный контроль и не добавляет зависимостей. Пример простого макроса для параметризации:

macro_rules! parameterized_test {
    ($name:ident, $cases:expr) => {
        #[test]
        fn $name() {
            for (input, expected) in $cases {
                assert_eq!(
                    factorial(input),
                    expected,
                    "Failed for input: {}",
                    input
                );
            }
        }
    };
}

parameterized_test!(test_factorial_cases, vec![
    (0, 1),
    (1, 1),
    (5, 120),
    (10, 3628800),
]);

Можно расширить макрос для поддержки разных типов и кастомных проверок:

macro_rules! param_test {
    ($name:ident, $test_fn:expr, [$(( $input:expr, $expected:expr )),+]) => {
        #[test]
        fn $name() {
            $(
                assert_eq!($test_fn($input), $expected, "Failed for input: {}", $input);
            )+
        }
    };
}

param_test!(test_factorial_macro, factorial, [
    (0, 1),
    (1, 1),
    (5, 120),
    (10, 3628800)
]);

Преимущества: нет зависимостей, полный контроль над логикой. Недостатки: сложнее поддерживать, нет разделения на отдельные тесты (при провале одного кейса весь тест падает), меньше возможностей для кастомизации.

Этот подход подходит для небольших проектов или когда вы хотите минимизировать количество крейтов. Для серьёзной разработки всё же рекомендую rstest или test-case.