В последнее время я работал над реализацией 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), }]; }
ссылка на оригинал статьи https://habr.com/post/418095/
Добавить комментарий