<< Home

Chapter 13: Testing, Benchmarking, and Docs

Quality in Rust is built into the toolchain. You can write tests next to your code, run them with a single command, measure performance with reliable harnesses, and publish documentation generated from your comments. This chapter shows how unit and integration tests work, how to add property tests with proptest, how to benchmark with criterion, how to write doc tests, and how to wire everything into a simple continuous integration setup.

💡 The examples in this chapter use cargo commands. Run them at your project root; the workspace layout affects how tests and benches are discovered.

Unit and integration tests

Rust has two primary testing styles. Unit tests live close to the code they validate. Integration tests exercise your public API through the crate boundary. Both styles use the same test runner that cargo invokes. You can mix and match; start with focused unit tests, then add integration tests for end to end behaviour.

Module based unit tests with #[cfg(test)]

A common pattern places unit tests in a private tests module within the same file. The module is compiled only during tests; it is excluded from normal builds. This keeps helper functions and fixtures close to the code under test without leaking them into your final binary or library.

// src/math.rs
pub fn add(a: i32, b: i32) -> i32 {
  a + b
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn adds_positive_numbers() {
    assert_eq!(add(2, 3), 5);
  }

  #[test]
  fn adds_negative_numbers() {
    assert_eq!(add(-2, -3), -5);
  }

  #[test]
  #[should_panic(expected = "overflow")]
  fn panics_on_overflow_in_debug() {
    let _ = add(i32::MAX, 1);
  }
}

Run unit tests for the current crate with cargo test. Use cargo test name_fragment to run a subset. The runner filters by test function names and module paths.

Integration tests in the tests/ directory

Integration tests go in a top level tests/ folder and are compiled as separate crates. Each .rs file becomes its own test crate that depends on your library crate. This ensures you exercise only the public interface, not private internals.

// src/lib.rs
pub fn slugify(s: &str) -> String {
  s.to_lowercase().replace(' ', "-")
}

// tests/slug_test.rs
use mycrate::slugify;

#[test]
fn turns_spaces_into_hyphens() {
  assert_eq!(slugify("Hello World"), "hello-world");
}

Use cargo test --test slug_test to run a single integration file. Add a tests/common.rs module or a submodule tree like tests/common/mod.rs when you need shared helpers across multiple integration tests.

Assertions, matching, and custom messages

Rust’s standard library provides helpful assertion macros. Use equality assertions for exact results; use pattern matching when you only care about shape or variants; add custom messages to clarify failures.

#[test]
fn examples_of_asserts() {
  let value = Some(10);
  assert!(value.is_some(), "expected Some(…)");
  assert_eq!(2 * 3, 6, "basic arithmetic must hold");
  match value {
    Some(v) if v > 5 => {}
    other => panic!("unexpected value: {:?}", other),
  }
}
⚠️ Tests run in parallel by default. If your tests rely on global state or the filesystem, provide isolation. Use temporary directories, unique file names, or serialise with -- --test-threads=1 as a last resort.

Fixtures and test organisation

Keep tests readable by extracting setup code into small helpers. Prefer values and builders over mutable global state. For filesystem fixtures, create a temporary directory and clean up using tempfile. Table driven tests are straightforward; iterate over cases and assert each outcome.

#[derive(Debug)]
struct Case {
  input: &'static str,
  want: &'static str,
}

#[test]
fn table_driven_slugify() {
  let cases = [
    Case { input: "Hello World", want: "hello-world" },
    Case { input: "À bientôt",   want: "à-bientôt" },
  ];

  for c in cases {
    assert_eq!(slugify(c.input), c.want, "input: {}", c.input);
  }
}

Property testing with proptest

Property tests state invariants that must always hold, then generate many inputs automatically. This complements example based tests. Instead of a single example for each behaviour, you declare properties like idempotence, symmetry, or round trip encoding, and the framework searches for counterexamples.

Declaring properties and strategies

Install proptest in Cargo.toml. A strategy describes how to generate values. You can compose strategies to build complex inputs. The macro proptest! expands into multiple tests that cover many random cases with shrinking.

# Cargo.toml
[dev-dependencies]
proptest = "1"

