Як писати тести
Тести — це функції Rust, які перевіряють, що не-тестовий код працює очікуваним чином. Тіла тестових функцій зазвичай виконують ці три дії:
- Налаштовують будь-які потрібні дані або стан.
- Запускають код, який ви хочете протестувати.
- Перевіряють, що результати такі, як ви очікуєте.
Розгляньмо можливості, які Rust надає спеціально для написання тестів, що виконують ці дії, зокрема атрибут test, кілька макросів і атрибут should_panic.
Структурування тестових функцій
У найпростішому вигляді тест у Rust — це функція, позначена атрибутом test. Атрибути — це метадані про частини коду Rust; одним із прикладів є атрибут derive, який ми використовували зі структурами в Розділі 5. Щоб перетворити функцію на тестову функцію, додайте #[test] у рядку перед fn. Коли ви запускаєте тести за допомогою команди cargo test, Rust будує двійковий файл запускника (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);
}
}
Файл починається з прикладу функції add, щоб у нас було що тестувати.
Наразі зосередьмося лише на функції it_works. Зверніть увагу на анотацію #[test]: цей атрибут указує, що це тестова функція, тож runner тестів знає, що треба розглядати цю функцію як тест. У модулі tests у нас також можуть бути нетестові функції, щоб допомагати налаштовувати спільні сценарії або виконувати спільні операції, тому нам завжди потрібно вказувати, які функції є тестами.
Тіло прикладної функції використовує макрос assert_eq!, щоб перевірити, що result, який містить результат виклику add з 2 і 2, дорівнює 4. Це твердження слугує прикладом формату типового тесту. Запустімо його, щоб побачити, що цей тест проходить.
Команда cargo test запускає всі тести в нашому проєкті, як показано в Listing 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, підсумовує кількість тестів, що пройшли або не пройшли.
Можна позначити тест як ignored, щоб він не запускався в конкретному випадку; ми розглянемо це в розділі «Ігнорування тестів, якщо явно не запитано» пізніше в цій главі. Оскільки ми цього тут не робили, підсумок показує 0 ignored. Ми також можемо передати аргумент команді cargo test, щоб запускати лише тести, чиї імена збігаються з рядком; це називається filtering, і ми розглянемо це в розділі «Запуск підмножини тестів за іменем». Тут ми не фільтрували тести, які запускаються, тож у кінці підсумку показано 0 filtered out.
Статистика 0 measured призначена для benchmark-тестів, які вимірюють продуктивність. Benchmark-тести, на момент написання цього тексту, доступні лише в нічній (nightly) версії Rust. Докладніше дивіться документацію про benchmark-тести.
Наступна частина виводу тесту, що починається з Doc-tests adder, стосується результатів будь-яких тестів документації (documentation tests). У нас поки що немає тестів документації, але Rust може скомпілювати будь-які приклади коду, що з’являються в нашій документації API. Ця можливість допомагає підтримувати синхронізацію між документацією та кодом! Ми обговоримо, як писати тести документації, у розділі «Коментарі документації як тести» в Розділі 14. Наразі проігноруємо вивід Doc-tests.
Почнімо налаштовувати тест під наші потреби. Спочатку змініть ім’я функції it_works на інше, наприклад exploration, ось так:
Filename: 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
Тепер ми додамо ще один тест, але цього разу створимо тест, який не пройде! Тести не проходять, коли щось у тестовій функції спричиняє паніку (panic). Кожен тест запускається в новому потоці (thread), і коли головний потік бачить, що тестовий потік завершився, тест позначається як неуспішний. У Розділі 9 ми говорили про те, що найпростіший спосіб викликати паніку — це викликати макрос panic!. Введіть новий тест як функцію з іменем another, щоб ваш файл src/lib.rs виглядав як у Listing 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");
}
}
Запустіть тести знову за допомогою cargo test. Вивід має виглядати як у Listing 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. Між окремими результатами та підсумком з’являються два нові розділи: перший показує докладну причину кожного невдалого (failure) тесту. У цьому випадку ми отримуємо деталі, що tests::another не пройшов, тому що спричинив паніку (panic) із повідомленням Make this test fail у рядку 17 файлу src/lib.rs. Наступний розділ перелічує лише імена всіх тестів, що не пройшли, що корисно, коли є багато тестів і багато докладного виводу про збої. Ми можемо використати ім’я тесту, що не пройшов, щоб запустити лише цей тест і легше налагодити його; про способи запуску тестів ми ще поговоримо в Розділі «Керування тим, як запускаються тести».
Рядок підсумку відображається наприкінці: загалом наш результат тестів — FAILED. Один тест пройшов і один тест не пройшов.
Тепер, коли ви побачили, як виглядають результати тестів у різних сценаріях, розгляньмо деякі інші макроси, окрім panic!, які корисні в тестах.
Перевірка результатів за допомогою assert!
Макрос assert!, наданий стандартною бібліотекою, корисний, коли ви хочете переконатися, що деяка умова в тесті обчислюється як true. Ми передаємо макросу assert! аргумент, який обчислюється до Boolean. Якщо значення true, нічого не відбувається, і тест проходить. Якщо значення false, макрос assert! викликає panic!, щоб спричинити збій тесту. Використання макроса assert! допомагає нам перевіряти, що наш код працює так, як ми задумали.
У Розділі 5, Listing 5-15, ми використовували структуру Rectangle і метод can_hold, які повторюються тут у Listing 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
}
}
Метод can_hold повертає Boolean, а отже це ідеальний випадок використання макроса 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));
}
}
Зверніть увагу на рядок 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
Він справді проходить! Додаймо ще один тест, цього разу стверджуючи, що менший прямокутник не може вмістити більший прямокутник:
Filename: 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, замінивши знак більшого (>) на знак меншого (<) під час порівняння ширин:
#[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));
}
}
Запуск тестів тепер дає таке:
$ 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: u64) -> u64 {
a + 2
}
#[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.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' 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. Можна уявити, що це було б особливо корисно, коли в нас відбувається багато тестів.
Зверніть увагу, що в деяких мовах і тестових фреймворках (frameworks) параметри функцій перевірки рівності називаються expected і actual, і порядок, у якому ми вказуємо аргументи, має значення. Однак у Rust вони називаються left і right, і порядок, у якому ми вказуємо значення, яке очікуємо, та значення, яке створює код, не має значення. Ми могли б написати перевірку в цьому тесті як assert_eq!(4, result), що дало б те саме повідомлення про збій, яке показує assertion `left == right` failed.
Макрос assert_ne! пройде, якщо два значення, які ми йому передаємо, не рівні, і не пройде, якщо вони рівні. Цей макрос найкорисніший у випадках, коли ми не впевнені, яким буде значення, але знаємо, яким воно точно не повинно бути. Наприклад, якщо ми тестуємо функцію, яка гарантовано змінює свій вхід певним чином, але спосіб, у який вхід змінюється, залежить від дня тижня, коли ми запускаємо тести, найкраще було б стверджувати, що вихід функції не дорівнює входу.
На нижчому рівні макроси assert_eq! і assert_ne! використовують оператори == і != відповідно. Коли перевірки не проходять, ці макроси виводять свої аргументи, використовуючи налагоджувальне форматування (debug formatting), що означає, що значення, які порівнюються, повинні реалізовувати трейти PartialEq і Debug. Усі примітивні типи та більшість типів стандартної бібліотеки реалізують ці трейти. Для структур і переліків, які ви визначаєте самі, вам потрібно буде реалізувати PartialEq, щоб перевіряти рівність цих типів. Вам також потрібно буде реалізувати Debug, щоб виводити значення, коли перевірка не проходить. Оскільки обидва трейти можна автоматично вивести (derivable traits), як згадувалося в Лістингу (Listing) 5-12 у Розділі 5, зазвичай це так само просто, як додати анотацію #[derive(PartialEq, Debug)] до визначення вашої структури або переліку. Дивіться Додаток C, «Трейти, які можна вивести», щоб дізнатися більше про ці та інші трейти, які можна вивести.
Додавання власних повідомлень про збій
Ви також можете додати власне повідомлення, яке буде виводитися разом із повідомленням про збій, як необов’язкові аргументи до макросів assert!, assert_eq! і assert_ne!. Будь-які аргументи, вказані після обов’язкових аргументів, передаються далі макросу format! (обговорюється в розділі «Конкатенація за допомогою + або format!» у Розділі 8), тож ви можете передати рядок формату, який містить заповнювачі {}
та значення для цих заповнювачів. Власні повідомлення корисні для документування того, що означає перевірка; коли тест не проходить, у вас буде краще уявлення про те, у чому проблема з кодом.
Наприклад, припустімо, що в нас є функція, яка вітає людей на ім’я, і ми хочемо перевірити, що ім’я, яке ми передаємо у функцію, з’являється у виводі:
Filename: 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' 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`
Ми бачимо значення, яке насправді отримали у виводі тесту, що допоможе нам налагодити те, що сталося, замість того, що, як ми очікували, мало статися.
Перевірка panic за допомогою should_panic
Окрім перевірки значень, що повертаються, важливо перевіряти, що наш код обробляє умови помилки так, як ми очікуємо. Наприклад, розгляньте тип Guess, який ми створили в Розділі 9, Лістингу (Listing) 9-13. Інший код, що використовує Guess, покладається на гарантію, що екземпляри Guess міститимуть лише значення між 1 і 100. Ми можемо написати тест, який переконується, що спроба створити екземпляр Guess зі значенням поза цим діапазоном спричиняє паніку (panic).
Ми робимо це, додаючи атрибут should_panic до нашої тестової функції. Тест проходить, якщо код усередині функції спричиняє panic; тест не проходить, якщо код усередині функції не спричиняє panic.
Лістинг (Listing) 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);
}
}
Ми розміщуємо атрибут #[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 спричинятиме panic, якщо значення більше за 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 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]. Отриманий нами збій означає, що код у тестовій функції не спричинив паніку (panic).
Тести, які використовують should_panic, можуть бути неточними. Тест should_panic пройде навіть тоді, коли тест спричиняє паніку з іншої причини, ніж та, яку ми очікували. Щоб зробити тести should_panic точнішими, ми можемо додати необов’язковий параметр expected до атрибута should_panic. Тестове середовище (test harness) переконається, що повідомлення про збій містить наданий текст. Наприклад, розгляньте змінений код для Guess у Лістингу 11-9, де функція new спричиняє паніку з різними повідомленнями залежно від того, чи значення надто мале, чи надто велике.
pub struct Guess {
value: i32,
}
// ANCHOR: here
// --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);
}
}
// ANCHOR_END: here
Цей тест пройде, тому що значення, яке ми помістили в параметр expected атрибута should_panic, є підрядком повідомлення, з яким функція Guess::new спричиняє panic. Ми могли б указати й усе повідомлення про panic, яке очікуємо, і в цьому випадку воно було б Guess value must be less than or equal to 100, got 200. Те, що ви обираєте вказувати, залежить від того, яка частина повідомлення про panic є унікальною або динамічною і наскільки точним ви хочете зробити свій тест. У цьому випадку підрядка повідомлення про panic достатньо, щоб переконатися, що код у тестовій функції виконує випадок 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`
Повідомлення про збій вказує, що цей тест справді спричинив panic, як ми й очікували, але повідомлення про panic не містило очікуваного рядка less than or equal to 100. Повідомлення про panic, яке ми отримали в цьому випадку, було Guess value must be greater than or equal to 1, got 200. Тепер ми можемо почати з’ясовувати, де наша помилка!
Використання Result<T, E> у тестах
Усі наші тести досі не проходять, коли виникає паніка (panic). Ми також можемо писати тести, які використовують 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.