Привет, Хабр! Представляю вашему вниманию перевод записи "#[test] в 2018" в блоге Джона Реннера (John Renner), которую можно найти здесь.
В последнее время я работал над реализацией eRFC для пользовательских тестовых фреймворков для Rust. Изучая кодовую базу компилятора, я изучил внутренности тестирования в Rust и понял, что было бы интересно этим поделиться.
На сегодняшний день программисты на Rust полагаются на встроенный атрибут
Когда эта программа будет скомпилирована при помощи команд
Таким образом, приватные сущности могут быть легко протестированы без использования каких-либо внешних инструментов тестирования. Это ключ к эргономике тестов в Rust. Семантически, однако, это довольно странно. Каким образом функция
Как упоминалось ранее, тесты могут существовать внутри приватных модулей, поэтому нам нужен способ экспонировать их в функцию
Теперь наш тест доступен как
Чтобы объяснить, нам нужно понять, как AST представляет идентификаторы. Имя каждой функции, переменной, модуля и т.д. сохраняется не как строка, а скорее как непрозрачный Символ, который по существу является идентификационным номером для каждого идентификатора. Компилятор хранит отдельную хеш-таблицу, которая позволяет нам восстанавливать удобочитаемое имя Символа при необходимости (например, при печати синтаксической ошибки). Когда компилятор создает модуль
Теперь, когда наши тесты доступны из корня нашего крэйта, нам нужно что-то сделать с ними.
Хотя это преобразование простое, оно дает нам много информации о том, как тесты фактически выполняются. Тесты собираются в массив и передаются в запускатель тестов, называемый
Если вы ранее писали тесты в Rust, вы можете быть знакомы с некоторыми необязательными атрибутами, доступными для тестовых функциях. Например, тест можно аннотировать с помощью
Это означает, что наши тесты больше, чем простые функции, и имеют информацию о конфигурации.
Как только мы построили массив этих тестовых объектов, они передаются запускателю тестов через обвязку, сгенерированную на шаге 2. Хотя этот шаг можно считать частью второго шага, я хочу обратить внимание на него как на отдельную концепцию, потому что это будет ключом к реализации пользовательских тестовых фреймворков, но это будет еще одно сообщение в блоге.
Хотя я почерпнул много информации непосредственно из исходников компилятора, мне удалось выяснить, что есть очень простой способ увидеть, что делает компилятор. У ночной сборки компилятора есть нестабильный флаг, который называется
Интереса ради, проиллюстрирую код тестового примера после макрораскрытия:
Пользовательский исходный код:
Код после раскрытия макросов:
В последнее время я работал над реализацией 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),
}];
}