Привет!

Юнит-тесты позволяют предотвратить ошибки и значительно упростить процессы рефакторинга и поддержки кода. Их реализация существует во всех языках программирования и Rust - не исключение.

Юнит-тесты в Rust обычно располагаются в том же файле, что и тестируемый код, в специальном модуле с именем tests, аннотированном #[cfg(test)]. Внутри этого модуля размещаются функции тестирования, каждая из которых также аннотируется как #[test].

Пример простого юнит-теста в Rust:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

tests содержит функцию it_works, которая проверяет, что операция сложения выполняется корректно. Если условие assert_eq!(2 + 2, 4) не выполняется, тест считается не пройденным.

Или пример немного посложней - функция для деления двух чисел, которая должна возвращать ошибку при попытке деления на ноль:

fn safe_divide(dividend: i32, divisor: i32) -> Result<i32, String> {
    if divisor == 0 {
        Err("Division by zero".to_string())
    } else {
        Ok(dividend / divisor)
    }
}

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

    #[test]
    fn test_divide_by_zero() {
        let result = safe_divide(10, 0);
        assert_eq!(result, Err("Division by zero".to_string()));
    }

    #[test]
    fn test_normal_division() {
        let result = safe_divide(10, 2);
        assert_eq!(result, Ok(5));
    }
}

В Rust нет ограничений на тестирование приватных функций, что позволяет выбирать, тестировать ли их напрямую или нет. Пример теста приватной функции:

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn internal() {
        assert_eq!(internal_adder(2, 2), 4);
    }
}

В этом случае тестируется приватная функция internal_adder, что возможно с use super::*, позволяющему тестам видеть содержимое родительского модуля.

Ассерты

Макрос assert! используется для проверки, что булево выражение истинно. Если выражение оценивается как false, происходит паника, и тест считается неудачным. Этот макрос может также принимать пользовательское сообщение для вывода в случае ошибки:

assert!(1 + 1 == 2);
assert!(some_boolean_function(), "Expected true but got false");

Макрос assert_eq! проверяет равенство двух выражений, используя трейт PartialEq. Если значения не равны, тест паникует и выводит значения обеих выражений, что помогает быстро понять причину несоответствия. Можно добавить собственное сообщение для вывода дополнительных данных:

let expected = 2;
let result = 1 + 1;
assert_eq!(result, expected, "Testing addition: {} + 1 should be {}", 1, expected);

Макрос assert_ne! используется для проверки, что два выражения не равны. Как и assert_eq!, при несоответствии значения выводятся для упрощения отладки, и возможно добавление пользовательского сообщения:

let a = 3;
let b = 4;
assert_ne!(a, b, "Values should not be equal: {} and {}", a, b);

Mock объекты и зависимости

С помощью атрибута #[automock], mockall может автоматически создать мок для любого трейта. Это упрощает тестирование, т.к не требуется ручное определение мок-структур:

#[automock]
trait MyTrait {
    fn foo(&self, x: u32) -> u32;
}

В тестах можно настроить поведение моков, используя методы expect_ для определения ожидаемых вызовов и возвращаемых значений:

#[test]
fn mytest() {
    let mut mock = MockMyTrait::new();
    mock.expect_foo()
        .with(eq(4))
        .times(1)
        .returning(|x| x + 1);
    assert_eq!(5, mock.foo(4));
}

Если автоматическое создание моков не подходит (например, при необходимости более сложной конфигурации), можно использовать макрос mock!:

mock! {
    pub MyStruct<T: Clone + 'static> {
        fn bar(&self) -> u8;
    }
    impl<T: Clone + 'static> MyTrait for MyStruct<T> {
        fn foo(&self, x: u32);
    }
}

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

let mut mock = MockMyTrait::new();
mock.expect_foo()
    .return_const(44u32);
mock.expect_bar()
    .with(predicate::ge(1))
    .returning(|x| x + 1);

Можно рассмотреть такой пример:

use mockall::{automock, predicate::*};

struct Database {
              ....
}

#[automock]
impl Database {
    fn get_user(&self, user_id: i32) -> Option<String> {
        // определенные операции
        Some("User".to_string())
    }
}

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

    #[test]
    fn test_get_user() {
        let mut mock_db = MockDatabase::new();
        mock_db.expect_get_user()
               .with(eq(42))
               .times(1)
               .returning(|_| Some("User".to_string()));

        let result = mock_db.get_user(42);
        assert_eq!(result, Some("User".to_string()));
    }

Подробнее про mockall можно прочитать по ссылке.

Snapshot тестирование

Когда впервые запускаются тесты с использованием insta, тесты скорее всего упадут, поскольку отсутствуют базовые снимки для сравнения. В этом случае insta создаёт новые снимки с расширением .snap.new. Можно просмотреть эти снимки и, если они соответствуют ожидаемым результатам, подтвердить их как основные снимки для будущих тестов.

При помощи команды cargo insta review можно интерактивно просматривать изменения между старыми и новыми снимками и выбирать, принимать ли новые результаты или отклонять их.

insta автоматически обнаруживает, когда тесты запущены в CI, и изменяет поведение обновления снимков, чтобы предотвратить случайное изменение снимков без явного подтверждения.

Макросы для создания снимков:

assert_snapshot! для базовых строковых снимков.

assert_debug_snapshot! для снимков, которые используют формат вывода Debug для объектов.

