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