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 under tests/ verify the public contract, which is what users depend on.
  • Each file under tests/ compiles as its own crate, so shared helper code needs tests/common/mod.rs and a mod common; declaration — a file named common.rs would be treated as a test crate of its own.
  • A test returning Result can use ? instead of unwrapping, which produces far better failure output.
  • #[should_panic] proves a panic occurs; add expected = "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 tempfile for 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, ignore or compile_fail when an example genuinely cannot run, but treat each as a debt you are choosing to carry.
  • std::hint::black_box stops 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"
StepWhy it is in the pipeline
cargo fmt --checkRemoves formatting from code review entirely
cargo clippy -D warningsCatches redundant clones, suspicious comparisons and idiom problems
cargo test --all-featuresEvery feature combination you claim to support, at least the union
cargo test --releaseOverflow wraps and debug assertions disappear, so behaviour differs
cargo doc with -D warningsFails on a broken intra-doc link before users find it
cargo auditReports known advisories in your dependency tree
cargo denyEnforces 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-version in 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.lock committed 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.

Modules, crates and project structure A command-line tool end to end

Last refreshed 2026-09-18.