Я всё ещё продолжаю изучать 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 test даёт следующее:

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-канал с полезностями и уютный чат

Комментарии (3)


  1. godzie
    07.11.2022 18:23
    +11

    #[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");
    }

    Великолепно, написали тест который тестирует тестовую реализацию - очень полезно.

    Да и в целом, способ из растбука гораздо лучше предложенного - закрываем за cfg(test) целый модуль в котором, при необходимости, пишем моки и тп.


    1. domix32
      08.11.2022 02:29
      +4

      Джависты


  1. Gorthauer87
    08.11.2022 19:33
    +2

    Совершенно вредный совет. Как минимум советуют делать фичи не взаимоисключающими. Во вторых, менять реализацию через фичи это нарушение принципов solid.

    Все это даже не в духе джавы, а скорее в духе си.