Rust Doctests: Testing Code Examples in Documentation
Rust has a feature that no other mainstream language matches: every code example in your documentation is compiled and run as a test by default. There is no special setup, no framework to install, no annotation to add. Write a /// ``` rust block in your doc comment, and cargo test runs it. If the code does not compile, or panics, or produces unexpected behavior, the test fails.
This is not just a convenience. It is a correctness guarantee. Rust library authors can ship documentation with high confidence that the examples actually work because the compiler has verified them. This guide covers everything from basic doc comments through hidden setup lines, attributes, and integration with the rest of your test suite.
How Rust Doctests Work
When you run cargo test, Rust does three things:
- Runs unit tests (
#[test]functions inside your source files) - Runs integration tests (files in the
tests/directory) - Runs doctests (code blocks in
///and//!comments across your entire codebase)
The doctest runner extracts each ```rust block, wraps it in a fn main() with appropriate use statements, compiles it as a standalone binary, and runs it. Each block becomes its own test entry in the output.
Writing Your First Doctest
/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// let result = my_crate::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}Run it:
cargo test --docOr run everything including doctests:
cargo testOutput:
Doc-tests my_crate
running 1 test
test src/lib.rs - add (line 6) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outNote that the language specifier after the triple backtick is optional for Rust files — bare ``` is assumed to be Rust in doc comments. Use ```rust for clarity or when embedding in markdown files that might be rendered elsewhere.
The # Prefix: Hiding Setup Code
Not all setup code belongs in the public documentation. Import statements, helper structs, and boilerplate can clutter examples. Prefix any line with # to hide it from rendered documentation while still running it in the test:
/// Parses a configuration file and returns the settings.
///
/// # Examples
///
/// ```
/// # use std::io::Write;
/// # let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
/// # writeln!(tmpfile, "[server]\nport = 8080").unwrap();
/// # let path = tmpfile.path().to_str().unwrap();
/// let config = my_crate::parse_config(path).unwrap();
/// assert_eq!(config.server.port, 8080);
/// ```
pub fn parse_config(path: &str) -> Result<Config, ConfigError> {
// ...
}In rendered documentation (docs.rs or cargo doc), users see:
let config = my_crate::parse_config(path).unwrap();
assert_eq!(config.server.port, 8080);In the actual test, the full setup runs. This is the key feature that makes Rust doctests practical for anything beyond trivial examples.
Module-Level Documentation with //!
/// documents the item below it. //! documents the item it is inside — typically used at the top of src/lib.rs to document the crate itself:
//! # My Crate
//!
//! `my_crate` provides utilities for processing configuration files.
//!
//! ## Quick Start
//!
//! ```
//! use my_crate::{Config, parse_config};
//!
//! let config = parse_config("config.toml").unwrap();
//! println!("Server port: {}", config.server.port);
//! ```The //! examples run as doctests just like /// examples. Put your most important usage examples here — they are the first thing users see.
Attributes: ignore, no_run, should_panic, compile_fail
ignore — marks a doctest as ignored. It will not be compiled or run:
/// ```ignore
/// // This example requires a running database and won't work in CI
/// let conn = Database::connect("postgres://localhost/mydb").unwrap();
/// ```Use ignore sparingly. It means the example is untested, which defeats the purpose. Consider whether you can use hidden setup lines to make it work instead.
no_run — compiles the example but does not run it. Use for examples that would have side effects (filesystem writes, network calls) but you still want to verify compile-time correctness:
/// ```no_run
/// use my_crate::Server;
///
/// let server = Server::new("0.0.0.0:8080");
/// server.run(); // Would actually start a server if executed
/// ```This is stronger than ignore because the code is still type-checked and must compile. You only skip execution.
should_panic — the example must panic for the test to pass. Use for documenting error conditions:
/// Divides two numbers. Panics if the divisor is zero.
///
/// # Panics
///
/// ```should_panic
/// my_crate::divide(10, 0); // panics!
/// ```
pub fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Division by zero");
}
a / b
}compile_fail — the example must fail to compile. Use for demonstrating type errors or API misuse:
/// `MyStruct` does not implement `Clone`.
///
/// ```compile_fail
/// let a = my_crate::MyStruct::new();
/// let b = a.clone(); // This won't compile!
/// ```
pub struct MyStruct {
// ...
}compile_fail is particularly useful when documenting ownership and borrowing constraints — it lets you show, not just tell, what the compiler prevents.
Using Crate Features in Doctests
By default, doctests do not enable any optional features. To test feature-gated code, enable the feature in your test command:
cargo test --doc --features async-runtimeOr in the doctest itself with a cfg attribute, though this is less common.
For a crate with multiple feature flags, consider a CI matrix:
# .github/workflows/test.yml
strategy:
matrix:
features: ["", "async", "serde", "async,serde"]
steps:
- run: cargo test --doc --features "${{ matrix.features }}"Testing Result and Error Handling
The ? operator works in doctests because Rust wraps them in a main() that returns Result:
/// Reads a file and returns its contents.
///
/// # Examples
///
/// ```
/// # use std::io::Write;
/// # let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
/// # write!(tmpfile, "hello world").unwrap();
/// # let path = tmpfile.path().to_str().unwrap();
/// let contents = my_crate::read_file(path)?;
/// assert_eq!(contents, "hello world");
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn read_file(path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(path)
}The hidden # Ok::<(), Box<dyn std::error::Error>>(()) at the end satisfies the Result return type that Rust infers for the implicit main(). This is a common pattern when using ? in doctests.
Doctests for Traits and Implementations
When documenting a trait, the example must provide a concrete implementation:
/// A type that can be serialized to a string.
///
/// # Examples
///
/// Implementing `Serialize` for a custom type:
///
/// ```
/// use my_crate::Serialize;
///
/// struct Point {
/// x: f64,
/// y: f64,
/// }
///
/// impl Serialize for Point {
/// fn serialize(&self) -> String {
/// format!("({}, {})", self.x, self.y)
/// }
/// }
///
/// let p = Point { x: 1.0, y: 2.0 };
/// assert_eq!(p.serialize(), "(1, 2)");
/// ```
pub trait Serialize {
fn serialize(&self) -> String;
}This pattern is especially valuable for trait libraries where users need to understand not just how to use existing implementations, but how to write their own.
Doctests vs #[test]: When to Use Each
Use doctests for:
- Public API surface — every public function, struct, and trait that users will call
- Common usage patterns you want to appear in generated documentation
- Demonstrating how types compose together
- Showing error cases and what panics look like
Use #[test] for:
- Internal implementation details that should not be in the docs
- Edge cases and corner cases that would clutter documentation
- Tests requiring complex setup that cannot be hidden cleanly
- Property-based testing and fuzzing
- Tests that mutate shared state or require specific ordering
A well-structured Rust library has both. The #[test] suite is comprehensive — it covers every branch, every edge case, every error path. The doctests are curated — they show the happy path and one or two important error cases, clearly enough that a new user can understand the API at a glance.
A Complete Example: A String Utilities Library
// src/lib.rs
//! String utilities for common text processing operations.
//!
//! ```
//! use strutils::{truncate, capitalize, word_count};
//!
//! let text = "hello world, this is a test";
//! assert_eq!(word_count(text), 6);
//! assert_eq!(truncate(text, 11), "hello world");
//! assert_eq!(capitalize("hello"), "Hello");
//! ```
/// Truncates a string to a maximum length, without cutting mid-word.
///
/// # Examples
///
/// ```
/// assert_eq!(strutils::truncate("hello world", 5), "hello");
/// assert_eq!(strutils::truncate("hello world", 11), "hello world");
/// assert_eq!(strutils::truncate("hello world", 100), "hello world");
/// ```
///
/// Returns the full string if max_len exceeds the string length:
///
/// ```
/// assert_eq!(strutils::truncate("hi", 1000), "hi");
/// ```
pub fn truncate(s: &str, max_len: usize) -> &str {
if s.len() <= max_len {
return s;
}
match s[..max_len].rfind(' ') {
Some(idx) => &s[..idx],
None => &s[..max_len],
}
}
/// Capitalizes the first letter of a string.
///
/// # Examples
///
/// ```
/// assert_eq!(strutils::capitalize("hello"), "Hello");
/// assert_eq!(strutils::capitalize("HELLO"), "HELLO");
/// assert_eq!(strutils::capitalize(""), "");
/// ```
///
/// Panics on non-ASCII strings (use unicode normalization for those):
///
/// ```should_panic
/// strutils::capitalize("héllo"); // panics!
/// ```
pub fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => {
if !c.is_ascii() {
panic!("capitalize requires ASCII input");
}
c.to_uppercase().to_string() + chars.as_str()
}
}
}
/// Counts the number of words in a string.
///
/// Words are separated by whitespace.
///
/// # Examples
///
/// ```
/// assert_eq!(strutils::word_count("hello world"), 2);
/// assert_eq!(strutils::word_count("one two three four five"), 5);
/// assert_eq!(strutils::word_count(""), 0);
/// assert_eq!(strutils::word_count(" spaces everywhere "), 2);
/// ```
pub fn word_count(s: &str) -> usize {
s.split_whitespace().count()
}Running cargo test on this produces:
Doc-tests strutils
running 7 tests
test src/lib.rs - (line 3) ... ok
test src/lib.rs - capitalize (line 38) ... ok
test src/lib.rs - capitalize (line 45) ... ok
test src/lib.rs - truncate (line 17) ... ok
test src/lib.rs - truncate (line 24) ... ok
test src/lib.rs - word_count (line 56) ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outSeven tests, zero extra setup, zero test file to maintain — the documentation is the test.
Common Pitfalls
Missing use statements — each doctest is an independent compilation unit. It does not inherit the use statements from the surrounding file. Always include full paths or explicit use statements:
// Wrong — will fail to compile
/// ```
/// let x = MyStruct::new(); // MyStruct not in scope!
/// ```
// Right
/// ```
/// use my_crate::MyStruct;
/// let x = MyStruct::new();
/// ```The implicit extern crate — in Rust 2018 edition and later, you do not need extern crate my_crate in doctests. The crate is in scope automatically. In Rust 2015 edition projects, you may need to add it.
Unstable features — doctests run with the same feature flags as the crate, but compiler feature gates (#![feature(...)]) need special handling. Use #[cfg_attr(doctest, allow(...))] when needed.
Slow test suites from many doctests — each doctest is a separate compilation. A crate with 100 documented functions has 100 separate compilations. This adds up. Use cargo test --doc to run only doctests, and consider whether every single function needs a doctest or whether module-level examples suffice for some.
Rust doctests are one of the few cases in software development where doing the right thing for users (clear, accurate documentation) and the right thing for maintainers (comprehensive tests) is exactly the same work. Write good examples in your doc comments, and you get tests for free. Let the examples rot, and cargo test will tell you immediately.
HelpMeTest monitors your Rust services 24/7 and generates AI-powered integration tests — start free at helpmetest.com