When documentation drifts, users pay the price
You finish a library function. The docs look crisp. You publish. A user copies the example, runs it, and gets a type error. The example promised i32 but the function actually takes u32. Or worse, the example works in isolation but fails when the user integrates it because you missed a trait import.
Doc tests catch this. They turn your documentation into executable code that runs every time you build. If the example breaks, the build breaks. You get feedback before the user does.
Doc tests are tests that live in your docs
Doc tests are code blocks embedded in documentation comments. Rust's documentation tool, rustdoc, treats these blocks as real test functions. When you run cargo test, Rust extracts the code, wraps it in a main function, compiles it, and runs it. If the example panics or fails an assertion, the test fails.
This keeps your docs honest. If the docs say "it works like this," the compiler proves it works like this. You get usage examples that are guaranteed to compile and run. Users can copy-paste with confidence.
The convention is to place examples under a # Examples header in your doc comments. This signals to readers and tools that code follows.
Minimal example
Start with a function and a doc test that imports it, calls it, and asserts the result.
/// Adds two integers together.
///
/// # Examples
///
/// ```
/// // Doc tests run in a separate compilation unit.
/// // You must import the item you are testing.
/// use my_lib::add;
///
/// let result = add(2, 2);
/// // Verify the output matches the expected value.
/// assert_eq!(result, 4);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Run this with cargo test. Rust compiles the example, runs it, and reports success. If you change add to return a - b, the test fails immediately.
How the compiler processes doc tests
When rustdoc encounters a code block in a doc comment, it performs a few steps. It strips the block from the markdown. It wraps the code in a fn main() { ... } function. It compiles the result as a standalone binary. It runs the binary and checks the exit code.
If the code contains assert! or assert_eq!, the test fails on mismatch. If the code panics, the test fails. If the code fails to compile, the test fails.
The compiler also checks for hidden lines. Lines starting with # are included in the test compilation but omitted from the rendered documentation. This lets you hide imports and setup code while keeping the example clean for readers.
Realistic example with hidden imports
Real examples often need imports that clutter the docs. Use # to hide them.
/// Parses a configuration string into a key-value map.
///
/// # Examples
///
/// ```
/// use my_lib::parse_config;
///
/// // Hide the Config struct import in the rendered docs.
/// // The test still compiles with it.
/// # use my_lib::Config;
///
/// let config = parse_config("key=value").unwrap();
/// assert_eq!(config.get("key"), Some("value"));
/// ```
pub fn parse_config(input: &str) -> Result<Config, ParseError> {
// Implementation details...
unimplemented!()
}
The rendered docs show only the use my_lib::parse_config; line and the logic. The # use my_lib::Config; line disappears from the HTML but stays in the test. This follows the community convention: hide boilerplate, show logic.
Hide boilerplate, not logic. If a user copies the visible code and it fails, your example is lying.
Advanced attributes for control
Doc tests support attributes that change how the compiler treats the block. These let you demonstrate errors, skip execution, or handle panics.
Demonstrating errors with compile_fail
Sometimes you want to show code that should not compile. Use compile_fail to mark a block that must fail compilation. This is powerful for teaching what not to do.
/// You cannot add a string to an integer.
///
/// # Examples
///
/// ```compile_fail
/// // This example demonstrates a type error.
/// // The test passes because compilation fails.
/// let x: i32 = "not a number";
/// ```
The compiler expects this block to fail. If you fix the error and the code compiles, the test fails. This ensures your error examples stay accurate.
Use compile_fail to document type mismatches, missing trait bounds, and lifetime errors. It turns negative examples into verified tests.
Verifying panics with should_panic
Use should_panic for code that intentionally panics. This proves your error handling works.
/// Accesses an element by index.
///
/// # Panics
///
/// Panics if the index is out of bounds.
///
/// # Examples
///
/// ```should_panic(expected = "index out of bounds")
/// let v = vec![1, 2, 3];
/// // This panics because index 10 is out of bounds.
/// let x = v[10];
/// ```
Always include expected = "..." when possible. Without it, the test passes on any panic, even if the panic message changes. The expected attribute ties the test to the specific error.
Skipping execution with no_run
Use no_run for examples that compile but shouldn't run. This is common for code that starts servers, modifies the filesystem, or takes too long.
/// Starts a web server on port 8080.
///
/// # Examples
///
/// ```no_run
/// // Compiles the example but skips execution.
/// // Useful for code with side effects.
/// my_server::start();
/// ```
The compiler checks that the code compiles. It does not run it. This verifies the API without triggering side effects in CI.
Ignoring tests with ignore
Use ignore for examples that cannot run in the test environment. This applies to code requiring specific hardware, network access, or external dependencies.
/// Reads from a specific hardware sensor.
///
/// # Examples
///
/// ```ignore
/// // Skips compilation and execution.
/// // Use only when the code cannot run at all.
/// let data = sensor::read();
/// ```
ignore skips everything. The code is not compiled. Use this sparingly. Prefer no_run if the code compiles but shouldn't run. Prefer compile_fail if you want to show an error. ignore is a last resort.
Pitfalls and compiler errors
Doc tests have traps. Avoid them by following these patterns.
Forgetting imports
Doc tests run in a separate scope. They do not inherit the module's imports. If you forget to import the function, the compiler rejects the test with E0425 (cannot find function add in this scope).
Always include the necessary use statements. Hide them with # if they clutter the docs.
Hiding too much
If you hide a line that contains essential logic, the user copies broken code. The test passes because the hidden line fixes it, but the user fails.
Check your examples by copying only the visible lines. If they don't compile, you hid too much. Move the logic into the visible code or restructure the example.
Vague panic tests
A should_panic test without expected passes on any panic. If the panic message changes, the test still passes. This masks regressions.
Add expected = "..." to every panic test. Match the actual panic message. This ensures the test verifies the specific error.
Running doc tests in isolation
Doc tests run in isolation. They cannot easily test interactions between multiple modules. If you need to test a workflow that spans modules, use an integration test instead.
Doc tests are for single-function examples. Integration tests are for multi-module flows.
If the test requires a hidden import to pass, your example is lying. Fix the example.
Decision matrix
Choose the right test type for the job.
Use doc tests for public API examples. They prove the interface works and serve as usage guides for users.
Use unit tests for private implementation details and edge cases. Doc tests are too verbose for testing every branch of a complex algorithm.
Use integration tests for end-to-end workflows. Doc tests run in isolation and cannot easily test interactions between multiple modules or external dependencies.
Use compile_fail to demonstrate errors. It verifies that bad code fails to compile and teaches users what to avoid.
Use should_panic to verify error handling. It proves that invalid inputs trigger the expected panics.
Use no_run for examples with side effects. It compiles the code to check correctness without running servers or modifying files.
Use ignore only when the code cannot run at all. Prefer no_run or compile_fail whenever possible.
Doc tests are your first line of defense against documentation drift. Write them for every public function.