Testing, documentation and CI
Unit tests in-module, integration tests, test organisation and fixtures, doc-tests, benchmarks, and a CI pipeline that lints and audits.
Unit and integration tests
// src/pricing.rs — unit tests live beside the code and see private items
pub fn discount(total: u64, member: bool) -> u64 {
if member { total * 9 / 10 } else { total }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn member_gets_ten_percent() {
assert_eq!(discount(100, true), 90);
}
#[test]
fn free_stays_free() {
assert_eq!(discount(0, true), 0);
}
#[test]
#[should_panic(expected = "negative")]
fn negative_totals_panic() {
let t: i64 = -1;
assert!(t >= 0, "negative total");
}
#[test]
fn result_returning_test() -> Result<(), String> {
let parsed: u64 = "42".parse().map_err(|e| format!("{e}"))?;
assert_eq!(parsed, 42);
Ok(())
}
}// tests/checkout.rs — a separate crate that sees only the public API
use notes::{Store, Item};
fn fixture() -> Store {
let mut s = Store::open(":memory:");
s.add(Item::new("widget", 250)).unwrap();
s
}
#[test]
fn checkout_totals_the_basket() {
let store = fixture();
assert_eq!(store.total(), 250);
}
#[test]
#[ignore = "requires a running database"]
fn against_real_database() { /* ... */ }- Unit tests are in the same file behind
#[cfg(test)], so they can test private functions. Integration tests undertests/verify the public contract, which is what users depend on. - Each file under
tests/compiles as its own crate, so shared helper code needstests/common/mod.rsand amod common;declaration — a file namedcommon.rswould be treated as a test crate of its own. - A test returning
Resultcan use?instead of unwrapping, which produces far better failure output. #[should_panic]proves a panic occurs; addexpected = "substring"so it cannot pass for the wrong reason.- Run in parallel by default — so tests must not share mutable global state or a fixed file path. Use
tempfilefor per-test directories.
Doc-tests and benchmarks
/// Splits a `"k=v"` pair.
///
/// # Examples
///
/// ```
/// use notes::parse_pair;
///
/// assert_eq!(parse_pair("port=8080"), Some(("port", "8080")));
/// assert_eq!(parse_pair("broken"), None);
/// ```
///
/// # Panics
///
/// Never panics.
pub fn parse_pair(s: &str) -> Option<(&str, &str)> {
s.split_once('=')
}
// benches/encode.rs — a criterion benchmark
fn bench_encode(c: &mut criterion::Criterion) {
let data = payload();
c.bench_function("encode", |b| {
b.iter(|| encode(std::hint::black_box(&data)))
});
}
criterion_group!(benches, bench_encode);
criterion_main!(benches);cargo test # unit + integration + doc-tests
cargo test --doc # doc-tests only
cargo test -- --nocapture # show stdout from tests
cargo test -- --ignored # run the slow ones
cargo bench # criterion, with statistical comparison
cargo test --release # catch debug/release behaviour differences- Every code block in a doc comment is compiled and executed by
cargo test. Documentation that drifts from the code therefore fails the build, which is the strongest reason to keep examples in docs rather than in a wiki. - Use
no_run,ignoreorcompile_failwhen an example genuinely cannot run, but treat each as a debt you are choosing to carry. std::hint::black_boxstops the optimiser from deleting the work you are trying to measure — without it a benchmark can report near-zero time.- Benchmarks are for comparing two versions of your code on the same machine. Absolute numbers from someone else's laptop mean nothing.
A CI pipeline
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --all-features -- -D warnings
- run: cargo test --all-features
- run: cargo test --release
- run: cargo doc --no-deps
env:
RUSTDOCFLAGS: "-D warnings"| Step | Why it is in the pipeline |
|---|---|
cargo fmt --check | Removes formatting from code review entirely |
cargo clippy -D warnings | Catches redundant clones, suspicious comparisons and idiom problems |
cargo test --all-features | Every feature combination you claim to support, at least the union |
cargo test --release | Overflow wraps and debug assertions disappear, so behaviour differs |
cargo doc with -D warnings | Fails on a broken intra-doc link before users find it |
cargo audit | Reports known advisories in your dependency tree |
cargo deny | Enforces licence and duplicate-version policy on top of that |
- Cache the registry and the
target/directory. A cold Rust build of a real project is minutes; a cached one is seconds. - Test the minimum supported Rust version from
rust-versionin a separate job, or you will silently raise your own requirement. - Run the tests in both debug and release. It is the only routine way to catch a difference in integer overflow behaviour or a test that depends on a debug assertion.
- Pin the toolchain in CI to a version you control rather than tracking stable automatically, so a compiler release never breaks the build without a commit.
- Keep
Cargo.lockcommitted and audit it. A review of a dependency bump is a review of an attack surface.
💡
Write the failing test before the fix, and put the reproduction in a unit test rather than a scratch binary. Rust's test harness makes that nearly free, and every bug that gets a test becomes a bug that cannot come back — which is a much better use of the time than debugging the same class of failure twice.
FAQ
How do I test code that reads the network or a clock?
Put the effect behind a trait: a
Clock, a Store, an HttpClient. Production wires the real implementation, tests wire a fake. Avoid global mocking frameworks; a trait and a struct are clearer and cost nothing at run time.Why does cargo clippy fail my build with -D warnings?
That flag promotes every clippy lint to an error. It is deliberate: it keeps new lints from accumulating. If one is genuinely wrong for your code, add a targeted
#[allow(...)] with a comment explaining why.Related
Modules, crates and project structure A command-line tool end to end
Last refreshed 2026-09-18.