Skip to content

Rust Tests, Documentation and Cargo

Rust tests should check a stated contract, not merely repeat what the implementation happens to do. This lesson turns the fixture parser into a documented library with meaningful errors, then tests its internals, public API and documentation examples separately.

Prerequisites and outcome

Complete iterators and lazy adapters. You will build a library and binary in one package, write three kinds of tests, and distinguish a compile check from running behavioural assertions. This is the Part II checkpoint; it uses fixture strings and no external crates.

Define the contract before writing Rust tests

The parser trims surrounding whitespace, treats empty input as missing, preserves zero, and accepts signed integer millidegrees in the inclusive fixture range -40000 through 125000. That range is an application rule for this exercise, not a claim about safe operating temperatures or a particular Pi sensor specification.

Invalid integer text retains its input and underlying ParseIntError. A valid integer outside the fixture range produces a separate OutOfRange error. The report accounts for every input and runs its predicate only for accepted measured values. Unit tests establish boundaries; integration and doc tests establish the public calling contract.

Create the complete five-file project

1
2
3
cargo new pi_report
cd pi_report
mkdir -p tests

Replace or create the following files. Keep the package name pi_report so the external examples import the correct crate.

Cargo.toml

1
2
3
4
5
6
[package]
name = "pi_report"
version = "0.1.0"
edition = "2024"

[dependencies]

src/parsing.rs — domain errors and private implementation

use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

/// An invalid integer or an integer outside this exercise's fixture range.
#[derive(Debug)]
pub enum ParseError {
    InvalidInteger {
        input: String,
        source: ParseIntError,
    },
    OutOfRange {
        value: i32,
    },
}

impl fmt::Display for ParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidInteger { input, .. } => {
                write!(formatter, "invalid integer {input:?}")
            }
            Self::OutOfRange { value } => {
                write!(formatter, "reading outside fixture range: {value} mC")
            }
        }
    }
}

impl Error for ParseError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::InvalidInteger { source, .. } => Some(source),
            Self::OutOfRange { .. } => None,
        }
    }
}

/// Parse signed millidegrees in the inclusive fixture range -40000..=125000.
/// Whitespace-only input is missing; zero is a measured value.
///
/// # Errors
/// Returns InvalidInteger for invalid or overflowing i32 text, and
/// OutOfRange for an integer outside the application fixture range.
///
/// # Examples
/// ```
/// use pi_report::parse_reading;
/// assert_eq!(parse_reading(" 0 ").expect("valid fixture"), Some(0));
/// assert_eq!(parse_reading(" ").expect("missing fixture"), None);
/// ```
pub fn parse_reading(input: &str) -> Result<Option<i32>, ParseError> {
    let text = input.trim();
    if text.is_empty() {
        return Ok(None);
    }
    let value = text
        .parse::<i32>()
        .map_err(|source| ParseError::InvalidInteger {
            input: text.to_owned(),
            source,
        })?;
    if !(-40_000..=125_000).contains(&value) {
        return Err(ParseError::OutOfRange { value });
    }
    Ok(Some(value))
}

#[cfg(test)]
mod tests {
    use super::{ParseError, parse_reading};

    #[test]
    fn whitespace_is_missing() {
        assert_eq!(parse_reading(" \t\n").expect("missing fixture"), None);
    }

    #[test]
    fn zero_remains_measured() {
        assert_eq!(parse_reading("0").expect("valid fixture"), Some(0));
    }

    #[test]
    fn inclusive_fixture_boundaries_are_accepted() {
        assert_eq!(
            parse_reading("-40000").expect("lower boundary"),
            Some(-40_000)
        );
        assert_eq!(
            parse_reading("125000").expect("upper boundary"),
            Some(125_000)
        );
    }

    #[test]
    fn invalid_text_retains_its_context() {
        let error = parse_reading(" bad ").expect_err("invalid fixture");
        assert!(matches!(error, ParseError::InvalidInteger { input, .. } if input == "bad"));
    }

    #[test]
    fn overflowing_integer_is_not_a_range_checked_value() {
        assert!(matches!(
            parse_reading("2147483648"),
            Err(ParseError::InvalidInteger { .. })
        ));
    }

    #[test]
    fn integers_just_outside_the_range_are_rejected() {
        assert!(matches!(
            parse_reading("-40001"),
            Err(ParseError::OutOfRange { value: -40_001 })
        ));
        assert!(matches!(
            parse_reading("125001"),
            Err(ParseError::OutOfRange { value: 125_001 })
        ));
    }
}

src/lib.rs — documented public API and generic reporting

//! Parse fixture readings and account for measured, missing and invalid input.
//!
//! The implementation module is not part of the public API:
//! ```compile_fail
//! use pi_report::parsing::parse_reading;
//! ```

mod parsing;
pub use parsing::{ParseError, parse_reading};

/// Counts every input; matching is a subset of measured readings.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Summary {
    pub measured: usize,
    pub missing: usize,
    pub invalid: usize,
    pub matching: usize,
}

/// Apply the predicate only to valid measured fixture values.
///
/// ```
/// use pi_report::analyze;
/// let summary = analyze(["0", "", "bad"], |value| value >= 0);
/// assert_eq!((summary.measured, summary.missing, summary.invalid), (1, 1, 1));
/// assert_eq!(summary.matching, 1);
/// ```
pub fn analyze<I, F>(inputs: I, mut predicate: F) -> Summary
where
    I: IntoIterator,
    I::Item: AsRef<str>,
    F: FnMut(i32) -> bool,
{
    let mut summary = Summary::default();
    for result in inputs
        .into_iter()
        .map(|input| parse_reading(input.as_ref()))
    {
        match result {
            Ok(Some(value)) => {
                summary.measured += 1;
                if predicate(value) {
                    summary.matching += 1;
                }
            }
            Ok(None) => summary.missing += 1,
            Err(_error) => summary.invalid += 1,
        }
    }
    summary
}

#[cfg(test)]
mod tests {
    use super::{Summary, analyze};

    #[test]
    fn report_accounts_for_every_record() {
        let inputs = ["46700", "60000", "", "bad", "125001", "0"];
        let summary = analyze(inputs, |value| value >= 60_000);
        assert_eq!(
            summary,
            Summary {
                measured: 3,
                missing: 1,
                invalid: 2,
                matching: 1
            }
        );
        assert_eq!(
            summary.measured + summary.missing + summary.invalid,
            inputs.len()
        );
    }

    #[test]
    fn predicate_runs_only_for_measured_values() {
        let mut calls = 0;
        let summary = analyze(["0", "", "bad", "60000"], |_| {
            calls += 1;
            true
        });
        assert_eq!(calls, 2);
        assert_eq!(summary.matching, 2);
    }

    #[test]
    fn owned_strings_and_empty_input_are_supported() {
        let inputs = vec![String::from("0"), String::from(" ")];
        assert_eq!(analyze(inputs, |_| true).measured, 1);
        assert_eq!(analyze(Vec::<String>::new(), |_| true), Summary::default());
    }
}

src/main.rs — a consumer of the public crate

use pi_report::{analyze, parse_reading};

fn main() {
    let threshold = 60_000;
    let summary = analyze(["46700", "60000", "", "bad", "125001", "0"], |value| {
        value >= threshold
    });
    println!(
        "measured={}, missing={}, invalid={}, matching={}",
        summary.measured, summary.missing, summary.invalid, summary.matching
    );
    if let Err(error) = parse_reading("125001") {
        println!("error={error}");
    }
}

tests/public_api.rs — an external caller's assertions

use pi_report::{ParseError, analyze, parse_reading};
use std::error::Error;

#[test]
fn public_parser_preserves_the_zero_and_missing_contract() {
    assert_eq!(parse_reading("0").expect("valid fixture"), Some(0));
    assert_eq!(parse_reading("").expect("missing fixture"), None);
    assert_eq!(analyze(["0", ""], |_| true).measured, 1);
}

#[test]
fn public_errors_distinguish_syntax_from_domain_range() {
    let syntax = parse_reading("bad").expect_err("invalid fixture");
    assert_eq!(syntax.to_string(), "invalid integer \"bad\"");
    assert!(syntax.source().is_some());
    let range = parse_reading("125001").expect_err("outside fixture range");
    assert!(matches!(range, ParseError::OutOfRange { value: 125_001 }));
    assert!(range.source().is_none());
}

Run each verification layer

1
2
3
4
5
6
7
8
9
cargo check --all-targets
cargo test
cargo test --lib
cargo test --test public_api
cargo test --doc
cargo test --release
cargo fmt --check
cargo doc --no-deps
cargo run --quiet

The default test command runs 9 library unit tests, 2 integration tests and 3 documentation tests, including the compile-fail example. The binary has no tests of its own. Separate target commands help locate a failure; they are not additional unique tests. The release command checks the same contracts under that build profile.

Expected binary output:

measured=3, missing=1, invalid=2, matching=1
error=reading outside fixture range: 125001 mC

Unit, integration and documentation tests answer different questions

Tests under cfg(test) in library source can inspect the surrounding crate's private implementation. They exercise the parser's boundary rules and the report's accounting. Integration tests under tests/ are separate crates and import the public re-exports, like a downstream consumer. See the Book's test organisation.

