Rust Doc Tests: Testing Documentation Examples

Documentation lies. Not intentionally, but inevitably. APIs evolve, function signatures change, and those carefully crafted examples in your README become misleading relics. Every language struggles...

Key Insights

  • Rust’s doc tests automatically verify that your documentation examples compile and run correctly, eliminating the common problem of outdated or broken code snippets in documentation.
  • The # prefix lets you hide boilerplate code from rendered documentation while keeping tests complete and runnable, giving you the best of both worlds.
  • Doc tests serve as both documentation and integration tests—if your examples are awkward to write, it’s a signal that your API design needs work.

Introduction to Doc Tests

Documentation lies. Not intentionally, but inevitably. APIs evolve, function signatures change, and those carefully crafted examples in your README become misleading relics. Every language struggles with this problem.

Rust solves it by making documentation examples executable. Code blocks in your doc comments aren’t just pretty formatting—they’re tests that cargo test runs automatically. If your example doesn’t compile or produces wrong output, you’ll know immediately.

This isn’t a nice-to-have feature. It’s a fundamental shift in how documentation works. Your examples become contracts. They must work, or your build fails. The result is documentation you can actually trust.

Writing Your First Doc Test

Doc tests live in documentation comments. Use /// for item documentation (functions, structs, modules) or //! for module-level documentation. Any fenced code block in these comments becomes a test.

/// Calculates the factorial of a non-negative integer.
///
/// # Examples
///
/// ```
/// let result = my_math::factorial(5);
/// assert_eq!(result, 120);
/// ```
///
/// ```
/// let result = my_math::factorial(0);
/// assert_eq!(result, 1);
/// ```
pub fn factorial(n: u64) -> u64 {
    match n {
        0 | 1 => 1,
        _ => n * factorial(n - 1),
    }
}

Run cargo test and you’ll see output like:

running 2 tests
test src/lib.rs - factorial (line 5) ... ok
test src/lib.rs - factorial (line 10) ... ok

Each code block runs in isolation. Rust wraps your code in an implicit fn main() and adds extern crate your_crate; for library crates. You write the interesting part; Rust handles the scaffolding.

The convention is to put examples under an # Examples heading, but it’s not required. Any code block in a doc comment becomes a test unless you tell Rust otherwise.

Doc Test Syntax and Attributes

Not every code example should run as a test. Rust provides attributes to control doc test behavior.

ignore - Skip This Test

Use ignore for examples that are correct but impractical to test—maybe they require specific hardware, take too long, or need external services.

/// Connects to the production database.
///
/// ```ignore
/// let conn = connect_to_database("prod.example.com")?;
/// conn.execute("SELECT * FROM users")?;
/// ```
pub fn connect_to_database(host: &str) -> Result<Connection, DbError> {
    // ...
}

The example still appears in documentation, but cargo test skips it.

should_panic - Expect Failure

When documenting panic conditions, use should_panic to verify the example actually panics.

/// Divides two numbers.
///
/// # Panics
///
/// Panics if `divisor` is zero.
///
/// ```should_panic
/// my_math::divide(10, 0); // This panics!
/// ```
pub fn divide(dividend: i32, divisor: i32) -> i32 {
    if divisor == 0 {
        panic!("Cannot divide by zero");
    }
    dividend / divisor
}

The test passes only if the code panics. If it runs successfully, the test fails.

no_run - Compile But Don’t Execute

Sometimes you need to verify code compiles without actually running it. Network calls, file system operations, or infinite loops are good candidates.

/// Starts the web server on the specified port.
///
/// ```no_run
/// use my_server::start;
/// 
/// start(8080); // Blocks forever, don't actually run
/// ```
pub fn start(port: u16) {
    // ...
}

Rust compiles the example to catch type errors and API misuse, but doesn’t execute it.

compile_fail - Expect Compilation Errors

This is powerful for documenting what users shouldn’t do. If you’re showing that certain code is invalid, mark it compile_fail.

/// A wrapper that ensures the inner value is always positive.
///
/// You cannot create a `Positive` with a negative value:
///
/// ```compile_fail
/// let p = my_types::Positive::new(-5); // Won't compile!
/// ```
pub struct Positive(u32);

impl Positive {
    pub const fn new(value: u32) -> Self {
        Positive(value)
    }
}

The test passes only if compilation fails. This documents invariants enforced by the type system.

Hiding Boilerplate with #

