Привет! Представляю вашему вниманию перевод записи "#[test] в 2018" в блоге Джона Реннера (John Renner), которую можно найти здесь.
В последнее время я работал над реализацией eRFC для пользовательских тестовых фреймворков для Rust. Изучая кодовую базу компилятора, я изучил внутренности тестирования в Rust и понял, что было бы интересно этим поделиться.
Атрибут #[test]
На сегодняшний день программисты на Rust полагаются на встроенный атрибут #[test]
. Все, что вам нужно сделать, это отметить функцию как тест и включить некоторые проверки:
#[test]
fn my_test() {
assert!(2+2 == 4);
}
Когда эта программа будет скомпилирована при помощи команд rustc --test
или cargo test
, она создаст исполняемый файл, который может запустить эту и любую другую тестовую функцию. Этот метод тестирования позволяет органично держать тесты рядом с кодом. Вы можете даже поместить тесты внутри приватных модулей:
mod my_priv_mod {
fn my_priv_func() -> bool {}
#[test]
fn test_priv_func() {
assert!(my_priv_func());
}
}
Таким образом, приватные сущности могут быть легко протестированы без использования каких-либо внешних инструментов тестирования. Это ключ к эргономике тестов в Rust. Семантически, однако, это довольно странно. Каким образом функция main
вызывает эти тесты, если они не видны (прим. переводчика: напоминаю, приватные — объявленные без использования ключевого слова pub
— модули защищены инкапсуляцией от доступа извне)? Что именно делает rustc --test
?
#[test]
реализован как синтаксическое преобразование внутри компиляторного крэйта libsyntax
. По сути, это причудливый макрос, который переписывает наш крэйт в 3 этапа:
Шаг 1: Повторный экспорт
Как упоминалось ранее, тесты могут существовать внутри приватных модулей, поэтому нам нужен способ экспонировать их в функцию main
без нарушения существующего кода. С этой целью libsyntax
создаёт локальные модули, называемые __test_reexports
, которые рекурсивно переэкспортируют тесты. Это раскрытие переводит приведенный выше пример в:
mod my_priv_mod {
fn my_priv_func() -> bool {}
fn test_priv_func() {
assert!(my_priv_func());
}
pub mod __test_reexports {
pub use super::test_priv_func;
}
}
Теперь наш тест доступен как my_priv_mod::__test_reexports::test_priv_func
. Для вложенных модулей __test_reexports
будет переэкспортировать модули, содержащие тесты, поэтому тест a::b::my_test
становится a::__test_reexports::b::__test_reexports::my_test
. Пока что этот процесс кажется довольно безопасным, но что произойдет, если есть существующий модуль __test_reexports
? Ответ: ничего.
Чтобы объяснить, нам нужно понять, как AST представляет идентификаторы. Имя каждой функции, переменной, модуля и т.д. сохраняется не как строка, а скорее как непрозрачный Символ, который по существу является идентификационным номером для каждого идентификатора. Компилятор хранит отдельную хеш-таблицу, которая позволяет нам восстанавливать удобочитаемое имя Символа при необходимости (например, при печати синтаксической ошибки). Когда компилятор создает модуль __test_reexports
, он генерирует новый Символ для идентификатора, поэтому, хотя генерируемый компилятором __test_reexports
может быть одноименным с вашим самописным модулем, он не будет использовать его Символ. Эта техника предотвращает коллизию имен во время генерации кода и является основой гигиены макросистемы Rust.
Шаг 2: Генерация обвязки
Теперь, когда наши тесты доступны из корня нашего крэйта, нам нужно что-то сделать с ними. libsyntax
генерирует такой модуль:
pub mod __test {
extern crate test;
const TESTS: &'static [self::test::TestDescAndFn] = &[/*...*/];
#[main]
pub fn main() {
self::test::test_static_main(TESTS);
}
}
Хотя это преобразование простое, оно дает нам много информации о том, как тесты фактически выполняются. Тесты собираются в массив и передаются в запускатель тестов, называемый test_static_main
. Мы вернемся к тому, что такое TestDescAndFn
, но на данный момент ключевым выводом является то, что есть крэйт, называемый test, который является частью ядра Rust и реализует весь рантайм для тестирования. Интерфейс test
нестабилен, поэтому единственным стабильным способом взаимодействия с ним является макрос #[test]
.
Шаг 3: Генерация тестового объекта
Если вы ранее писали тесты в Rust, вы можете быть знакомы с некоторыми необязательными атрибутами, доступными для тестовых функциях. Например, тест можно аннотировать с помощью #[should_panic]
, если мы ожидаем, что тест вызовет панику. Это выглядит примерно так:
#[test]
#[should_panic]
fn foo() {
panic!("intentional");
}
Это означает, что наши тесты больше, чем простые функции, и имеют информацию о конфигурации. test
кодирует эти данные конфигурации в структуру, называемую TestDesc. Для каждой тестовой функции в крэйте libsyntax
будет анализировать её атрибуты и генерировать экземпляр TestDesc
. Затем он объединяет TestDesc
и тестовую функцию в логичную структуру TestDescAndFn
, с которой работает test_static_main
. Для данного теста сгенерированный экземпляр TestDescAndFn
выглядит так:
self::test::TestDescAndFn {
desc: self::test::TestDesc {
name: self::test::StaticTestName("foo"),
ignore: false,
should_panic: self::test::ShouldPanic::Yes,
allow_fail: false,
},
testfn: self::test::StaticTestFn(||
self::test::assert_test_result(::crate::__test_reexports::foo())),
}
Как только мы построили массив этих тестовых объектов, они передаются запускателю тестов через обвязку, сгенерированную на шаге 2. Хотя этот шаг можно считать частью второго шага, я хочу обратить внимание на него как на отдельную концепцию, потому что это будет ключом к реализации пользовательских тестовых фреймворков, но это будет еще одно сообщение в блоге.
Послесловие: Методы исследования
Хотя я почерпнул много информации непосредственно из исходников компилятора, мне удалось выяснить, что есть очень простой способ увидеть, что делает компилятор. У ночной сборки компилятора есть нестабильный флаг, который называется unpretty
, который вы можете использовать для распечатки исходного кода модуля после раскрытия макросов:
$ rustc my_mod.rs -Z unpretty=hir
Примечание переводчика
Интереса ради, проиллюстрирую код тестового примера после макрораскрытия:
Пользовательский исходный код:
#[test]
fn my_test() {
assert!(2+2 == 4);
}
fn main() {}
Код после раскрытия макросов:
#[prelude_import]
use std::prelude::v1::*;
#[macro_use]
extern crate std as std;
#[test]
pub fn my_test() {
if !(2 + 2 == 4)
{
{
::rt::begin_panic("assertion failed: 2 + 2 == 4",
&("test_test.rs", 3u32,
3u32))
}
};
}
#[allow(dead_code)]
fn main() { }
pub mod __test_reexports {
pub use super::my_test;
}
pub mod __test {
extern crate test;
#[main]
pub fn main() -> () { test::test_main_static(TESTS) }
const TESTS: &'static [self::test::TestDescAndFn] =
&[self::test::TestDescAndFn {
desc:
self::test::TestDesc {
name: self::test::StaticTestName("my_test"),
ignore: false,
should_panic: self::test::ShouldPanic::No,
allow_fail: false,
},
testfn:
self::test::StaticTestFn(::__test_reexports::my_test),
}];
}
Автор: Virtuos86