Привет!
Юнит-тесты позволяют предотвратить ошибки и значительно упростить процессы рефакторинга и поддержки кода. Их реализация существует во всех языках программирования и 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 - это одна из главных фич языка. О ней преподаватели расскажут на вебинаре и полайвкодят вместе с вами.
alhimik45
А так, чтобы параметры кодом генерировать вместо констант, сейчас возможно? Пару лет назад не нашёл такого способа, плюнул, сделал циклами внутри обычного #[test].
domix32
Если что-то а ля фаззинг то kani и proptest по идее ваш клиент.
alhimik45
Не фаззинг, а просто генерация тест кейсов кодом. (Пример как это в NUnit)
Иногда в константы кейсы трудно запихать из-за сложной логики создания входных данных
domix32
Какой-то такой код похож на то что делает NUnit?
Вместо копипасты с генераторами тут будут рэнжи с данными
a in 0..1000i32
илиv in any::<u32>().prop_map(|v| v.to_string())
как вот тут. Собственно вторым способом наверняка можно также тесткейсы из массива и как кортеж обрабатывать, аналогично примеру с NUnit, где кейсы были массивами объектов/классов, но это надо маны читать, сам не пользовался.Kani тоже умеет в генерацию, но он все же больше про фаззинг, нежели про протесты.
alhimik45
Похож, но всё же не то. Тут именно ренжи и всякие их
any::<u32>()
являются источником данных и явного способа впихнуть туда полностью кастомно генерируемый массив своих данных не видно. Накостылять наверняка можно, но выглядеть полагаю уже будет не сильно читаемо.domix32
Так можно использовать обычные массивы вместо any. Будет что-то вроде
Написал наугад, но в принципе можно по красоте сделать с итераторами и композерами.
alhimik45
Не, напрямую так не получится :(
После in должно идти что-то имплементирующее Strategy trait, который всякие возможности перебора и упрощения комбинаций значений должен предоставлять. Поэтому нужно именно что костылять с dummy имплементаций трейта которая бы возвращала определенный список значений, причём так, чтобы proptest не резал некоторые значения как избыточные при симплификации.