Об устройстве встроенной функциональности тестирования в Rust (перевод)

от автора

Привет, Хабр! Представляю вашему вниманию перевод записи «#[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),           }];   } 


ссылка на оригинал статьи https://habr.com/post/418095/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *