Я всё ещё продолжаю изучать 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 |
Целевая архитектура ЦП |
|
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
В документации представлено множество способов, позволяющих избегать одновременной активации исключающих свойств:
В редких случаях свойства могут быть взаимно несовместимыми друг с другом. Этого по возможности следует избегать, потому что такие ситуации требуют координирования всех способов использования пакета в граф зависимостей, чтобы невозможно было включить их вместе. Если это невозможно, попробуйте добавить ошибку компиляции для распознавания такого сценария.
— 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
}
// опущено для краткости
- Структура
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.
Информация для более глубокого изучения:
Telegram-канал с полезностями и уютный чат
Комментарии (3)
Gorthauer87
08.11.2022 19:33+2Совершенно вредный совет. Как минимум советуют делать фичи не взаимоисключающими. Во вторых, менять реализацию через фичи это нарушение принципов solid.
Все это даже не в духе джавы, а скорее в духе си.
godzie
Великолепно, написали тест который тестирует тестовую реализацию - очень полезно.
Да и в целом, способ из растбука гораздо лучше предложенного - закрываем за cfg(test) целый модуль в котором, при необходимости, пишем моки и тп.
domix32
Джависты