The Rust Testing Model
Every language eventually needs an answer to "where do tests live and what do they see," and Rust answers that question unusually directly by baking a test harness into cargo itself.
There is no equivalent of choosing a runner or wiring up a config file before the first test compiles.
Instead, the language gives you three distinct places to put test code, each with a different relationship to your crate's internals, and understanding why those three places exist is the key to reading and organizing any Rust test suite you encounter.
This page is the conceptual anchor for the rest of the testing section: it explains the harness itself, the unit/integration boundary, and why doctests are treated as a first-class test kind rather than a documentation afterthought.
Summary
- Rust's test harness organizes tests by what they can see, not just by where the files sit on disk.
- Insight: Without a visibility-based split, test suites drift toward either testing private internals that consumers never touch, or never exercising the public contract that actually ships.
- Key Concepts: test harness,
#[cfg(test)], unit tests, integration tests, doctests, compilation unit. - When to Use: Reach for unit tests when checking internal logic, integration tests when checking the public API as a consumer would use it, and doctests when an example in your documentation needs to stay provably correct.
- Limitations/Trade-offs: The built-in harness is opinionated and minimal; it does not natively support fixtures, parameterized tests, or rich reporting the way some other ecosystems' frameworks do.
- Related Topics: test organization, mocking and test doubles, coverage and CI, property-based testing.
Foundations
A test harness is the program that discovers test functions, runs them, and reports pass/fail results.
In most ecosystems this is a separate library you add as a dependency, but in Rust the standard library and cargo ship a harness together, so cargo test works on a brand-new project with zero setup.
Any function annotated #[test] becomes a test case, and the harness compiles a special test binary that runs every discovered #[test] function, capturing output and reporting failures with the assertion that tripped.
The simplest way to think about this is that cargo test does not run your binary or library as-is; it builds an alternate version of your code with test scaffolding compiled in, runs that instead, and throws the scaffolding away afterward.
That alternate build is gated by the #[cfg(test)] attribute, which tells the compiler "only include this code when compiling for tests."
Because of cfg(test), test code adds zero size and zero runtime cost to the binary you actually ship, which is a deliberate design choice: tests are a development-time concern, not a production dependency.
A minimal example shows the shape every Rust developer eventually memorizes:
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*; // pulls in add() from the parent module
#[test]
fn adds_two_positive_numbers() {
assert_eq!(add(2, 2), 4);
}
}The mod tests block lives beside the code it exercises, and use super::* is what gives it access to private items the outside world can never see.
That single detail, private access from inside the crate, is the seed that grows into the entire unit/integration split covered next.
Mechanics & Interactions
The harness draws its most important line around compilation units: what gets compiled together, and therefore what can see what.
A #[cfg(test)] module inside src/ compiles as part of the same crate as the code it tests, so it has full access to private functions, private fields, and unexported types.
Code under tests/, by contrast, is compiled as a set of entirely separate crates, one per file, each of which depends on your library the same way an external consumer would: through use my_crate::... and only the items marked pub.
This is not an arbitrary folder convention; it is the mechanism Rust uses to make a promise: an integration test can never accidentally pass by reaching around your public API, because the compiler simply will not let it see anything else.
That constraint has a direct consequence on what each test type is good for. Unit tests answer "does this internal algorithm behave correctly," while integration tests answer "does the thing I actually export behave correctly when used the way a real caller would use it."
my_crate/
├── src/
│ ├── lib.rs # #[cfg(test)] mods live beside logic
│ └── parser.rs
└── tests/
├── api_smoke.rs # separate crate, sees only `pub` items
└── cli_behavior.rs # separate crate, compiled independently
Doctests occupy a third, structurally different position. A doctest is a code block embedded in a /// doc comment, and rustdoc extracts every such block, wraps it in a hidden fn main(), and compiles and runs it as its own tiny program during cargo test.
Doctests only ever exercise public items, because they are literally illustrating how a caller would write use my_crate::thing; and then use it, so they share integration tests' public-API-only visibility but serve a different purpose: proving that the example in your documentation still compiles and still produces the result the prose claims.
The pitfall most newcomers hit is assuming these three kinds of tests are interchangeable, when in fact each one fails to catch a different category of bug. A unit test suite with no integration tests can pass while the public API is unusable; an integration suite with no doctests can pass while every code sample in your cargo doc output is stale and wrong.
Advanced Considerations & Applications
At scale, the three-way split becomes an organizational tool as much as a technical one. Large crates often push almost all logic into small, pure functions covered by unit tests, keep a lean set of integration tests around the handful of true public entry points, and rely on doctests specifically for the examples that appear on docs.rs.
This division also interacts with build performance in a way that surprises people coming from other languages: because every file in tests/ compiles as its own crate, and each links against your full library, a tests/ directory with dozens of files can noticeably slow down cargo test compared to the same test count expressed as unit tests, since the linker work is repeated per file.
Doctests carry their own operational cost too. They run more slowly per-test than unit tests because each doc example spins up as an independent compiled program, which is one reason large crates sometimes mark expensive examples no_run (compiled but not executed) rather than leaving them fully live.
The three-kind split also shapes how CI pipelines are structured: cargo test --lib isolates unit tests for a fast inner-loop signal, cargo test --test <name> runs one integration file at a time, and cargo test --doc runs the documentation examples as their own gate, letting teams tune which signal blocks a merge versus which runs nightly.
Where teams push past the built-in harness entirely is property-based and snapshot testing, which are not new test locations but new assertion strategies layered on top of the same #[test] mechanism, and coverage tooling, which instruments the same three test kinds rather than replacing them.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Unit tests (#[cfg(test)]) | Fast, sees private internals, cheap to run in bulk | Can pass while the public API is broken | Pure logic, parsers, edge-case-heavy functions |
Integration tests (tests/) | Mirrors real consumer usage, catches wiring bugs | Slower to compile, only sees pub surface | HTTP handlers, CLI entry points, cross-module wiring |
| Doctests | Guarantees examples in docs actually compile | Slowest per-test, awkward for async/complex setup | Public API usage examples on docs.rs |
Common Misconceptions
- "Rust needs a testing framework like other languages." - The
#[test]harness ships with the toolchain; frameworks likeproptestorcriterionadd capabilities on top, they don't replace the base mechanism. - "
tests/is just a naming convention, like atest/folder in other ecosystems." - It's a compilation boundary enforced by the compiler: each file there is its own crate that can only see your public API, not merely a directory the harness happens to scan. - "Doctests are optional documentation fluff." - They are real, compiled, executed tests that fail your build the same as any other; they exist specifically so stale examples can't ship silently.
- "Unit tests and integration tests are interchangeable; pick whichever is more convenient." - They answer different questions (internal correctness versus public contract correctness), so a suite dominated by one type has a real, structural blind spot.
- "More
tests/files means better organization." - Each file is a separate crate that relinks your library, so splitting integration tests too finely can slowcargo testwithout adding coverage value.
FAQs
What is the Rust test harness, in one sentence?
It's the built-in mechanism, shipped with cargo and the standard library, that discovers #[test]-annotated functions, compiles a special test binary, and reports pass/fail results without any external dependency.
Why does Rust bundle testing into the toolchain instead of leaving it to third-party crates?
- Removes a decision point for every new project:
cargo testworks immediately. - Keeps a consistent baseline vocabulary (
#[test],assert_eq!) across the whole ecosystem. - Third-party crates still add value on top (property testing, snapshots, benchmarking) without needing to reinvent test discovery.
How does cargo test actually decide what counts as a test?
It scans for functions annotated #[test] inside code compiled under #[cfg(test)], compiles a dedicated test binary containing them, then separately compiles and runs each file under tests/ and each doc example, executing all three passes in one invocation.
How do #[cfg(test)] modules see private functions when tests/ files can't?
A #[cfg(test)] mod is part of the same compilation unit as the code around it, so normal Rust visibility rules apply and use super::* reaches private items; a tests/*.rs file is compiled as an independent crate that depends on yours like any external consumer, so only pub items are visible to it.
Is a doctest a "real" test or just a documentation nicety?
It's a real, independently compiled and executed test; rustdoc extracts each fenced code block from a /// comment, wraps it in a hidden function, and cargo test fails the build if that code doesn't compile or its assertions don't pass.
When should I write an integration test instead of a unit test?
When you want to verify behavior the way an actual downstream user would experience it, such as calling a public function, hitting an HTTP handler, or invoking a CLI, since integration tests only exercise what's actually exported.
What's the trade-off of relying only on unit tests?
Unit tests can all pass while the crate's public surface is broken or unusable, because they never route through the same use my_crate::... path a real consumer takes; a small integration suite closes that gap.
What's the trade-off of relying only on integration tests?
They compile more slowly (each file is its own crate linked against your full library) and can't see private internals, making it harder to pin down edge cases deep inside an algorithm without exercising the whole public path around it.
Do doctests replace the need for integration tests?
No; doctests exist to keep documentation examples honest, not to provide thorough coverage, and they're usually kept short and illustrative rather than exhaustive.
Why do doctests sometimes use no_run instead of actually executing?
Some examples are expensive, need network access, or would have side effects unsuitable for a test run, so no_run tells rustdoc to compile the example (catching syntax and type errors) without executing it.
Does the three-way split (unit, integration, doctest) affect build times?
Yes: unit tests compile once as part of the crate's test build, but each tests/*.rs file is a separate crate that relinks the full library, and each doctest compiles as its own tiny program, so integration and doc tests generally add more wall-clock time per test than unit tests do.
Can I run just one of the three test kinds?
Yes: cargo test --lib runs only unit tests, cargo test --test <name> runs one integration file, and cargo test --doc runs only doctests, which is commonly used to give each kind its own CI stage.
Related
- Testing Basics - hands-on walkthrough of
#[test], assertions, andcargo test - Unit vs Integration Tests - deeper comparison of the two locations and when to use each
- Doctests - full recipe for writing and gating documentation examples
- Test Organization - structuring shared fixtures and helpers across a suite
- Testing Best Practices - team-level standards for the testing pyramid
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.