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

Комментарии (4)


  1. PsyHaSTe
    24.07.2018 20:36

    У ночной сборки компилятора есть нестабильный флаг, который называется unpretty, который вы можете использовать для распечатки исходного кода модуля после раскрытия макросов

    Выглядит сильно удобнее, чем cargo expand. Надо будет обязательно затестить.


  1. chabapok
    25.07.2018 14:23

    Кстати, #[should_panic] в нормальных тестах лучше не применять — обычно когда ты хочешь панику, то хочется. чтобы она была в определенной строке, а #[should_panic] подразумевает панику в любой строке, что для тестов не является приемлемым.


    1. humbug
      25.07.2018 16:00
      +1

      Так можно же уточнять, что именно должно упасть.


      #[should_panic(expected = "assertion failed")]


      1. chabapok
        25.07.2018 16:45

        Вобщем да, можно. Но все равно какой-то он кривой, а параметр expected — по сути костыль. Например, мест, в которых оно может упасть с сообщением «assertion failed» может быть много, и мы проверяем, что упало в любом из них, а не в конкретном.