The triple-slash comments document items; //! documents the enclosing crate or module. Rustdoc compiles ordinary Rust examples and runs them as tests. compile_fail expects rejection, but does not by itself prove the intended diagnostic: a typo could also fail compilation. We therefore also verify the private-module access directly. no_run compiles without running, while ignore skips a test; neither is a substitute for a passing behavioural example. See documentation tests.

Assertions state specific expectations. assert_eq reports actual and expected values, and requires suitable comparison and debug implementations. expect in a test fixture makes an unexpected error fail the test; this is not a recommendation to panic on every production input. A should_panic test should target an actual panic contract, not hide an ordinary Result error. The Book explains test assertions.

Cargo checks are tools, not language guarantees

cargo check type-checks selected targets but does not execute their assertions or fully replace a native build. --all-targets includes test targets for checking, but does not run rustdoc examples. cargo test builds and runs the selected tests; --no-run builds tests without executing them. These distinctions are documented in cargo check and cargo test.

Tests can run in parallel. Keep fixtures independent of shared files, process-wide environment and external hardware. Test filters can leave other tests unexecuted; -- --test-threads=1 controls the test harness, not Cargo compilation. Use -- --nocapture when output is useful for debugging, and never treat printed logs alone as assertions.

Cargo.toml expresses package requirements, including the edition and dependency declarations; Cargo.lock records resolved dependencies. Preserve the lockfile for this executable project and use --locked to detect unexpected resolution changes once it exists. --offline avoids network access but cannot provide dependencies absent from the local cache. Our project has no external dependencies. See manifest and lockfile roles.

Formatting and documentation generation are separate tools. Clippy can suggest additional checks if installed, but a clean lint report is not a proof of all program properties. Do not install extra components merely to run this example. A finite test suite cannot establish exhaustive correctness, memory-model claims or performance.

The public error and borrowing policy

ParseError implements Debug, Display and Error. InvalidInteger retains the low-level source separately from its contextual display text; callers can inspect the source rather than parse a human-readable message. OutOfRange has no underlying error. The Error trait describes that separation.

analyze accepts any suitable IntoIterator whose Item can cheaply expose text through AsRef. It supports borrowed strings and owned Strings without demanding one container type. The parser returns owned integers or owned error context, so it does not return a borrow into an item that the iterator may drop. The summary counts invalid records; it intentionally does not retain all error objects. Use parse_reading directly when diagnostics are needed.

Deliberately failing: an assertion with the wrong contract

In a separate copy of the project, add tests/broken_contract.rs:

1
2
3
4
5
6
use pi_report::parse_reading;

#[test]
fn zero_is_not_missing() {
    assert_eq!(parse_reading("0").expect("valid fixture"), None);
}

cargo check --all-targets succeeds, but cargo test --test broken_contract fails: zero produces Some(0), not None. Repair the assertion to expect Some(0); do not change the parser to agree with an incorrect test. Keep the deliberately failing test out of the normal project.

Part II checkpoint: defend and extend the public contract

  1. Explain why the parser's implementation module remains private while its API is reachable from the binary, external test and doc example. Replace main's import with pi_report::parsing::parse_reading: expect E0603.
  2. Run the three test categories individually and identify their boundaries. Explain why cargo check alone cannot detect the deliberately wrong zero assertion.
  3. Raise the predicate threshold from 60000 to 60001 in main: measured/missing/invalid counts stay 3/1/2 while matching becomes 0. Use a stateful FnMut predicate to record valid inputs; existing tests prove missing and invalid inputs never call it.
  4. Change the parser's range check to reject 125000 instead of 125001: the upper-boundary unit test must fail. Change missing input into Some(0): both the missing-input tests and the documented example must fail.
  5. Pass a Vec rather than an array of borrowed strings to analyze: the same fixture report should be produced. Explain why the result needs no lifetime tied to that container.
  6. Repair broken_contract's assertion and rerun its target. Introduce a doc-example typo only in a scratch copy and verify that cargo test --doc fails rather than assuming generated HTML validates examples.

Do not update correct tests just to make an accidental policy change green. Decide whether a changed contract is intended, update its documentation and boundaries consistently, then verify the public caller experience again.

Verification and next step

On October 10, 2026, all five files were verified together on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. All-target checking, 9 unit tests, 2 integration tests, 3 doc tests, separate target selection, release tests, formatting, documentation generation and debug/release output comparisons passed. Locked testing, build-only testing and name-filtered harness options were also checked. The assertion repair, changed threshold and owned-input report passed; private-module access produced E0603. Wrong zero assertions, range/missing policy changes and a doc-example typo failed their intended test targets. No live sensor or performance result is claimed.

Next: Box, Deref and Drop, separating owned storage, borrowed access and cleanup.

Previous: iterators and lazy adapters · Course overview

Donate