# tests/prop_slug.rs
use mycrate::slugify;
use proptest::prelude::*;

proptest! {
  #[test]
  fn slug_is_lowercase(ref s in "\\PC*") {
    let slug = slugify(s);
    prop_assert!(slug.chars().all(|c| !c.is_uppercase()));
  }

  #[test]
  fn idempotent(input in "\\PC*") {
    let once = slugify(&input);
    let twice = slugify(&once);
    prop_assert_eq!(once, twice);
  }
}

When a failure occurs, proptest shrinks the input to a minimal counterexample. This makes debugging practical; you get a small case that reproduces the issue.

💡 Use domain specific strategies for realistic data. For example, define a strategy that yields valid URLs, JSON snippets, or constrained numbers; this produces failures that reflect real usage.

Composing strategies for structured data

Strategies are first class values. You can map and filter them to model constraints. The following example builds a simple URL like scheme://host/path and checks a round trip parse property.

use proptest::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq)]
struct SimpleUrl {
  scheme: String,
  host: String,
  path: String,
}

fn parse_simple(s: &str) -> Option<SimpleUrl> {
  let parts: Vec<_> = s.splitn(3, '/').collect();
  if parts.len() < 3 { return None; }
  let scheme_host = parts[0];
  let path = format!("/{}", parts[2]);
  let mut it = scheme_host.splitn(2, "://");
  let scheme = it.next()?.to_string();
  let host = it.next()?.to_string();
  Some(SimpleUrl { scheme, host, path })
}

fn render_simple(u: &SimpleUrl) -> String {
  format!("{}://{}{}", u.scheme, u.host, u.path)
}

proptest! {
  #[test]
  fn parse_render_roundtrip(
    scheme in "(http|https)",
    host in "[a-z]{1,8}\\.example\\.com",
    path in "/[a-z0-9/]{0,16}"
  ) {
    let url = SimpleUrl { scheme: scheme.to_string(), host, path };
    let s = render_simple(&url);
    prop_assert_eq!(parse_simple(&s), Some(url));
  }
}

Benchmarks and criterion

Microbenchmarks help you track performance over time. The criterion crate provides statistics, warm up, resampling, and regression detection. It is more reliable than the unstable built in harness. Benchmarks live under benches/. Each file defines groups of related measurements.

Setting up criterion with benches/

Add criterion as a development dependency. Create a bench file in benches/, then run cargo bench. The harness records results to target/criterion and can compare against a baseline.

# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

# benches/slug_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use mycrate::slugify;

fn bench_slugify(c: &mut Criterion) {
  let input = "The quick brown fox jumps over the lazy dog";
  c.bench_function("slugify baseline", |b| {
    b.iter(|| slugify(black_box(input)))
  });
}

criterion_group!(benches, bench_slugify);
criterion_main!(benches);

Open the HTML report to visualise distributions; it appears under target/criterion/…/report/index.html. For stable results, pin CPU frequency scaling and close background applications that create noise.

⚠️ Benchmark code runs in release mode by default. If your function relies on side effects, use black_box to prevent the optimiser from eliminating work.

Comparing algorithms and tracking regressions

Benchmarks are most useful when you compare competing approaches. Group related functions, then examine which inputs change the ordering. Criterion can track changes between runs and flag regressions with its statistical tests.

use criterion::{Criterion, BenchmarkId, criterion_group, criterion_main};

fn algo_a(s: &str) -> usize { s.bytes().filter(|b| b.is_ascii()).count() }
fn algo_b(s: &str) -> usize { s.chars().filter(|c| c.is_ascii()).count() }

fn compare_algos(c: &mut Criterion) {
  let mut group = c.benchmark_group("ascii_count");
  for size in [16usize, 256, 4096] {
    let input = "a".repeat(size);
    group.bench_with_input(BenchmarkId::new("bytes", size), &input, |b, s| {
      b.iter(|| algo_a(s))
    });
    group.bench_with_input(BenchmarkId::new("chars", size), &input, |b, s| {
      b.iter(|| algo_b(s))
    });
  }
  group.finish();
}

criterion_group!(benches, compare_algos);
criterion_main!(benches);

Doc tests and examples

