Перейти к содержимому

Тестирование в Rust

Тестирование встроено прямо в Rust и cargo, так что для начала не нужна никакая отдельная библиотека. На этой странице ровно столько, сколько нужно, чтобы написать и запустить первые тесты.

Тест — это обычная функция с пометкой #[test]:

fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}

На что стоит обратить внимание:

  • #[cfg(test)] говорит Rust, что этот модуль компилируется только при запуске тестов. В обычную программу он не входит.
  • use super::*; подключает всё из родительского модуля (здесь — функцию add), чтобы тест мог её вызвать.
  • #[test] помечает конкретную функцию как тест, который должен запустить cargo test.

Из папки проекта:

Terminal window
cargo test

Ты увидишь примерно такой вывод:

running 1 test
test tests::it_adds_two_numbers ... ok
test result: ok. 1 passed; 0 failed; 0 ignored

Cargo запустил один тест, он прошёл (ok): 1 пройден, 0 провалено, 0 пропущено.

Три макроса покрывают почти всё, что тебе понадобится:

#[test]
fn checks() {
assert!(2 + 2 == 4); // тест падает, если условие ложно
assert_eq!(2 + 2, 4); // падает, если два значения не равны
assert_ne!(2 + 2, 5); // падает, если два значения РАВНЫ
}

Обычно assert_eq! и assert_ne! лучше, чем простой assert!(a == b): когда они падают, то выводят оба значения. Ты сразу видишь, что не совпало, а не просто «assertion failed».

fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn this_will_fail() {
assert_eq!(add(2, 2), 5);
}
}
test tests::this_will_fail ... FAILED
assertion `left == right` failed
left: 4
right: 5

Именно такое сообщение и хочется видеть, когда тест падает: слева (left) — что на самом деле выдал твой код, справа (right) — что ожидал тест.

Иногда правильное поведение — это и есть паника. Например, функция должна отказываться от неверных данных. Для этого есть #[should_panic]:

fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("нельзя делить на ноль");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn dividing_by_zero_panics() {
divide(10, 0);
}
}

Когда тест падает, хочется как можно быстрее понять почему. Здесь очень помогают два маленьких дополнения.

Своё сообщение. assert!, assert_eq! и assert_ne! принимают после значений дополнительное сообщение, которое пишется так же, как в println!. Оно выводится, только если проверка не прошла.

expected в should_panic. Простой #[should_panic] проходит при любой панике, даже если её вызвала совсем другая ошибка. Если добавить expected = "...", тест пройдёт, только когда сообщение паники содержит этот текст.

Вот снова функция divide, теперь с main, чтобы её можно было запустить, и два теста, которые используют обе идеи:

fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("нельзя делить на ноль");
}
a / b
}
fn main() {
println!("10 / 2 = {}", divide(10, 2));
println!("7 / 2 = {}", divide(7, 2));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_down() {
let result = divide(7, 2);
assert_eq!(result, 3, "7 / 2 должно округляться вниз, а получилось {result}");
}
#[test]
#[should_panic(expected = "делить на ноль")]
fn dividing_by_zero_panics() {
divide(10, 0);
}
}

Вывод:

10 / 2 = 5
7 / 2 = 3
▶ Попробуй сам

Вывод выше — это то, что печатает cargo run, потому что main тесты не запускает. Чтобы запустить тесты, используй cargo test. В Rust Playground открой меню рядом с кнопкой Run и выбери Test. Ты увидишь:

running 2 tests
test tests::rounds_down ... ok
test tests::dividing_by_zero_panics - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Тесты выполняются параллельно, поэтому две строки test ... могут появиться в любом порядке. А теперь представь, что тест ожидал 4, а не 3. В сообщении о провале будет твой текст, прямо рядом с двумя значениями:

assertion `left == right` failed: 7 / 2 должно округляться вниз, а получилось 3
left: 3
right: 4

А если divide запаникует с другим сообщением, проверка expected это поймает. Здесь тест ждал слово "negative", а в сообщении паники его нет:

note: panic did not contain expected string
panic message: "нельзя делить на ноль"
expected substring: "negative"

