About the device built-in testing functionality in Rust (translation)

Hi, Habr! I present to you the translation of the record "# [test] in 2018" in the blog of John Renner (John Renner), which can be found here .

Recently, I have been working on the implementation of eRFC for custom test frameworks for Rust. Studying the code base of the compiler, I studied the insides of testing in Rust and realized that it would be interesting to share this.

Attribute # [test]


Today, Rust programmers rely on the built-in attribute #[test]. All you need to do is mark the function as a test and include some checks:

#[test]fnmy_test() {
  assert!(2+2 == 4);
}

When this program is compiled using the rustc --testor commands cargo test, it will create an executable file that can run this and any other test function. This method of testing allows you to organically keep the tests next to the code. You can even put tests inside private modules:

mod my_priv_mod {
  fnmy_priv_func() -> bool {}
  #[test]fntest_priv_func() {
    assert!(my_priv_func());
  }
}

Thus, private entities can be easily tested without using any external testing tools. This is the key to ergonomics tests in Rust. Semantically, however, this is rather strange. How does the function maincall these tests, if they are not visible ( note the translator : I remind you, private - declared without using a keyword pub- the modules are protected from outside encapsulation)? What exactly does it do rustc --test?

#[test]implemented as a syntax transformation inside the compiler cachet libsyntax. In fact, this is a fancy macro that rewrites our crete in 3 steps:

Step 1: Re-export


As mentioned earlier, tests can exist inside private modules, so we need a way to expose them to a function mainwithout disrupting existing code. For this purpose, libsyntaxcreates local modules, called __test_reexports, which recursively re-export tests . This disclosure translates the example above into:

mod my_priv_mod {
  fnmy_priv_func() -> bool {}
  fntest_priv_func() {
    assert!(my_priv_func());
  }
  pubmod __test_reexports {
    pubuse super::test_priv_func;
  }
}

Now our test is available as my_priv_mod::__test_reexports::test_priv_func. For nested modules __test_reexportswill re-export the modules containing tests, so the test a::b::my_testbecomes a::__test_reexports::b::__test_reexports::my_test. So far this process seems fairly safe, but what happens if there is an existing module __test_reexports? Answer: nothing .

To explain, we need to understand how AST represents identifiers . The name of each function, variable, module, etc. not saved as a string, but rather as an opaque Symbolwhich is essentially an identification number for each identifier. The compiler stores a separate hash table, which allows us to restore the readable name of the Character if necessary (for example, when printing a syntax error). When the compiler creates a module __test_reexports, it generates a new Symbol for the identifier, therefore, although generated by the compiler __test_reexportsmay be the same with your own handwritten module, it will not use its Symbol. This technique prevents name collisions during code generation and is the basis for the hygiene of the Rust macrosystem.

Step 2: Bundle generation


Now that our tests are available from our root, we need to do something with them. libsyntaxgenerates such a module:

pubmod __test {
  externcrate test;
  const TESTS: &'static [self::test::TestDescAndFn] = &[/*...*/];
  #[main]pubfnmain() {
    self::test::test_static_main(TESTS);
  }
}

Although this conversion is simple, it gives us a lot of information about how tests are actually performed. Tests are collected into an array and passed to the test launcher, called test_static_main. We will come back to what it is TestDescAndFn, but at the moment the key conclusion is that there is a cache, called test , which is part of the Rust core and implements all runtime for testing. The interface is testunstable, so the macro is the only stable way to interact with it #[test].

Step 3: Test Object Generation


If you have previously written tests in Rust, you may be familiar with some optional attributes that are available for test functions. For example, a test can be annotated with #[should_panic]if we expect the test to cause panic. It looks like this:

#[test]#[should_panic]fnfoo() {
  panic!("intentional");
}

This means that our tests are more than simple functions and have configuration information. testencodes this configuration data into a structure called TestDesc . For each test function in the cache, it libsyntaxwill analyze its attributes and generate an instance TestDesc. Then it integrates the TestDesctest function into a logical structure TestDescAndFnwith which it works test_static_main. For this test, the generated instance TestDescAndFnlooks like this:

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())),
}

Once we have built an array of these test objects, they are passed to the test runner through the binding generated in step 2. Although this step can be considered part of the second step, I want to draw attention to it as a separate concept, because it will be the key to the implementation of custom test files. frameworks, but it will be another blog post.

Afterword: Research Methods


Although I learned a lot of information directly from the source code of the compiler, I managed to find out that there is a very simple way to see what the compiler does. The compiler's nightly build has an unstable flag, which is called unpretty, which you can use to print the module source code after macros are opened:

$ rustc my_mod.rs -Z unpretty=hir

Translator's Note


For the sake of interest, I will illustrate the code of the test case after macro opening:

Custom source code:

#[test]fnmy_test() {
  assert!(2+2 == 4);
}
fnmain() {}

Code after macros expansion:

#[prelude_import]use std::prelude::v1::*;
#[macro_use]externcrate std as std;
#[test]pubfnmy_test() {
  if !(2 + 2 == 4)
     {
         {
             ::rt::begin_panic("assertion failed: 2 + 2 == 4",
                               &("test_test.rs", 3u32,
                                 3u32))
         }
     };
  }
  #[allow(dead_code)]fnmain() { }
  pubmod __test_reexports {
      pubuse super::my_test;
  }
  pubmod __test {
      externcrate test;
      #[main]pubfnmain() -> () { 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),
          }];
  }

Also popular now: