Привет!
Юнит-тесты позволяют предотвратить ошибки и значительно упростить процессы рефакторинга и поддержки кода. Их реализация существует во всех языках программирования и 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 - это одна из главных фич языка. О ней преподаватели расскажут на вебинаре и полайвкодят вместе с вами.