Rust can compile and run examples embedded in your documentation. This prevents code drift. A doc comment marked with triple slashes becomes part of your public documentation and its code blocks are treated as tests by default. You can also publish standalone examples under examples/; these build like small binaries that depend on your crate.

Writing rustdoc comments with executable blocks

Use fenced code blocks with rust language tags. Prefer concise, self contained snippets. If your example requires extern crate or use lines, include them so the snippet compiles in isolation.

// src/lib.rs

/// Convert a string into a slug.
/// 
/// # Examples
/// 
/// ```rust
/// use mycrate::slugify;
/// let s = slugify("Hello World");
/// assert_eq!(s, "hello-world");
/// ```
pub fn slugify(s: &str) -> String {
  s.to_lowercase().replace(' ', "-")
}

Run all doc tests with cargo test --doc. Use attributes like ignore or no_run for examples that cannot execute, such as those that require network access. Keep these rare; tested code gives the strongest guarantee.

The examples/ directory for runnable demos

Place small programs under examples/ to demonstrate usage. They appear in documentation and can be executed directly. This is helpful for tutorials and quick start guides that readers can compile and run without scaffolding.

// examples/cli.rs
use mycrate::slugify;

fn main() {
  let arg = std::env::args().nth(1).unwrap_or_else(|| "Hello World".into());
  println!("{}", slugify(&arg));
}

Run the example with cargo run --example cli -- "Some Input". Keep examples focused; a single concept per file helps readers learn quickly.

💡 You can show multiple sections inside a doc comment using headers like # Panics, # Errors, and # Safety. Readers look for these conventional sections; use them consistently.

Continuous integration basics

Continuous integration runs your checks on every push. Even a lightweight pipeline improves confidence. Start with stable Rust; add nightly only when needed. Cache builds to keep runs fast. Fail the build on any test or lint error so that problems never merge undetected.

A minimal GitHub Actions workflow

Create a workflow file that installs the toolchain, caches dependencies, runs tests, and saves criterion reports as artifacts. This example uses stable Rust; adjust names and paths to match your crate. The parts with … indicate optional steps.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Build
        run: cargo build --verbose
      - name: Lint
        run: cargo clippy --all-targets --all-features -- -D warnings
      - name: Format check
        run: cargo fmt -- --check
      - name: Unit and integration tests
        run: cargo test --all-features
      - name: Doc tests
        run: cargo test --doc
      - name: Benchmarks & reports
        run: cargo bench || true  # allow benches to run without failing the job
      - name: Upload criterion reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: criterion-reports
          path: target/criterion/**/report/index.html

Keep CI fast by avoiding unnecessary feature matrices. Add a second job for nightly or additional platforms later. For crates with network calls, provide feature flags that swap real clients for fakes during tests.

⚠️ Do not run microbenchmarks on shared runners for performance gating. Results vary across machines. Use criterion’s comparison locally or on dedicated hardware; treat CI benches as informational artifacts.

Catching undefined behaviour with sanitizers

You can enable sanitizers in CI to catch memory and thread issues early. Use a nightly toolchain for the thread sanitizer; address and leak sanitizers work on stable for many targets. Add dedicated jobs because these flags slow builds.

# Example CI step
- name: Address sanitizer
  run: RUSTFLAGS="-Zsanitizer=address" RUSTDOCFLAGS="-Zsanitizer=address" \
       cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu

Summary of test types

The following table contrasts common test and example locations. Use it as a quick reference when deciding where to place new checks.

Location Purpose Visibility Runs with
#[cfg(test)] module Focused unit tests near implementation Private to crate cargo test
tests/ Public API integration tests Through crate boundary cargo test
examples/ Runnable usage demos Users can run directly cargo run --example …
Rustdoc code blocks Executable documentation Published docs cargo test --doc
benches/ with criterion Performance tracking and comparisons N/A cargo bench

Checklist before publishing

Before releasing a version, run through a short checklist. Confirm formatting, linting, tests, doc tests, and examples. Review benchmark trends. Ensure your CI workflow reflects these steps so that every pull request gets the same scrutiny.

# Local quality sweep
cargo fmt -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo test --doc
cargo bench  # review reports under target/criterion/…