Как писать тесты
Тесты — это функции на Rust, которые проверяют, что основной код работает ожидаемым образом. Тело тестовой функции обычно выполняет три действия:
- Подготавливает необходимые данные или состояние.
- Запускает код, который нужно протестировать.
- Проверяет, что результаты соответствуют ожиданиям.
Давайте рассмотрим возможности Rust, специально предназначенные для написания
тестов, которые выполняют эти действия. К ним относятся атрибут test,
несколько макросов и атрибут should_panic.
Структура тестовой функции
Проще всего, тест на Rust — это функция, аннотированная атрибутом test.
Атрибуты — это метаданные о фрагментах кода на Rust; один из примеров —
атрибут derive, который мы использовали со структурами в Главе 5. Чтобы
превратить функцию в тестовую, добавьте #[test] на строку перед fn. Когда
вы запускаете тесты командой cargo test, Rust собирает бинарный файл
тестового раннера, который выполняет аннотированные функции и сообщает,
прошла каждая тестовая функция или нет.
Каждый раз, когда мы создаём новый библиотечный проект с помощью 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. Эта проверка
служит примером формата типичного теста. Давайте запустим его, чтобы
убедиться, что тест проходит.
Команда 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. Ознакомьтесь с документацией о бенчмарк-тестах,
чтобы узнать больше.
Мы можем передать аргумент команде cargo test для запуска только тех тестов,
имя которых совпадает с заданной строкой; это называется фильтрацией, и мы
рассмотрим это в разделе «Запуск подмножества тестов по имени». Здесь мы не фильтровали запускаемые тесты, поэтому в конце сводки
показано 0 filtered out.
Следующая часть вывода теста, начиная с 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. Вывод теперь показывает exploration
вместо it_works:
$ 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' 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. Между
индивидуальными результатами и сводкой появляются два новых раздела: первый
отображает подробную причину каждого сбоя теста. В этом случае мы получаем
подробности, что another не прошёл, потому что он panicked at 'Make this test fail' в строке 17 файла src/lib.rs. Следующий раздел содержит только имена
всех неудачных тестов, что полезно, когда тестов много и много подробного
вывода о неудачных тестах. Мы можем использовать имя неудачного теста, чтобы
запустить только этот тест и легче его отладить; мы поговорим больше о способах
запуска тестов в разделе «Управление запуском тестов».
Итоговая строка отображается в конце: в целом, результат нашего теста —
FAILED. У нас был один прошедший тест и один проваленный.
Теперь, когда вы видели, как выглядят результаты тестов в разных сценариях,
давайте рассмотрим некоторые макросы, кроме panic!, которые полезны в тестах.
Проверка результатов с помощью макроса assert!
Макрос assert!, предоставляемый стандартной библиотекой, полезен, когда вы
хотите убедиться, что некоторое условие в тесте вычисляется как true. Мы
передаём макросу assert! аргумент, который вычисляется как логическое
значение. Если значение true, ничего не происходит и тест проходит. Если
значение false, макрос assert! вызывает panic!, чтобы тест не прошёл.
Использование макроса assert! помогает нам проверять, что наш код работает
так, как мы задумали.
В Главе 5, Листинг 5-15, мы использовали структуру 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 — это внутренний модуль, нам нужно сделать код,
который тестируем во внешнем модуле, доступным в области видимости внутреннего
модуля. Мы используем здесь glob-импорт, поэтому всё, что мы определили во
внешнем модуле, доступно этому модулю 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() {
// --snip--
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, заменив знак больше-than на знак меньше-than при сравнении ширин:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
// --snip--
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));
}
}
Запуск тестов теперь produces:
$ 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' 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 для выражения ==, не выводя значения, которые привели к
false.
В Листинге 11-7 мы пишем функцию с именем add_two, которая прибавляет 2 к
своему параметру, а затем тестируем эту функцию с помощью макроса assert_eq!.
pub fn add_two(a: usize) -> usize {
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: usize) -> usize {
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' 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`
Наши тесты обнаружили ошибку! Тест it_adds_two не прошёл, и сообщение
сообщает нам, что проверка, которая не прошла, — это assertion `left == right` failed и каковы значения left и right. Это сообщение помогает нам
начать отладку: аргумент left, где у нас был результат вызова add_two(2),
был 5, а аргумент right был 4. Вы можете представить, что это будет
особенно полезно, когда у нас много тестов.
Обратите внимание, что в некоторых языках и тестовых фреймворках параметры
функций проверки равенства называются expected и actual, и порядок, в
котором мы указываем аргументы, имеет значение. Однако в Rust они называются
left и right, и порядок, в котором мы указываем ожидаемое значение и
значение, которое производит код, не имеет значения. Мы могли бы написать эту
проверку как assert_eq!(add_two(2), result), что дало бы то же самое
сообщение о сбое, отображающее assertion failed: `(left == right)`.
Макрос 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"));
}
}
Запуск этого теста produces:
$ 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' 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' 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, Листинг 9-13. Другой код, использующий 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,
}
// --snip--
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
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 к атрибуту should_panic.
Тестовый раннер убедится, что сообщение о сбое содержит предоставленный текст.
Например, рассмотрим изменённый код для Guess в Листинге 11-9, где функция
new паникует с разными сообщениями в зависимости от того, значение слишком
мало или слишком велико.
pub struct Guess {
value: i32,
}
// --snip--
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' 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.
Вы не можете использовать аннотацию #[should_panic] на тестах, которые
используют Result<T, E>. Чтобы проверить, что операция возвращает вариант
Err, не используйте оператор вопроса на значении Result<T, E>. Вместо
этого используйте assert!(value.is_err()).
Теперь, когда вы знаете несколько способов писать тесты, давайте посмотрим,
что происходит, когда мы запускаем наши тесты, и изучим различные опции,
которые мы можем использовать с cargo test.