Pull to refresh

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

Reading time5 min
Views3.2K
Привет, Хабр! Представляю вашему вниманию перевод записи "#[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),
          }];
  }
Tags:
Hubs:
Total votes 33: ↑33 and ↓0+33
Comments4

Articles