Я всё ещё продолжаю изучать Rust. Кроме синтаксиса, для знания языка нужно понимать его идиомы и экосистему. Сейчас я нахожусь на этапе изучения тестирования в Rust.
Исходная проблема
В течение многих лет работы с JVM мы активно применяли внедрение зависимостей. Даже если вы не используете фреймворк, внедрение зависимостей помогает разделять компоненты. Вот простой пример:
class Car(private val engine: Engine) { fun start() { engine.start() } } interface Engine { fun start() } class CarEngine(): Engine { override fun start() = ... } class TestEngine(): Engine { override fun start() = ... }
В обычном коде:
val car = Car(CarEngine())
В тестовом коде:
val dummy = Car(TestEngine())
Внедрение зависимостей нужно для исполнения разных фрагментов кода в соответствии с их контекстом.
Чтобы превратить функцию в тестовую, добавьте
#[test]в строку передfn. При запуске тестов командойcargo testRust собирает тестовый двоичный файл, выполняющий аннотированные функции и сообщающий, завершилась ли каждая тестовая функция успешно.
На простейшем уровне это позволяет задавать тестовые функции. Они валидны только при вызове cargo test:
fn main() { println!("{}", hello()); } fn hello() -> &'static str { return "Hello world"; } #[test] fn test_hello() { assert_eq!(hello(), "Hello world"); }
Выполнение cargo run даёт следующий результат:
Hello world
С другой стороны, выполнение cargo run даёт следующее:
running 1 test test test_hello ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Однако наша основная проблема заключается в другом: мы хотим, чтобы код зависел от того, является ли контекст тестовым.
Макрос test — это не то решение, которое нам нужно.
Эксперименты с макросом cfg
Rust разделяет «юнит»-тесты и «интеграционные» тесты. Я пишу в кавычках, потому что семантика может сбивать с толку. Вот что означают эти понятия:
- Юнит-тесты пишутся в том же файле, что и main. Можно аннотировать их макросом
#[test], а затем вызыватьcargo test, как показано выше. - Интеграционные тесты являются внешними по отношению к тестируемому коду. Код аннотируется как часть интеграционных тестов при помощи макроса
#[cfg(test)].
Описание макроса cfg:
Вычисляет булевы сочетания флагов конфигурации во время компиляции.
В дополнение к атрибуту
#[cfg]этот макрос используется, чтобы разрешить вычисление булевых выражений флагов конфигурации. Это часто приводит к уменьшению объёма дублированного кода.
Макрос cfg предоставляет множество готовых переменных конфигурации:
| Переменная | Описание | Пример |
|---|---|---|
target_arch |
Целевая архитектура ЦП |
|
target_feature
|
Функциональность платформы, доступная для текущей целевой платформы компиляции |
|
target_os
|
Операционная система целевой платформы |
|
target_family |
Обобщённое описание целевой платформы, например, семейство операционных систем или архитектур |
|
target_env |
Дополнительная пояснительная информация о целевой платформе с информацией об использованном |
|
target_endian
|
"big" или "little" |
|
target_pointer_width
|
Ширина указателя целевой платформы в битах |
|
target_vendor
|
Производитель платформы |
|
test
|
Включено при компиляции тестовой обвязки | |
proc_macro
|
Когда компилируемый crate компилируется с proc_macro |
|
panic |
Зависимости от стратегии паники |
|
Среди множества переменных вы могли заметить флаг test. Для написания интеграционного теста нужно аннотировать код макросом #[cfg(test)]:
#[cfg(test)] fn test_something() { // Whatever }
Также можно использовать макрос для создания альтернативного кода в контексте test:
fn hello() -> &'static str { return "Hello world"; } #[cfg(test)] fn hello() -> &'static str { return "Hello test"; }
Этот фрагмент кода работает во время cargo run, но не во время cargo test. В первом случае вторая функция игнорируется. Во втором этого не происходит, и Rust пытается скомпилировать две функции с одинаковой сигнатурой.
error[E0428]: the name `hello` is defined multiple times --> src/lib.rs:10:1 | 5 | fn hello() -> &'static str { | -------------------------- previous definition of the value `hello` here ... 10 | fn hello() -> &'static str { | ^^^^^^^^^^^^^^^^^^^^^^^^^^ `hello` redefined here | = note: `hello` must be defined only once in the value namespace of this module
К счастью, макрос cfg имеет булеву логику. Следовательно, мы можем выполнить отрицание конфигурации test для первой функции:
fn main() { println!("{}", hello()); } #[cfg(not(test))] fn hello() -> &'static str { return "Hello world"; } #[cfg(test)] fn hello() -> &'static str { return "Hello test"; } #[test] fn test_hello() { assert_eq!(hello(), "Hello test"); }
cargo runприводит к получениюHello world,cargo testкомпилируется, а затем успешно выполняет тест.
Хоть это и решает проблему, но такой подход имеет очевидные недостатки:
- он двоичен — или тестовый контекст, или нет,
- не масштабируется: после определённого размера из-за большого количества аннотаций проектом невозможно будет управлять.
Совершенствуем структуру
Чтобы усовершенствовать структуру, представим сценарий, с которым я множество раз сталкивался в JVM:
- при обычном прогоне код подключается к базе данных продакшена, например Postgres,
- для интеграционного тестирования код использует локальную базу, например SQLite,
- для юнит-тестирования код использует не базу данных, а имитацию.
Вот фундамент нашей структуры:
fn main() { // Get a database implementation // 1 db.do_stuff(); } trait Database { fn doStuff(self: Self); } struct MockDatabase {} struct SqlitDatabase {} struct PostgreSqlDatabase {} impl Database for MockDatabase { fn doStuff(self: Self) { println!("Do mock stuff"); } } impl Database for SqlitDatabase { fn doStuff(self: Self) { println!("Do stuff with SQLite"); } } impl Database for PostgreSqlDatabase { fn doStuff(self: Self) { println!("Do stuff with PostgreSQL"); } }
Как получить правильную реализацию в зависимости от контекста?
У нас есть три контекста, а cfg[test] позволяет использовать только двоичный флаг. Настало время использовать новый подход.
Используем свойства Cargo
В поисках решения я задал вопрос в Slack-канале Rust. Уильям Диллон предложил мне изучить свойства (feature) Cargo.
У Cargo есть механизм описания условного компилирования и вспомогательных зависимостей. Пакет задаёт набор именованных свойств в таблице
[features]файлаCargo.toml, и каждое свойство можно включить или отключить. Свойства собираемого пакета можно включать в командной строке флагами наподобие--features. Свойства для зависимостей можно включить в объявлении зависимостей вCargo.toml.— Features
▍ Задаём свойства
Первым делом нужно определить, какие свойства мы будем использовать. Они настраиваются в файле Cargo.toml:
[features] unit = [] it = [] prod = []
▍ Использование свойств в коде
Чтобы воспользоваться свойством, мы применяем макрос cfg:
fn main() { #[cfg(feature = "unit")] // 1 let db = MockDatabase {}; #[cfg(feature = "it")] // 2 let db = SqlitDatabase {}; #[cfg(feature = "prod")] // 3 let db = PostgreSqlDatabase {}; db.do_stuff(); } trait Database { fn do_stuff(self: Self); } #[cfg(feature = "unit")] // 1 struct MockDatabase {} #[cfg(feature = "unit")] // 1 impl Database for MockDatabase { fn do_stuff(self: Self) { println!("Do mock stuff"); } } // Урезано для краткости // 2-3
- Компилируется, только если включено свойство
unit. - Компилируется, только если включено свойство
it. - Компилируется, только если включено свойство
prod.
▍ Активация свойства
Для активации свойства нужно использовать флаг -F.
cargo run -F unit
Do mock stuff
▍ Свойство по умолчанию
Свойство «production» должно быть основным, поэтому критически важно установить его по умолчанию.
Я уже сталкивался с этой проблемой: когда коллега в отпуске, а тебе нужно выполнить сборку, то читать код в поисках обязательных флагов очень мучительно.
Rust позволяет задавать «стандартные» свойства. Их не нужно активировать, они включены по умолчанию. Магия происходит в файле Cargo.toml.
[features] default = ["prod"] # 1 unit = [] it = [] prod = []
Свойство prod будет установлено как свойство по умолчанию.
Теперь мы можем запустить программу, не задавая явным образом свойство prod:
cargo run
Do stuff with PostgreSQL
▍ Исключающие свойства
Все три свойства являются исключающими: одновременно можно включить только одно из них. Для отключения свойства по умолчанию нам нужен дополнительный флаг:
cargo run --no-default-features -F unit
Do mock stuff
В документации представлено множество способов, позволяющих избегать одновременной активации исключающих свойств:
В редких случаях свойства могут быть взаимно несовместимыми друг с другом. Этого по возможности следует избегать, потому что такие ситуации требуют координирования всех способов использования пакета в граф зависимостей, чтобы невозможно было включить их вместе. Если это невозможно, попробуйте добавить ошибку компиляции для распознавания такого сценария.
Давайте добавим код:
#[cfg(all(feature = "unit", feature = "it"))] compile_error!("feature \"unit\" and feature \"it\" cannot be enabled at the same time"); #[cfg(all(feature = "unit", feature = "prod"))] compile_error!("feature \"unit\" and feature \"prod\" cannot be enabled at the same time"); #[cfg(all(feature = "it", feature = "prod"))] compile_error!("feature \"it\" and feature \"prod\" cannot be enabled at the same time");
Если мы попытаемся выполнить запуск со свойством unit, пока включено свойство prod по умолчанию:
cargo run -F unit
То получим следующее:
error: feature "unit" and feature "prod" cannot be enabled at the same time --> src/main.rs:4:1 | 4 | compile_error!("feature \"unit\" and feature \"prod\" cannot be enabled at the same time"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Исправляем представленную выше структуру
Показанная выше структура запутывает. В тестах точкой входа является не функция main, а сами тестовые функции.
Давайте снова добавим тесты как в начальной фазе.
#[cfg(feature = "prod")] // 1 fn main() { let db = PostgreSqlDatabase {}; println!("{}", db.do_stuff()); } trait Database { fn do_stuff(self: Self) -> &'static str; // 2 } #[cfg(feature = "unit")] struct MockDatabase {} #[cfg(feature = "prod")] struct PostgreSqlDatabase {} #[cfg(feature = "unit")] impl Database for MockDatabase { fn do_stuff(self: Self) -> &'static str { "Do mock stuff" } } #[cfg(feature = "prod")] impl Database for PostgreSqlDatabase { fn do_stuff(self: Self) -> &'static str { "Do stuff with PostgreSQL" } } #[test] #[cfg(feature = "unit")] fn test_unit() { let db = MockDatabase {}; assert_eq!(db.do_stuff(), "Do mock stuff"); // 3 } // опущено для краткости
- Структура
PostgreSqlDatabaseнедоступна, когда активировано любое из тестовых свойств. - Изменяем сигнатуру, чтобы можно было тестировать.
- Тестируем!
Теперь мы можем выполнять разные команды:
cargo test --no-default-features -F unit #1 cargo test --no-default-features -F it #2 cargo run #3
- Выполняем юнит-тест.
- Выполняем интеграционный тест.
- Запускаем приложение.
Заключение
В этом посте я описал проблему, вызванную наличием наборов тестов, нацеленных на разные области применения. Стандартная переменная конфигурации test двоична: область применения или является test, или нет. Этого недостаточно, когда необходимо разделение на юнит-тесты и интеграционные тесты, каждый из которых требует своей реализации поведения.
Способом решения этой проблемы являются свойства Rust. Свойство (feature) позволяет ограничить код меткой, которую разработчик может включать для каждого запуска в командной строке.
Если откровенно, то я не знаю, являются ли свойства Rust правильным способом реализации областей тестирования. Как бы то ни было, это сработало и помогло мне лучше разобраться в экосистеме Rust.
Полный исходный код, представленный в этом посте, можно найти на GitHub.
Информация для более глубокого изучения:
ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/697772/

Добавить комментарий