Тестирование Rust

от автора

Я всё ещё продолжаю изучать 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 test Rust собирает тестовый двоичный файл, выполняющий аннотированные функции и сообщающий, завершилась ли каждая тестовая функция успешно.

The Anatomy of a Test Function

На простейшем уровне это позволяет задавать тестовые функции. Они валидны только при вызове 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] этот макрос используется, чтобы разрешить вычисление булевых выражений флагов конфигурации. Это часто приводит к уменьшению объёма дублированного кода.

Macro std::cfg

Макрос cfg предоставляет множество готовых переменных конфигурации:

Переменная Описание Пример
target_arch Целевая архитектура ЦП
  • "x86"
  • "arm"
  • "aarch64"
target_feature

 

Функциональность платформы, доступная для текущей целевой платформы компиляции
  • "rdrand"
  • "sse"
  • "se2"
target_os

 

Операционная система целевой платформы
  • "windows"
  • "macos"
  • "linux"
target_family Обобщённое описание целевой платформы, например, семейство операционных систем или архитектур
  • "windows"
  • "unix"
target_env Дополнительная пояснительная информация о целевой платформе с информацией об использованном ABI или libc
  • ""
  • "gnu"
  • "musl"
target_endian

 

"big" или "little"
target_pointer_width

 

Ширина указателя целевой платформы в битах
  • "32"
  • "64"
target_vendor

 

Производитель платформы
  • "apple"
  • "pc"
test

 

Включено при компиляции тестовой обвязки
proc_macro

 

Когда компилируемый crate компилируется с proc_macro
panic Зависимости от стратегии паники
  • "abort"
  • "unwind"

Среди множества переменных вы могли заметить флаг 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

  1. Компилируется, только если включено свойство unit.
  2. Компилируется, только если включено свойство it.
  3. Компилируется, только если включено свойство 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

В документации представлено множество способов, позволяющих избегать одновременной активации исключающих свойств:

В редких случаях свойства могут быть взаимно несовместимыми друг с другом. Этого по возможности следует избегать, потому что такие ситуации требуют координирования всех способов использования пакета в граф зависимостей, чтобы невозможно было включить их вместе. Если это невозможно, попробуйте добавить ошибку компиляции для распознавания такого сценария.

Mutually exclusive features

Давайте добавим код:

#[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 }  // опущено для краткости

  1. Структура PostgreSqlDatabase недоступна, когда активировано любое из тестовых свойств.
  2. Изменяем сигнатуру, чтобы можно было тестировать.
  3. Тестируем!

Теперь мы можем выполнять разные команды:

cargo test --no-default-features -F unit            #1 cargo test --no-default-features -F it              #2 cargo run                                           #3

  1. Выполняем юнит-тест.
  2. Выполняем интеграционный тест.
  3. Запускаем приложение.

Заключение

В этом посте я описал проблему, вызванную наличием наборов тестов, нацеленных на разные области применения. Стандартная переменная конфигурации test двоична: область применения или является test, или нет. Этого недостаточно, когда необходимо разделение на юнит-тесты и интеграционные тесты, каждый из которых требует своей реализации поведения.

Способом решения этой проблемы являются свойства Rust. Свойство (feature) позволяет ограничить код меткой, которую разработчик может включать для каждого запуска в командной строке.

Если откровенно, то я не знаю, являются ли свойства Rust правильным способом реализации областей тестирования. Как бы то ни было, это сработало и помогло мне лучше разобраться в экосистеме Rust.

Полный исходный код, представленный в этом посте, можно найти на GitHub.

Информация для более глубокого изучения:

Telegram-канал с полезностями и уютный чат


ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/697772/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *