Как писать тесты
Тесты — это функции на Rust, которые проверяют, что остальной (не тестовый) код работает как ожидается. Тело тестовой функции обычно делает три вещи:
- готовит нужные данные или состояние;
- запускает код, который нужно проверить;
- проверяет, что результат такой, как ожидалось.
Посмотрим, что Rust даёт специально для написания таких тестов: атрибут
test, несколько макросов и атрибут should_panic.
Устройство тестовой функции
В простейшем случае тест в Rust — это функция с атрибутом test. Атрибуты —
это метаданные о частях кода на Rust; один из них, derive, мы уже
использовали со структурами в главе 5. Чтобы превратить функцию в тестовую,
добавьте #[test] на строке перед fn. Когда вы запускаете тесты командой
cargo test, Rust собирает специальный бинарник для запуска тестов (test
runner), который выполняет помеченные функции и сообщает, прошёл каждый тест
или провалился.
Каждый раз, когда мы создаём через Cargo новый библиотечный проект, в нём автоматически генерируется тестовый модуль с тестовой функцией. Это шаблон для ваших тестов, чтобы не вспоминать каждый раз точную структуру и синтаксис. Тестовых функций и тестовых модулей можно добавлять сколько угодно!
Сначала поэкспериментируем с шаблонным тестом и разберёмся, как устроены тесты. А потом напишем настоящие тесты, которые вызывают наш код и проверяют, что он ведёт себя правильно.
Создадим новый библиотечный проект adder, который будет складывать два
числа:
$ cargo new adder --lib
Created library `adder` project
$ cd adder
Содержимое файла src/lib.rs в библиотеке adder должно выглядеть как в
листинге 11-1.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
cargo newФайл начинается с примера функции add, чтобы было что тестировать.
Пока сосредоточимся только на функции it_works. Обратите внимание на
аннотацию #[test]: этот атрибут говорит, что функция тестовая, и
запускатель тестов будет считать её тестом. В модуле tests могут быть и
обычные функции — например, для подготовки общих сценариев или выполнения
общих операций, — поэтому тесты всегда нужно явно помечать.
Тело функции-примера с помощью макроса assert_eq! проверяет, что result, в
котором лежит результат вызова add с 2 и 2, равен 4. Эта проверка
(assertion) — пример того, как обычно выглядит тест. Запустим его и
убедимся, что он проходит.
Команда cargo test запускает все тесты проекта, как показано в листинге
11-2.
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo скомпилировал и запустил тест. Видим строку running 1 test. Следующая
строка показывает имя сгенерированной тестовой функции — tests::it_works — и
что результат её выполнения — ok. Итоговая строка test result: ok.
означает, что все тесты прошли, а часть 1 passed; 0 failed подсчитывает,
сколько тестов прошло и сколько провалилось.
Тест можно пометить как игнорируемый, чтобы он не запускался в обычных
случаях; об этом — в разделе «Игнорируем тесты, если их не запросили
явно» далее в этой главе. Здесь мы этого не делали,
поэтому в итогах 0 ignored. Ещё можно передать cargo test аргумент, чтобы
запустить только тесты, имя которых совпадает со строкой, — это называется
фильтрацией, и о ней пойдёт речь в разделе «Запуск части тестов по
имени». Здесь мы ничего не фильтровали, поэтому в
конце итогов — 0 filtered out.
Значение 0 measured относится к бенчмаркам — тестам, измеряющим
производительность. На момент написания книги бенчмарки доступны только в
nightly Rust. Подробнее — в документации о бенчмарках.
Следующая часть вывода, начинающаяся с Doc-tests adder, — результаты
документационных тестов. Их у нас пока нет, но Rust умеет компилировать любые
примеры кода из документации к API. Это помогает поддерживать документацию и
код в согласии! О том, как писать документационные тесты, мы поговорим в
разделе «Документационные комментарии как тесты» главы 14. А пока не будем обращать внимания на вывод Doc-tests.
Начнём подстраивать тест под себя. Сначала переименуем функцию it_works,
например, в exploration:
Файл: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
Снова запустите cargo test. Теперь вместо it_works в выводе exploration:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::exploration ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Добавим ещё один тест, но на этот раз — проваливающийся! Тест проваливается,
когда что-то в тестовой функции паникует. Каждый тест запускается в
отдельном потоке, и когда главный поток видит, что поток теста умер, тест
помечается как проваленный. В главе 9 мы говорили, что проще всего вызвать
панику макросом panic!. Добавьте новый тест — функцию another, — чтобы
файл src/lib.rs выглядел как в листинге 11-3.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
panic!Снова запустите тесты через cargo test. Вывод должен быть как в листинге
11-4: тест exploration прошёл, а another — провалился.
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok
failures:
---- tests::another stdout ----
thread 'tests::another' (6019162) panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Вместо ok в строке test tests::another теперь FAILED. Между
результатами отдельных тестов и итогами появились два новых раздела. Первый
подробно объясняет причину провала каждого теста: в нашем случае
tests::another провалился, потому что запаниковал с сообщением
Make this test fail в строке 17 файла src/lib.rs. Второй раздел просто
перечисляет имена всех проваленных тестов — это удобно, когда тестов много и
подробного вывода о провалах тоже много. По имени проваленного теста можно
запустить только его, чтобы проще было отлаживать; подробнее о способах
запуска тестов — в разделе «Управление запуском
тестов».
В конце — итоговая строка: общий результат тестов — FAILED. Один тест
прошёл, один провалился.
Теперь, когда вы видели, как выглядят результаты тестов в разных ситуациях,
посмотрим на другие макросы, кроме panic!, которые пригодятся в тестах.
Проверка результатов с assert!
Макрос assert! из стандартной библиотеки пригодится, когда нужно убедиться,
что какое-то условие в тесте истинно. Ему передаётся аргумент, который
вычисляется в логическое значение. Если оно true, ничего не происходит, и
тест проходит. Если false, макрос assert! вызывает panic!, и тест
проваливается. Макрос assert! помогает проверить, что код работает так, как
мы задумали.
В листинге 5-15 главы 5 у нас были структура Rectangle и метод can_hold —
они повторены в листинге 11-5. Поместим этот код в src/lib.rs и напишем к
нему тесты с макросом assert!.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
Rectangle и её метод can_hold из главы 5Метод can_hold возвращает логическое значение — идеальный случай для
макроса assert!. В листинге 11-6 мы пишем тест для метода can_hold:
создаём экземпляр Rectangle шириной 8 и высотой 7 и проверяем, что он может
вместить другой экземпляр Rectangle шириной 5 и высотой 1.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
}
can_hold, который проверяет, что больший прямоугольник действительно может вместить меньшийОбратите внимание на строку use super::*; внутри модуля tests. Модуль
tests — обычный модуль, который подчиняется обычным правилам видимости,
описанным в разделе «Пути к элементам в дереве
модулей»
главы 7. Модуль tests — внутренний, поэтому тестируемый код из внешнего
модуля нужно подключить в его область видимости. Здесь используется *, так
что всё, что определено во внешнем модуле, доступно модулю tests.
Мы назвали тест larger_can_hold_smaller («больший вмещает меньший») и
создали два нужных экземпляра Rectangle. Затем вызвали макрос assert! и
передали ему результат вызова larger.can_hold(&smaller). Это выражение
должно вернуть true, так что тест должен пройти. Проверим!
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 1 test
test tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Проходит! Добавим ещё один тест — на этот раз проверим, что меньший прямоугольник не может вместить больший:
Файл: src/lib.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
// --пропуск--
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
Правильный результат can_hold в этом случае — false, поэтому перед
передачей в assert! его нужно инвертировать. Тогда тест пройдёт, если
can_hold вернёт false:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Два теста проходят! Теперь посмотрим, что будет с результатами тестов, если
внести в код баг. Изменим реализацию метода can_hold, заменив при сравнении
ширин знак «больше» (>) на «меньше» (<):
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
// --пропуск--
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
Теперь при запуске тестов получаем:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok
failures:
---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' (6020788) panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::larger_can_hold_smaller
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Тесты поймали баг! larger.width равно 8, а smaller.width — 5, поэтому
сравнение ширин в can_hold теперь возвращает false: 8 не меньше 5.
Проверка равенства с assert_eq! и assert_ne!
Частый способ проверить работу кода — сравнить результат тестируемого кода со
значением, которое он должен вернуть. Можно сделать это макросом assert!,
передав ему выражение с оператором ==. Но такая проверка настолько
распространена, что в стандартной библиотеке для неё есть пара макросов —
assert_eq! и assert_ne!. Они проверяют два аргумента на равенство или
неравенство соответственно. А ещё при провале проверки они выводят оба
значения, так что легче понять, почему тест провалился. Макрос assert! же
сообщает лишь, что выражение == дало false, но не показывает значений,
которые к этому привели.
В листинге 11-7 мы пишем функцию add_two, которая прибавляет к параметру
2, и тестируем её макросом assert_eq!.
pub fn add_two(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
add_two макросом assert_eq!Проверим, что тест проходит!
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Мы создаём переменную result с результатом вызова add_two(2) и передаём
result и 4 аргументами в макрос assert_eq!. Строка вывода для этого
теста — test tests::it_adds_two ... ok, и ok означает, что тест прошёл!
Внесём в код баг, чтобы посмотреть, как выглядит провал assert_eq!. Пусть
функция add_two прибавляет 3:
pub fn add_two(a: u64) -> u64 {
a + 3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
Снова запустим тесты:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' (6020955) panicked at src/lib.rs:12:9:
assertion `left == right` failed
left: 5
right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_adds_two
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Тест поймал баг! tests::it_adds_two провалился, и сообщение говорит, что
не выполнилась проверка left == right, а также показывает значения left и
right. Это хорошая отправная точка для отладки: аргумент left, где лежал
результат вызова add_two(2), равен 5, а аргумент right — 4. Нетрудно
представить, как это выручает, когда тестов много.
Заметьте: в некоторых языках и тестовых фреймворках параметры функций проверки
равенства называются expected (ожидаемое) и actual (фактическое), и
порядок аргументов важен. В Rust же они называются left и right, и неважно,
в каком порядке указывать ожидаемое значение и значение, которое выдаёт код.
Проверку в этом тесте можно записать как assert_eq!(4, result) — сообщение о
провале будет тем же: assertion `left == right` failed.
Макрос assert_ne! проходит, если два переданных значения не равны, и
проваливается, если равны. Он особенно полезен, когда мы не знаем, каким
будет значение, но точно знаем, каким оно не должно быть. Например, если
тестируемая функция гарантированно как-то меняет входные данные, но как
именно — зависит от дня недели, в который запускаются тесты, лучше всего
проверить, что выход функции не равен входу.
Под капотом макросы assert_eq! и assert_ne! используют операторы == и
!= соответственно. При провале эти макросы выводят свои аргументы в
отладочном формате, а значит, сравниваемые значения должны реализовывать
трейты PartialEq и Debug. Все примитивные типы и большинство типов
стандартной библиотеки их реализуют. Для собственных структур и
перечислений, чтобы проверять их на равенство, нужно реализовать
PartialEq, а чтобы выводить значения при провале — Debug. Оба трейта
выводимые, как говорилось в листинге 5-12 главы 5, поэтому обычно достаточно
добавить к определению структуры или перечисления аннотацию
#[derive(PartialEq, Debug)]. Подробнее об этих и других выводимых трейтах —
в приложении C, «Выводимые трейты».
Свои сообщения о провале
В макросы assert!, assert_eq! и assert_ne! можно необязательными
аргументами передать собственное сообщение, которое выведется вместе с
сообщением о провале. Все аргументы после обязательных передаются в макрос
format! (о нём — в разделе «Склеивание через + и
format!» главы 8), так что можно передать
строку формата с заполнителями {} и значения для них. Свои сообщения полезны,
чтобы пояснить смысл проверки: когда тест провалится, будет понятнее, в чём
проблема с кодом.
Например, у нас есть функция, которая приветствует человека по имени, и мы хотим проверить, что переданное имя появляется в выводе:
Файл: src/lib.rs
pub fn greeting(name: &str) -> String {
format!("Hello {name}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
Требования к программе ещё не согласованы, и мы почти уверены, что Hello в
начале приветствия поменяется. Мы не хотим править тест при каждом изменении
требований, поэтому проверяем не точное равенство значению, которое вернула
greeting, а лишь то, что вывод содержит текст входного параметра.
Теперь внесём баг: пусть greeting не включает name — и посмотрим, как
выглядит провал теста по умолчанию:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
Запуск теста даёт:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' (6021143) panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Здесь сказано лишь, что проверка не прошла и в какой она строке. Полезнее
было бы вывести значение, которое вернула greeting. Добавим своё сообщение
о провале — строку формата с заполнителем, в который подставится фактическое
значение из greeting:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{result}`"
);
}
}
Теперь при запуске теста сообщение об ошибке информативнее:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' (6021333) panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
В выводе теста видно значение, которое мы получили на самом деле, — это поможет понять, что произошло вместо ожидаемого.
Проверка паники с should_panic
Важно проверять не только возвращаемые значения, но и то, что код
обрабатывает ошибочные ситуации так, как мы ожидаем. Вспомните тип Guess,
который мы создали в листинге 9-13 главы 9. Другой код, использующий Guess,
полагается на гарантию, что экземпляры Guess содержат только значения от 1
до 100. Можно написать тест, который проверит, что попытка создать Guess со
значением вне этого диапазона вызывает панику.
Для этого к тестовой функции добавляют атрибут should_panic. Тест проходит,
если код внутри функции паникует, и проваливается, если не паникует.
В листинге 11-8 — тест, проверяющий, что ошибочные ситуации в Guess::new
возникают тогда, когда мы ожидаем.
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
panic!Атрибут #[should_panic] ставится после атрибута #[test] и перед тестовой
функцией, к которой относится. Посмотрим на результат, когда тест проходит:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests guessing_game
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Выглядит отлично! Теперь внесём баг: уберём условие, при котором функция new
паникует, если значение больше 100:
pub struct Guess {
value: i32,
}
// --пропуск--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
Тест из листинга 11-8 провалится:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
note: test did not panic as expected at src/lib.rs:21:8
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Сообщение здесь не очень информативное, но, взглянув на тестовую функцию, мы
увидим, что она помечена #[should_panic]. Значит, провал означает, что код в
тестовой функции не вызвал панику.
Тесты с should_panic бывают неточными: такой тест пройдёт, даже если код
запаникует совсем по другой причине, чем мы ожидали. Чтобы сделать тесты с
should_panic точнее, в атрибут should_panic можно добавить необязательный
параметр expected. Тогда тестовая обвязка проверит, что сообщение о панике
содержит указанный текст. Например, в листинге 11-9 изменённый код Guess,
где функция new паникует с разными сообщениями в зависимости от того,
слишком мало значение или слишком велико.
pub struct Guess {
value: i32,
}
// --пропуск--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
panic! вызывается с сообщением, содержащим указанную подстрокуЭтот тест пройдёт: значение параметра expected в атрибуте should_panic —
подстрока сообщения, с которым паникует Guess::new. Можно было указать и
всё ожидаемое сообщение целиком — в данном случае
Guess value must be less than or equal to 100, got 200. Что именно указывать,
зависит от того, какая часть сообщения уникальна или меняется и насколько
точным должен быть тест. Здесь подстроки достаточно, чтобы убедиться, что код
в тестовой функции выполняет ветку else if value > 100.
Чтобы увидеть, что происходит при провале теста should_panic с expected,
снова внесём баг: поменяем местами тела блоков if value < 1 и
else if value > 100:
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
Теперь тест should_panic провалится:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' (6021675) panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: "Guess value must be greater than or equal to 1, got 200."
expected substring: "less than or equal to 100"
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Сообщение говорит, что тест действительно запаниковал, как и ожидалось, но в
сообщении о панике нет ожидаемой строки less than or equal to 100. На самом
деле сообщение было Guess value must be greater than or equal to 1, got 200.
Теперь можно разбираться, где баг!
Result<T, E> в тестах
До сих пор все наши тесты при провале паниковали. Но можно писать и тесты,
использующие Result<T, E>! Вот тест из листинга 11-1, переписанный так, чтобы
использовать Result<T, E> и возвращать Err вместо паники:
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
Теперь функция it_works возвращает Result<(), String>. Вместо вызова
макроса assert_eq! она возвращает Ok(()), если тест прошёл, и Err со
String внутри, если провалился.
Тесты, возвращающие Result<T, E>, позволяют использовать в теле оператор
?. Это удобно, когда тест должен провалиться, если любая операция внутри
него вернёт Err.
К тестам, использующим Result<T, E>, нельзя применять аннотацию
#[should_panic]. Чтобы проверить, что операция возвращает Err, не
применяйте к значению Result<T, E> оператор ?, а используйте
assert!(value.is_err()).
Теперь, когда вы знаете несколько способов писать тесты, посмотрим, что
происходит при их запуске, и разберём разные настройки cargo test.