Организация тестов
Как упоминалось в начале главы, тестирование — сложная дисциплина, и разные люди используют разную терминологию и организацию. Сообщество Rust рассматривает тесты в рамках двух основных категорий: модульные тесты и интеграционные тесты. Модульные тесты (unit tests) небольшие и более сфокусированные, они тестируют один модуль изоляционно и могут проверять приватные интерфейсы. Интеграционные тесты (integration tests) полностью внешние по отношению к вашей библиотеке и используют ваш код так же, как и любой другой внешний код, используя только публичный интерфейс и потенциально охватывая несколько модулей за один тест.
Написание обоих видов тестов важно для обеспечения того, что части вашей библиотеки работают так, как вы ожидаете, по отдельности и вместе.
Модульные тесты
Цель модульных тестов — протестировать каждую единицу кода изолированно от остального кода, чтобы быстро определить, где код работает, а где — нет. Вы размещаете модульные тесты в директории src в каждом файле с кодом, который они тестируют. Соглашение — создавать модуль с именем tests в каждом файле для содержания тестовых функций и аннотировать модуль cfg(test).
Модуль tests и #[cfg(test)]
Аннотация #[cfg(test)] на модуле tests говорит Rust компилировать и запускать тестовый код только при выполнении cargo test, а не при cargo build. Это экономит время компиляции, когда вы хотите только собрать библиотеку, и экономит место в итоговом скомпилированном артефакте, так как тесты не включаются. Вы увидите, что поскольку интеграционные тесты находятся в другой директории, им не нужна аннотация #[cfg(test)]. Однако, поскольку модульные тесты находятся в тех же файлах, что и код, вы будете использовать #[cfg(test)] чтобы указать, что они не должны включаться в скомпилированный результат.
Напомним, что когда мы создали новый проект adder в первом разделе этой главы, Cargo сгенерировал для нас этот код:
Имя файла: 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 позволяют тестировать приватные функции. Рассмотрим код в Листинге 11-12 с приватной функцией internal_adder.
pub fn add_two(a: usize) -> usize {
internal_adder(a, 2)
}
fn internal_adder(left: usize, right: usize) -> usize {
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 не заставит вас это делать.
Интеграционные тесты
В Rust интеграционные тесты полностью внешние по отношению к вашей библиотеке. Они используют вашу библиотеку так же, как и любой другой код, что означает, что они могут вызывать только функции, которые являются частью публичного API вашей библиотеки. Их цель — проверить, работают ли многие части вашей библиотеки вместе правильно. Единицы кода, которые работают корректно по отдельности, могут иметь проблемы при интеграции, поэтому покрытие тестами интегрированного кода также важно. Для создания интеграционных тестов сначала нужна директория tests.
Директория tests
Мы создаём директорию tests на верхнем уровне нашей директории проекта, рядом с src. Cargo знает, что нужно искать файлы интеграционных тестов в этой директории. Затем мы можем создавать столько файлов тестов, сколько хотим, и Cargo будет компилировать каждый из файлов как отдельный крейт.
Давайте создадим интеграционный тест. С кодом из Листинга 11-12 всё ещё в файле src/lib.rs, создайте директорию tests и создайте новый файл с именем tests/integration_test.rs. Ваша структура директорий должна выглядеть так:
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── integration_test.rs
Введите код из Листинга 11-13 в файл tests/integration_test.rs.
use adder::add_two;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
adderКаждый файл в директории tests — это отдельный крейт, поэтому нам нужно подключать нашу библиотеку в область видимости каждого тестового крейта. По этой причине мы добавляем use adder::add_two; в начало кода, что не требовалось в модульных тестах.
Нам не нужно аннотировать какой-либо код в 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
Три раздела вывода включают модульные тесты, интеграционный тест и тесты документации. Обратите внимание, что если какой-либо тест в разделе завершится неудачей, последующие разделы не будут запущены. Например, если модульный тест завершится неудачей, не будет никакого вывода для интеграционных тестов и тестов документации, потому что эти тесты будут запущены только если все модульные тесты проходят.
Первый раздел для модульных тестов такой же, как мы видели: одна строка для каждого модульного теста (одна с именем internal, которую мы добавили в Листинге 11-12) и затем итоговая строка для модульных тестов.
Раздел интеграционных тестов начинается со строки Running tests/integration_test.rs. Далее есть строка для каждой тестовой функции в этом интеграционном тесте и итоговая строка для результатов интеграционного теста прямо перед началом раздела Doc-tests adder.
Каждый файл интеграционного теста имеет свой собственный раздел, поэтому если мы добавим больше файлов в директорию tests, будет больше разделов интеграционных тестов.
Мы всё ещё можем запустить конкретную функцию интеграционного теста, указав имя тестовой функции в качестве аргумента для cargo 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.
Подмодули в интеграционных тестах
По мере добавления большего количества интеграционных тестов вы можете захотеть создать больше файлов в директории tests для их организации; например, вы можете группировать тестовые функции по функциональности, которую они тестируют. Как упоминалось ранее, каждый файл в директории tests компилируется как отдельный крейт, что полезно для создания отдельных областей видимости, чтобы более точно имитировать способ, которым конечные пользователи будут использовать ваш крейт. Однако это означает, что файлы в директории tests не разделяют то же поведение, что и файлы в src, как вы узнали в Главе 7 относительно того, как разделять код на модули и файлы.
Разное поведение файлов в директории tests наиболее заметно, когда у вас есть набор вспомогательных функций для использования в нескольких файлах интеграционных тестов, и вы пытаетесь следовать шагам из раздела “Разделение модулей на разные файлы” Главы 7, чтобы извлечь их в общий модуль. Например, если мы создадим tests/common.rs и поместим в него функцию с именем setup, мы можем добавить некоторый код в setup, который мы хотим вызывать из нескольких тестовых функций в нескольких файлах тестов:
Имя файла: tests/common.rs
pub fn setup() {
// setup code specific to your library's tests would go here
}
Когда мы снова запустим тесты, мы увидим новый раздел в выводе тестов для файла common.rs, даже though этот файл не содержит тестовых функций и мы нигде не вызывали функцию 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, — не то, чего мы хотели. Мы просто хотели поделиться некоторым кодом с другими файлами интеграционных тестов. Чтобы избежать появления common в выводе тестов, вместо создания tests/common.rs мы создадим tests/common/mod.rs. Структура директорий проекта теперь выглядит так:
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
├── common
│ └── mod.rs
└── integration_test.rs
Это более старое соглашение об именах, которое Rust также понимает, о котором мы упоминали в разделе “Альтернативные пути файлов” Главы 7. Название файла таким образом говорит Rust не обрабатывать модуль common как файл интеграционного теста. Когда мы перемещаем код функции setup в tests/common/mod.rs и удаляем файл tests/common.rs, раздел в выводе тестов больше не появится. Файлы в поддиректориях директории tests не компилируются как отдельные крейты и не имеют разделов в выводе тестов.
После того как мы создали tests/common/mod.rs, мы можем использовать его из любого файла интеграционных тестов как модуль. Вот пример вызова функции setup из теста it_adds_two в tests/integration_test.rs:
Имя файла: 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; такое же, как объявление модуля, которое мы продемонстрировали в Листинге 7-21. Затем, в тестовой функции, мы можем вызвать функцию common::setup().
Интеграционные тесты для бинарных крейтов
Если наш проект — бинарный крейт, который содержит только файл src/main.rs и не имеет файла src/lib.rs, мы не можем создать интеграционные тесты в директории tests и подключать функции, определённые в файле src/main.rs, в область видимости с помощью оператора use. Только библиотечные крейты экспортируют функции, которые могут использовать другие крейты; бинарные крейты предназначены для самостоятельного запуска.
Это одна из причин, почему проекты Rust, которые предоставляют бинарник, имеют простой файл src/main.rs, который вызывает логику, находящуюся в файле src/lib.rs. Используя эту структуру, интеграционные тесты могут тестировать библиотечный крейт с помощью use, чтобы сделать важную функциональность доступной. Если важная функциональность работает, небольшое количество кода в файле src/main.rs будет работать также, и это небольшое количество кода не нужно тестировать.
Итог
Функции тестирования Rust предоставляют способ указать, как должен функционировать код, чтобы убедиться, что он продолжает работать так, как вы ожидаете, даже когда вы вносите изменения. Модульные тесты проверяют разные части библиотеки по отдельности и могут тестировать приватные детали реализации. Интеграционные тесты проверяют, что многие части библиотеки работают вместе правильно, и они используют публичный API библиотеки для тестирования кода так же, как внешний код будет его использовать. Хотя система типов и правила владения Rust помогают предотвратить некоторые виды ошибок, тесты всё ещё важны для уменьшения логических ошибок, связанных с ожидаемым поведением вашего кода.
Давайте объединим знания, которые вы изучили в этой главе и в предыдущих главах, чтобы поработать над проектом!