Real examples often need imports, setup code, or error handling that clutters the documentation. Lines starting with # (hash followed by space) are compiled but hidden from rendered docs.

Here’s what users see in the documentation:

/// Parses a configuration file and returns the settings.
///
/// # Examples
///
/// ```
/// let config = my_app::parse_config("app.toml")?;
/// assert_eq!(config.timeout, 30);
/// # Ok::<(), my_app::ConfigError>(())
/// ```
pub fn parse_config(path: &str) -> Result<Config, ConfigError> {
    // ...
}

The rendered documentation shows just the essential two lines. But the actual test includes the hidden Ok::<(), my_app::ConfigError>(()) that makes the ? operator work.

For more complex setup, you might hide several lines:

/// Processes items from a queue.
///
/// ```
/// # use my_queue::{Queue, Item};
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # let mut queue = Queue::new();
/// # queue.push(Item::new("test"));
/// let item = queue.pop()?;
/// process_item(item);
/// # Ok(())
/// # }
/// # fn process_item(_: my_queue::Item) {}
/// ```

Users see a clean two-line example. The test sees complete, runnable code. Use this judiciously—if you’re hiding more than you’re showing, consider whether a unit test would be more appropriate.

Testing Complex Scenarios

Async Doc Tests

Async examples need a runtime. You can use tokio::main or set up the runtime manually:

/// Fetches data from the API.
///
/// ```
/// # use my_client::fetch_data;
/// # tokio_test::block_on(async {
/// let data = fetch_data("https://api.example.com/users").await?;
/// assert!(!data.is_empty());
/// # Ok::<(), my_client::ApiError>(())
/// # });
/// ```
pub async fn fetch_data(url: &str) -> Result<Vec<User>, ApiError> {
    // ...
}

Alternatively, with the tokio feature enabled in dev-dependencies:

/// ```
/// #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let result = my_async::do_something().await?;
/// # Ok(())
/// # }
/// ```

Error Handling with ?

The ? operator requires a Result return type. Since doc tests wrap your code in main(), you need to specify the return type:

/// Reads and parses a JSON file.
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let data: serde_json::Value = my_lib::read_json("data.json")?;
/// println!("{}", data["name"]);
/// # Ok(())
/// # }
/// ```

The turbofish syntax Ok::<(), ErrorType>(()) is a common shorthand when you don’t want to write out the full main function.

Testing Private Items

Doc tests run as external code, so they can’t access private items directly. For internal documentation, use regular #[test] functions instead. However, you can test private items through public interfaces or by making items pub(crate) with #[doc(hidden)].

Common Pitfalls and Debugging

The Implicit Main Wrapper

Rust wraps your doc test in:

fn main() {
    // your code here
}

This means you can’t define your own main unless you hide the implicit one. If you need custom main behavior:

/// ```
/// fn main() {
///     // Custom main with specific setup
///     std::env::set_var("MY_VAR", "value");
///     my_lib::init();
/// }
/// ```

Missing Imports

Doc tests don’t automatically import your crate’s contents. You need explicit use statements (often hidden):

/// ```
/// # use my_crate::prelude::*;
/// let widget = Widget::new();
/// ```

Debugging Failed Tests

When a doc test fails, the error message includes the line number in your source file. But the actual test code is generated, making debugging tricky.

Run with --nocapture to see println output:

cargo test --doc -- --nocapture

For complex failures, temporarily expand your example into a full program and test it separately.

Best Practices

Keep examples focused. Each code block should demonstrate one concept. If you’re testing edge cases, write multiple small examples rather than one comprehensive one.

Test the happy path. Doc tests are documentation first. Show users how to use your API correctly. Save exhaustive edge case testing for unit tests.

Use doc tests as API feedback. If writing an example feels awkward, your API might need work. Doc tests force you to use your own API as a consumer would. Embrace that friction.

Don’t duplicate unit tests. Doc tests verify that examples work. Unit tests verify that implementations are correct. If you’re writing the same assertions in both places, you’re doing it wrong.

Document error conditions separately. Use should_panic and compile_fail examples to show what not to do, but keep them distinct from success examples.

Run doc tests in CI. They’re part of cargo test by default, but make sure your CI actually runs the full test suite. Documentation that compiles locally but fails in CI is still broken documentation.

Doc tests are one of Rust’s best features for maintaining quality documentation. They turn your examples into contracts, ensuring that what users read is what actually works. Use them liberally, but use them wisely—they’re documentation that happens to be tested, not tests that happen to be documented.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.