Організація тестів
Як згадувалося на початку розділу, тестування є складною дисципліною, і різні люди використовують різну термінологію та організацію. Спільнота Rust розглядає тести в термінах двох основних категорій: модульні тести (unit tests) та інтеграційні тести (integration tests). Модульні тести (unit tests) є невеликими та більш сфокусованими, тестують один модуль ізоляційно за раз і можуть тестувати приватні інтерфейси. Інтеграційні тести (integration tests) є повністю зовнішніми щодо вашої бібліотеки та використовують ваш код так само, як будь-який інший зовнішній код, використовуючи лише публічний інтерфейс і потенційно задіюючи кілька модулів на один тест.
Написання обох видів тестів важливе, щоб переконатися, що частини вашої бібліотеки роблять те, чого ви від них очікуєте, окремо і разом.
Модульні тести (Unit Tests)
Мета модульних тестів (unit tests) полягає в тому, щоб тестувати кожну одиницю коду ізоляційно від
решти коду, щоб швидко визначити, де код працює, а де — ні, як очікується.
Ви розміщуватимете модульні тести (unit tests) у каталозі src у кожному файлі з
кодом, який вони тестують. Зазвичай створюють модуль із назвою tests
у кожному файлі, щоб містити функції тестів, і позначають модуль
cfg(test).
Модуль tests і #[cfg(test)]
Анотація #[cfg(test)] на модулі tests каже Rust компілювати й
запускати код тестів лише коли ви запускаєте cargo test, а не коли ви
запускаєте cargo build. Це заощаджує час компіляції, коли ви лише хочете зібрати
бібліотеку, і заощаджує місце в результативному скомпільованому артефакті,
оскільки тести не включаються. Ви побачите, що оскільки інтеграційні тести (integration tests)
розміщуються в іншому каталозі, їм не потрібна анотація #[cfg(test)].
Однак, оскільки модульні тести (unit tests) перебувають у тих самих файлах, що й код, ви
використовуватимете #[cfg(test)], щоб указати, що їх не слід включати
до скомпільованого результату.
Згадайте, що коли ми згенерували новий проєкт adder у першому розділі
цього розділу, Cargo згенерував для нас цей код:
Filename: src/lib.rs
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);
}
}
У автоматично згенерованому модулі tests атрибут cfg означає
configuration і каже Rust, що наступний елемент слід включати
лише за наявності певної опції конфігурації. У цьому випадку опція конфігурації —
test, яку Rust надає для компіляції та запуску тестів. Використовуючи
атрибут cfg, Cargo компілює наш код тестів лише якщо ми активно запускаємо тести
за допомогою cargo test. Це включає будь-які допоміжні функції, які можуть
бути в цьому модулі, на додаток до функцій, позначених #[test].
Тести приватних функцій
У спільноті тестування точаться суперечки про те, чи слід тестувати приватні
функції безпосередньо, і в інших мовах це робить тестування приватних функцій
складним або неможливим. Незалежно від того, якої ідеології тестування ви
дотримуєтеся, правила приватності Rust дозволяють вам тестувати приватні функції.
Розгляньте код у Listing 11-12 із приватною функцією internal_adder.
pub fn add_two(a: u64) -> u64 {
internal_adder(a, 2)
}
fn internal_adder(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internal() {
let result = internal_adder(2, 2);
assert_eq!(result, 4);
}
}
Зверніть увагу, що функцію internal_adder не позначено як pub. Тести — це просто
код Rust, а модуль tests — це просто ще один модуль. Як ми обговорювали в
“Шляхи для звернення до елемента в дереві модулів”,
елементи в дочірніх модулях можуть використовувати елементи в їхніх предківських модулях. У
цьому тесті ми вводимо всі елементи, що належать предківському модулю модуля tests,
у область видимості за допомогою use super::*, а потім тест може викликати internal_adder.
Якщо ви не вважаєте, що приватні функції слід тестувати, у Rust немає нічого,
що змусило б вас це робити.
Інтеграційні тести (Integration Tests)
У Rust integration tests є повністю зовнішніми щодо вашої бібліотеки. Вони використовують вашу бібліотеку так само, як це робив би будь-який інший код, а це означає, що вони можуть викликати лише функції, які є частиною публічного API вашої бібліотеки. Їхня мета — перевірити, чи багато частин вашої бібліотеки працюють разом коректно. Одиниці коду, які працюють коректно самі по собі, можуть мати проблеми під час інтеграції, тому покриття інтегрованого коду тестами також важливе. Щоб створити інтеграційні тести (integration tests), спочатку вам потрібен каталог tests.
Каталог tests
Ми створюємо каталог tests на верхньому рівні каталогу нашого проєкту, поруч із src. Cargo знає, що слід шукати файли інтеграційних тестів (integration test) у цьому каталозі. Потім ми можемо створити стільки файлів тестів, скільки хочемо, а Cargo компілюватиме кожен із файлів як окремий крейт (crate).
Давайте створимо інтеграційний тест (integration test). Якщо код у Listing 11-12 досі знаходиться у файлі src/lib.rs, створіть каталог tests і створіть новий файл із назвою tests/integration_test.rs. Структура вашого каталогу має виглядати так:
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── integration_test.rs
Введіть код із Listing 11-13 у файл tests/integration_test.rs.
use adder::add_two;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
Кожен файл у каталозі tests є окремим крейтом (crate), тому нам потрібно ввести нашу
бібліотеку в область видимості кожного тестового крейта (test crate). З цієї причини ми додаємо use adder::add_two; на початку коду, чого нам не потрібно було робити в модульних тестах (unit tests).
Нам не потрібно позначати будь-який код у tests/integration_test.rs як
#[cfg(test)]. Cargo особливо обробляє каталог tests і компілює файли
в цьому каталозі лише коли ми запускаємо cargo test. Запустіть cargo test зараз:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)
running 1 test
test 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
Три секції виводу включають модульні тести (unit tests), інтеграційні тести (integration test) і doc tests. Зверніть увагу, що якщо будь-який тест у секції завершується невдачею, наступні секції не будуть запущені. Наприклад, якщо модульний тест (unit test) завершується невдачею, не буде жодного виводу для інтеграційних (integration) і doc tests, тому що ці тести будуть запущені лише якщо всі модульні тести (unit tests) успішно проходять.
Перша секція для модульних тестів (unit tests) така сама, як ми вже бачили: один рядок
для кожного модульного тесту (unit test) (один із назвою internal, який ми додали в Listing 11-12),
а потім підсумковий рядок для модульних тестів (unit tests).
Секція інтеграційних тестів (integration tests) починається з рядка Running tests/integration_test.rs. Далі є рядок для кожної функції тесту в
цьому інтеграційному тесті (integration test) і підсумковий рядок для результатів інтеграційного тесту (integration test)
безпосередньо перед початком секції Doc-tests adder.
Кожен файл інтеграційного тесту (integration test) має власну секцію, тож якщо ми додамо більше файлів у каталог tests, буде більше секцій інтеграційних тестів (integration test).
Ми й надалі можемо запускати певну функцію інтеграційного тесту (integration test), указавши
назву функції тесту як аргумент до cargo test. Щоб запустити всі тести в
певному файлі інтеграційного тесту (integration test), використовуйте аргумент --test команди cargo test
після назви файлу:
$ cargo test --test integration_test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Ця команда запускає лише тести у файлі tests/integration_test.rs.
Підмодулі в integration tests
У міру додавання більшої кількості інтеграційних тестів (integration tests), ви можете захотіти створити більше файлів у каталозі tests, щоб допомогти їх організувати; наприклад, ви можете групувати функції тестів за функціональністю, яку вони тестують. Як згадувалося раніше, кожен файл у каталозі tests компілюється як власний окремий крейт (crate), що корисно для створення окремих областей видимості, щоб точніше імітувати спосіб, у який кінцеві користувачі використовуватимуть ваш крейт (crate). Однак це означає, що файли у каталозі tests не розділяють ту саму поведінку, що й файли у src, як ви дізналися в Розділі 7 щодо того, як розділяти код на модулі та файли.
Різна поведінка файлів каталогу tests найбільш помітна, коли у вас є набір
допоміжних функцій для використання в кількох файлах інтеграційних тестів (integration test), і
ви намагаєтеся дотримуватися кроків у розділі “Separating Modules into Different
Files” Розділу 7, щоб
винести їх у спільний модуль. Наприклад, якщо ми створимо tests/common.rs
і розмістимо в ньому функцію з назвою setup, ми можемо додати до setup код,
який хочемо викликати з кількох функцій тестів у кількох файлах тестів:
Filename: tests/common.rs
pub fn setup() {
// setup code specific to your library's tests would go here
}
Коли ми знову запустимо тести, ми побачимо нову секцію у виводі тестів для
файлу common.rs, хоча цей файл не містить жодних функцій тестів і ми
не викликали функцію setup звідки-небудь:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)
running 1 test
test 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
Те, що common з’являється в результатах тестів із running 0 tests,
не є тим, чого ми хотіли. Ми просто хотіли поділитися деяким кодом з іншими
файлами integration test. Щоб уникнути появи common у виводі тестів,
замість створення tests/common.rs ми створимо tests/common/mod.rs. Тепер
структура каталогу проєкту виглядає так:
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
├── common
│ └── mod.rs
└── integration_test.rs
Це старіша угода про іменування, яку Rust також розуміє, про яку ми згадували
в розділі “Alternate File Paths” у Розділі 7. Називання
файлу таким чином каже Rust не розглядати модуль common як файл інтеграційного тесту (integration test).
Коли ми переносимо код функції setup у tests/common/mod.rs і
видаляємо файл tests/common.rs, секція у виводі тестів більше
не з’являтиметься. Файли в підкаталогах каталогу tests не компілюються як
окремі крейти (crates) і не мають секцій у виводі тестів.
Після того як ми створили tests/common/mod.rs, ми можемо використовувати його з будь-якого
файлу інтеграційного тесту (integration test) як модуль. Ось приклад виклику функції setup
із тесту it_adds_two у tests/integration_test.rs:
Filename: tests/integration_test.rs
use adder::add_two;
mod common;
#[test]
fn it_adds_two() {
common::setup();
let result = add_two(2);
assert_eq!(result, 4);
}
Зверніть увагу, що оголошення mod common; є таким самим, як оголошення модуля,
яке ми демонстрували в Listing 7-21. Потім у функції тесту ми можемо викликати
функцію common::setup().
Інтеграційні тести (Integration Tests) для бінарного крейта (binary crate)
Якщо наш проєкт є бінарним крейтом (binary crate), який містить лише файл src/main.rs і
не має файлу src/lib.rs, ми не можемо створити інтеграційні тести (integration tests) у каталозі
tests і ввести функції, визначені у файлі src/main.rs, в область видимості
за допомогою оператора use. Лише бібліотечні крейти (library crates) експонують функції, які інші
крейти (crates) можуть використовувати; бінарні крейти (binary crates) призначені для запуску самостійно.
Це одна з причин, чому проєкти Rust, які надають binary, мають
простий файл src/main.rs який викликає логіку, що живе у файлі
src/lib.rs. Використовуючи таку структуру, інтеграційні тести (integration tests) можуть
тестувати бібліотечний крейт (library crate) за допомогою use, щоб зробити важливу функціональність
доступною. Якщо важлива функціональність працює, невеликий обсяг коду у файлі
src/main.rs також працюватиме, і цей невеликий обсяг коду не потрібно
тестувати.
Підсумок
Можливості тестування Rust надають спосіб указати, як код має функціонувати, щоб гарантувати, що він продовжує працювати так, як ви очікуєте, навіть коли ви вносите зміни. Модульні тести (unit tests) окремо перевіряють різні частини бібліотеки і можуть тестувати приватні деталі реалізації. Інтеграційні тести (integration tests) перевіряють, що багато частин бібліотеки працюють разом коректно, і вони використовують публічний API бібліотеки, щоб тестувати код так само, як зовнішній код використовуватиме його. Хоча система типів Rust і правила власності допомагають запобігати деяким видам помилок, тести все ще важливі, щоб зменшити логічні помилки, пов’язані з тим, як ваш код має поводитися.
Давайте поєднаємо знання, які ви отримали в цьому розділі та в попередніх розділах, щоб попрацювати над проєктом!