How doctests work

by Guillaume Gomez

Who am I?

Rust language reviewer and contributor. Member of:
  • rustdoc team (team leader)
  • docs.rs team
  • dev-tools team
  • clippy-contributors team


I am an engineer at Huawei.
## Doc... tests? ``````rust //! I'm a doc comment /// I'm also a doc comment /// /// ``` /// println!("I'm a doctest"); /// ``` pub fn foo() {} ``````
## Features of doctests * compile_fail * edition * ignore * no_run * should_panic * ...
## Example ``````rust //! ```should_panic,edition2021 //! panic!("If I don't panic, something weird is going on"); //! ``` ``````
## More features! * Hiding lines with `#` * You can use `?` without wrapping * Custom CSS * `cfg(doctest)`
## Example (again) ``````rust //! ```custom,{.my-css-class} //! let x = Ok("question mark")?; //! # Result::<(), ()>::Ok(()) //! ``` ``````
## More options * `--show-output` (`cargo test --doc -- --show-output`) * `#![doc(test(attr(...)))]`
# Example (again++) ``````rust #![doc(test(attr(warn(unused))))] //! ``` //! let x = 12; //! ``` ``````
## ... So how do they work? To generate doctests, we need to check a few things: * Is the code syntax valid? * Is there a `main` function? * Is it using inner attributes? (`#![]`) * Is it returning a `Result`? * Is it importing external crates? * Is it defining a macro?
## Doctest generation example ``````rust //! ``` //! println!("hello world"); //! ``` `````` Becomes: ```rust #![allow(unused)] fn main() { #[allow(non_snake_case)] fn _doctest_main_bar_rs_1_0() { println!("hello world"); } _doctest_main_bar_rs_1_0() } ```
## Limitations * Slow (not anymore since the 2024 edition!) * Cannot test non-public API * No support from tools (clippy, rustfmt...)

Merged doctests!

Sorry...

## Merged doctests? Numbers: | crate | before this feature | with this feature | speedup | |-|-|-|-| | std | 12s | 3.56s | x3 | | core | 54.08s | 13.5s | x4 | | sysinfo | 4.6s | 1.11s | x4.1 | | geos | 3.95s | 0.45s | x8.7 | | jiff | 4min39 | 7.2s | x38.8 |

Merged doctest generation example

## Merged doctest runner 1. Compile the merged doctests 2. Run the binary 3. The binary calls `libtest` with all tests to be run 4. `libtest` runs each test in its own thread... 5. ... Each doctest runs itself into a new process
## Why running each doctest into a new process? * Prevents issues with non-thread-local globals * `exit` will only exit the current doctest
## Merged doctest code ```rust mod __doctest_0 { fn main() { println!("hello world"); } pub const TEST = test::TestDescAndFn::new_doctest( "bar.rs - (line 1)", test::StaticTestFn(|| { if let Some(bin_path) = crate::doctest_path() { test::assert_test_result( crate::doctest_runner(bin_path, 0), ) } else { test::assert_test_result(self::main()) } }), ); } ```
## Merged doctests limitations * Some code attributes aren't supported (`test_harness`, `compile_fail`) * Some options are not supported (`--show-output`) * `std::panic::Location::caller()` is mostly useless * If any of the merged doctests failed to compile... then none is run as merged doctests * They need to be grouped by edition
## New code attribute `standalone_crate` ``````rust //! ```standalone_crate //! let location = std::panic::Location::caller(); //! assert_eq!(location.line(), 4); //! ``` ``````
## What's next? * Smaller generated merged doctests? * Binary crates/private items doctests? (#50784)
## About binary crates/private items doctests Multiple approaches: 1. Generate expanded crate code with doctests 2. Add a new `--doctest` flag to rustc

Thank you for listening!

More Rust things on
< blog.guillaume-gomez.fr >

< guillaume1.gomez@gmail.com >

@GuillaumeGomez
@imperio@toot.cat
@imperioworld.bsky.social
@imperioworld_