Организация тестов
Как упоминалось в начале главы, тестирование – сложная дисциплина, и разные люди используют разную терминологию и по-разному их организуют. Сообщество Rust делит тесты на две основные категории: модульные тесты и интеграционные тесты. Модульные тесты небольшие и более сфокусированные: они проверяют по одному модулю изолированно и могут тестировать приватные интерфейсы. Интеграционные тесты полностью внешние по отношению к вашей библиотеке и используют ваш код так же, как любой другой внешний код: только через публичный интерфейс, при этом потенциально задействуя несколько модулей в одном тесте.
Важно писать оба вида тестов, чтобы убедиться, что части вашей библиотеки делают то, что вы от них ожидаете, как по отдельности, так и вместе.
Модульные тесты
Цель модульных тестов – проверять каждую единицу кода в изоляции от
остального кода, чтобы быстро определить, где код работает ожидаемо, а где нет.
Модульные тесты размещают в каталоге 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: 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 ни к чему вас не принуждает.
Интеграционные тесты
В 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, хотя этот файл не содержит тестовых функций, и мы нигде не
вызывали функцию 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 и правила владения помогают предотвратить некоторые виды ошибок, тесты все равно важны для уменьшения логических ошибок, связанных с ожидаемым поведением вашего кода.
Объединим знания, полученные в этой и предыдущих главах, и поработаем над проектом!