Честный ответ: для короткого скрипта ты, скорее всего, не станешь. Тесты начинают окупаться, когда код будут использовать повторно или менять позже. С ними можно смело переделывать код (рефакторинг): cargo test сразу скажет, если ты что-то сломал, и тебе не придётся узнавать об этом от пользователя.

Тесты могут жить и в документации: примеры кода внутри документирующих комментариев /// тоже запускаются через cargo test, так что твои примеры никогда не устареют. Как это сделать, показано в уроке Документация.

В обоих упражнениях у решения есть main, которую можно запустить, и тесты, которые запускаются через cargo test (или Test в Playground).

1. Протестируй функцию выставления оценок

Заголовок раздела «1. Протестируй функцию выставления оценок»

Напиши fn grade(score: u32) -> char, которая возвращает 'A' за 90 баллов и выше, 'B' за 80–89, 'C' за 70–79 и 'F' за всё, что ниже. Затем напиши тесты, которые проверяют границы, например 90 и 89. Подсказка: ошибки прячутся именно на границах. Тест ровно для 90 ловит классическую ошибку — > вместо >=.

Показать решение
fn grade(score: u32) -> char {
if score >= 90 {
'A'
} else if score >= 80 {
'B'
} else if score >= 70 {
'C'
} else {
'F'
}
}
fn main() {
for score in [95, 85, 72, 40] {
println!("{score} -> {}", grade(score));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edges_of_a() {
assert_eq!(grade(90), 'A');
assert_eq!(grade(89), 'B');
}
#[test]
fn edges_of_c() {
assert_eq!(grade(70), 'C');
assert_eq!(grade(69), 'F');
}
#[test]
fn zero_is_f() {
assert_eq!(grade(0), 'F', "за 0 баллов должна быть оценка F");
}
}

Вывод:

95 -> A
85 -> B
72 -> C
40 -> F
▶ Попробуй сам

cargo test должен сообщить 3 passed. Попробуй заменить score >= 90 на score > 90 и снова запустить тесты: edges_of_a упадёт и покажет left: 'B' и right: 'A'.

2. Протестируй функцию, которая возвращает Option

Заголовок раздела «2. Протестируй функцию, которая возвращает Option»

Напиши fn safe_divide(a: i32, b: i32) -> Option<i32>, которая при b, равном 0, возвращает None вместо паники. Напиши один тест для обычного деления и один для деления на ноль. Подсказка: assert_eq! работает и с Option, так что можно написать assert_eq!(safe_divide(1, 0), None).

Показать решение
fn safe_divide(a: i32, b: i32) -> Option<i32> {
if b == 0 {
None
} else {
Some(a / b)
}
}
fn main() {
println!("{:?}", safe_divide(10, 2));
println!("{:?}", safe_divide(1, 0));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn divides_normally() {
assert_eq!(safe_divide(10, 2), Some(5));
}
#[test]
fn zero_gives_none() {
assert_eq!(safe_divide(1, 0), None);
}
}

Вывод:

Some(5)
None
▶ Попробуй сам

Сравни это с функцией divide выше. Там деление на ноль — это паника, поэтому тесту нужен #[should_panic]. Здесь это просто обычное возвращаемое значение, и хватает простого assert_eq!. Функции, которые возвращают Option или Result, обычно проще тестировать.

  • Пометь функцию #[test], чтобы она стала тестом. Все тесты запускаются через cargo test.
  • assert!, assert_eq! и assert_ne! покрывают большинство повседневных проверок. Выбирай assert_eq!/assert_ne! — у них понятнее сообщения о провале.
  • #[should_panic] проверяет, что функция паникует, когда должна. Добавь expected = "...", чтобы убедиться, что она запаниковала по правильной причине.
  • Добавь своё сообщение в любой макрос assert, чтобы при провале было понятно, что пошло не так.
  • Больше всего пользы от тестов для кода, который ты собираешься менять или использовать повторно.

Проверь себя

1. Какой атрибут помечает функцию как тест, который должен запустить cargo test?

2. Почему assert_eq! и assert_ne! обычно лучше простого assert!(a == b)?

3. Что делает #[should_panic]?

Результат: 0 / 3