assert_yaml_snapshot!, assert_json_snapshot! и другие, поддерживающие сериализацию данных в различные форматы (требуется включение соответствующих функций).

С помощью переменных среды или файлов конфигурации можно настроить поведение insta, например, обновление снимков только при явном указании или автоматическое принятие изменений при отсутствии их в CI.

Пример:

#[test]
fn test_vector() {
    let data = vec![1, 2, 3];
    insta::assert_debug_snapshot!(data);
}

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

Параметризованные тесты

Для реализации параметризованных тестов часто используется крейт rstest. Этот крейт предоставляет макросы для определения тестов с несколькими наборами параметров.

Допустим, есть функция add, которая просто возвращает сумму двух чисел. Напишем для неё параметризованный тест, который проверит функцию на разных наборах входных данных:

use rstest::rstest;

fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[rstest]
#[case(1, 2, 3)]
#[case(5, -2, 3)]
#[case(0, 0, 0)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
    assert_eq!(add(a, b), expected);
}

Макрос #[rstest] используется для создания параметризованного теста. Аннотация #[case(...)] определяет отдельные наборы параметров, каждый из которых приведет к генерации отдельного теста.

Теперь создадим параметризованный тест для функции, которая выполняет деление с проверкой на деление на ноль:

use rstest::rstest;
use std::num::NonZeroU32;

fn safe_divide(numerator: u32, denominator: NonZeroU32) -> u32 {
    numerator / denominator.get()
}

#[rstest]
#[case(10, NonZeroU32::new(2).unwrap(), 5)]
#[case(20, NonZeroU32::new(5).unwrap(), 4)]
#[case(12, NonZeroU32::new(3).unwrap(), 4)]
fn test_safe_divide(#[case] numerator: u32, #[case] denominator: NonZeroU32, #[case] expected: u32) {
    assert_eq!(safe_divide(numerator, denominator), expected);
}

Используется тип NonZeroU32, который гарантирует, что делитель не может быть нулём, тем самым предотвращая возможность ошибки деления на ноль во время выполнения. Каждый #[case] определяет различные входные данные, на которых должна быть проверена функция.


В завершение хочу порекомендовать вам бесплатный вебинар про Borrow checker - это одна из главных фич языка. О ней преподаватели расскажут на вебинаре и полайвкодят вместе с вами.

Комментарии (7)


  1. alhimik45
    26.04.2024 14:01

    Параметризованные тесты

    А так, чтобы параметры кодом генерировать вместо констант, сейчас возможно? Пару лет назад не нашёл такого способа, плюнул, сделал циклами внутри обычного #[test].


    1. domix32
      26.04.2024 14:01

      Если что-то а ля фаззинг то kani и proptest по идее ваш клиент.


      1. alhimik45
        26.04.2024 14:01

        Не фаззинг, а просто генерация тест кейсов кодом. (Пример как это в NUnit)
        Иногда в константы кейсы трудно запихать из-за сложной логики создания входных данных


        1. domix32
          26.04.2024 14:01

          Какой-то такой код похож на то что делает NUnit?

          use proptest::prelude::*;
          
          fn add(a: i32, b: i32) -> i32 {
              a + b
          }
          
          proptest! {
              #[test]
              fn test_add(a in 0..1000i32, b in 0..1000i32) {
                  let sum = add(a, b);
                  assert!(sum >= a);
                  assert!(sum >= b);
              }
          }
          
          fn main() { test_add(); }

          Вместо копипасты с генераторами тут будут рэнжи с данными a in 0..1000i32 или v in any::<u32>().prop_map(|v| v.to_string()) как вот тут. Собственно вторым способом наверняка можно также тесткейсы из массива и как кортеж обрабатывать, аналогично примеру с NUnit, где кейсы были массивами объектов/классов, но это надо маны читать, сам не пользовался.

          Kani тоже умеет в генерацию, но он все же больше про фаззинг, нежели про протесты.


          1. alhimik45
            26.04.2024 14:01

            Похож, но всё же не то. Тут именно ренжи и всякие их any::<u32>() являются источником данных и явного способа впихнуть туда полностью кастомно генерируемый массив своих данных не видно. Накостылять наверняка можно, но выглядеть полагаю уже будет не сильно читаемо.


            1. domix32
              26.04.2024 14:01

              Так можно использовать обычные массивы вместо any. Будет что-то вроде

              proptest! {
                  #[test]
                  fn test_add((a,b,c) in 
                  [ (3,4,5),
                    (6,7,8),
                    (9,10,11),].into_iter()) 
                  {
                    let sum = add(a,b);
                    let sum2 = add(b,c);
                    let sum3 = add(sum, sum2);
                    assert!(sum3 == a + b + c)
                  }

              Написал наугад, но в принципе можно по красоте сделать с итераторами и композерами.


              1. alhimik45
                26.04.2024 14:01
                +1

                Не, напрямую так не получится :(

                the trait `Strategy` is not implemented for `{integer}`, which is required by `({integer}, {integer}): Strategy`
                

                После in должно идти что-то имплементирующее Strategy trait, который всякие возможности перебора и упрощения комбинаций значений должен предоставлять. Поэтому нужно именно что костылять с dummy имплементаций трейта которая бы возвращала определенный список значений, причём так, чтобы proptest не резал некоторые значения как избыточные при симплификации.