[Перевод] Тестирование Rust

-lidyb2-_fbfemz7idl8wmlzaqs.png

Я всё ещё продолжаю изучать 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 предоставляет множество готовых переменных конфигурации: Среди множества переменных вы могли заметить флаг 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-канал с полезностями и уютный чат

sz7jpfj8i1pa6ocj-eia09dev4q.png

© Habrahabr.ru