Crabrix blog

Testing Rust: useful fixtures, boundaries and doctests

Write Rust tests that catch real mistakes. Choose useful boundaries, build deterministic fixtures, and connect unit tests, integration tests and doctests.

Begin with a promise, not a coverage target

A test is useful when it protects a behavior somebody depends on. For a configuration parser, that might be rejecting an empty key. For a report, it might be preserving every accepted record. For an editor command, it might be leaving the document unchanged after validation fails. These promises are more actionable than a goal to add tests around every line. They tell you what to arrange, what operation to perform, and what result would count as a regression.

A good first question is: what believable mistake could a future edit introduce? Removing a boundary check, reversing a sort order, swallowing an error or changing an ownership contract are concrete possibilities. Write the smallest case that distinguishes the intended behavior from that mistake. You do not need a miniature production deployment to test whether a numeric limit includes its endpoint.

This article uses a small reading-limit function to explain test boundaries and fixtures. Desktop Cargo commands are labeled separately. Crabrix can run the standalone assertion program, but its bundled environment does not provide a general cargo test harness. Keeping that distinction explicit prevents a testing tutorial from promising an unsupported workflow.

A crab inspects normal, empty, cracked and boundary-marked sample tiles on a workbench.
A useful fixture set includes ordinary cases and the inputs most likely to reveal a broken rule.

Separate the rule from its surroundings

Imagine a settings screen that accepts a maximum number of readings to include in a report. The supported interval is one through sixty, inclusive. The text field, persistence layer and screen animation are real parts of the application, but none is required to decide whether 60 is a valid limit. Put that decision in a small function with explicit input and output. The interface can call it and display the error without owning the validation rule itself.

This separation creates a cheap place to ask precise questions. Does zero fail? Does sixty succeed? Does a value above sixty fail? Does malformed text produce a different error from an out-of-range integer? A function returning Result makes those outcomes visible to tests and callers. Avoid putting every distinction into a boolean when the interface needs to explain why the input was rejected.

A pure function is not mandatory for all testing, but it is a useful starting point. When a test needs a running database simply to exercise arithmetic or string validation, inspect whether unrelated responsibilities have become tangled. Extracting the rule can improve the design even before you write the first assertion.

Make the smallest example executable

The following complete program accepts surrounding whitespace, rejects non-numeric input and enforces the inclusive interval. It uses an unsigned integer, so negative text is classified as a numeric-format error. That is a specific contract. An application that wants a separate “negative values are not allowed” message could parse a signed integer first and validate the domain afterward. Tests should describe the contract you actually intend, not whichever behavior happened to emerge from a type choice.

The assertions cover both endpoints and representative failures. They run inside main, so the program is convenient for experimenting in Crabrix. On a desktop project, the same assertions can move into individual #[test] functions. Keep the input and expected value visible together; a reader should not need to inspect a large helper framework to understand why a case exists.

Try deliberately changing 1..=60 to 1..60. The assertion for sixty should fail. This small mutation demonstrates that the fixture detects a plausible boundary mistake. You have stronger evidence than a green test run whose assertions would also pass after the rule was accidentally removed.

#[derive(Debug, PartialEq)]
enum LimitError { NotANumber, OutOfRange }
fn parse_limit(input: &str) -> Result<u16, LimitError> {
    let value = input.trim().parse::<u16>()
        .map_err(|_| LimitError::NotANumber)?;
    if !(1..=60).contains(&value) { return Err(LimitError::OutOfRange); }
    Ok(value)
}
fn main() {
    assert_eq!(parse_limit(" 12 "), Ok(12));
    assert_eq!(parse_limit("1"), Ok(1));
    assert_eq!(parse_limit("60"), Ok(60));
    assert_eq!(parse_limit("0"), Err(LimitError::OutOfRange));
    assert_eq!(parse_limit("61"), Err(LimitError::OutOfRange));
    assert_eq!(parse_limit(""), Err(LimitError::NotANumber));
    assert_eq!(parse_limit("many"), Err(LimitError::NotANumber));
    println!("Seven limit checks passed");
}

Choose what boundary each test observes

A unit test can focus on one rule close to its implementation. In Rust, a test module in the same source module can access private items through the normal module relationship. That is useful for narrowly targeted checks, but it does not mean every private helper deserves a permanent test. Prefer assertions that describe a meaningful behavior rather than the exact decomposition used to implement it.

An integration test in a desktop Cargo package is an external consumer of the library’s public API. Files under tests are compiled as separate crates. This boundary catches problems that an internal test may miss, such as an item that is not publicly available or a public operation whose pieces do not work together. It does not require launching every service the real product uses. Scope still follows the promise you want to verify.

A doctest makes a documented usage example executable. It checks that the example remains compatible with the library and, when assertions are present, that it produces the stated result. These boundaries complement one another. A stack of internal tests does not automatically validate the public example that new users will copy.

Three testing boundaries: a unit rule, an external integration test calling a library public API, and an executable documentation example.
Choose the observation boundary, then cover normal, empty, malformed and boundary inputs within that contract.

Sources: The Rust Book: test organization

Give boundary cases names that explain the rule

For an inclusive interval, test the minimum, maximum and values immediately outside it. For a parser, test the smallest valid input and the smallest malformed input that could be confused with it. For a collection operation, test empty, single-item and repeated-item cases when they affect behavior. Boundary testing is not a ritual of adding random extreme numbers. It is a way to examine where the contract changes from one outcome to another.

Names such as accepts_maximum_limit or rejects_missing_separator help a failure explain itself. A name such as test_case_7 forces the reader to inspect the fixture before learning which promise broke. If many cases differ only in data, a table-driven loop can keep them compact, but include the input in the assertion message so a failure identifies the relevant row.

Do not assume all large numbers exercise the same path. A value outside the supported domain may still fit the numeric type, while another value may overflow during parsing. Decide whether those failures should be distinct. The test set should reflect the user-facing distinction instead of accidentally locking in an unhelpful error message.

Make fixtures small enough to understand

A fixture is the input environment a test needs. For a text parser, a short string is often better than a copied production file containing hundreds of unrelated fields. For a directory importer, a temporary directory with two carefully chosen files can be clearer than a whole repository snapshot. Keep enough structure to trigger the behavior and remove details that do not affect the assertion.

A regression fixture should preserve the property that caused the original defect. If an imported name failed because it contained a combining mark, do not simplify it to ASCII while reducing the case. If order mattered, keep the relevant order. Record a short explanation beside the fixture so future cleanup does not erase the reason it exists. Small is useful only when the failure remains represented.

Avoid putting private user data into fixtures. Reproduce the necessary structure with synthetic names and values. This also improves maintenance: a deliberately constructed sample can communicate its purpose, while a realistic-looking dump often contains accidental details that later readers mistake for requirements.

Control time, randomness and shared state

A test that sometimes fails can be worse than an obvious failure because people learn to rerun it until it passes. Common causes include reading the current clock, relying on random data without recording a seed, sharing a mutable global variable, or using a fixed filesystem path that another test also writes. Identify these dependencies instead of increasing retry counts and treating the symptoms as normal.

Pass time-dependent values into the rule being tested when possible. Use a fixed seed for a deterministic scenario, and print or retain a generated seed when exploring broader random cases. Give filesystem tests isolated temporary locations and clean up only their own files. A test should not depend on the order in which other tests happen to run.

There are legitimate integration checks against real services, but label their environment requirements and keep them distinct from ordinary local rule tests. The question “does our parser reject this malformed field?” should have the same answer on a train without network access as it does in a developer’s office.

Assert outcomes that matter to callers

An assertion about a private intermediate vector can make a harmless refactor look like a regression. An assertion about returned records, a preserved document or a structured error protects something a caller can rely on. This does not prohibit inspecting internal state when an invariant genuinely matters. It asks you to explain why that state is part of the behavior you intend to preserve.

For an error result, assert the stable distinction the caller uses. If an interface branches on an error enum, comparing variants is usually more robust than matching the entire English message. Separately test a message when its wording is itself a product requirement. Avoid broad assertions such as “the error string is nonempty” when the bug is specifically about reporting the wrong field.

For operations that can fail after partial work, check the aftermath. Did the original input remain unchanged? Was a temporary output discarded? Was the stored version left intact? A test that only sees Err can miss destructive side effects that occurred before the error was returned.

Let documentation examples prove their claims

A useful documentation example shows a realistic public call and an observable result. Rustdoc can run ordinary Rust code blocks as documentation tests in a desktop library project. Include the imports and setup a reader needs, or hide purely supporting lines using rustdoc’s documented conventions. The visible example should remain understandable on its own. A code block that only compiles may fail to demonstrate the behavior its surrounding prose promises.

Use an assertion when a result is predictable. If you document a parser returning a specific field, check that field. When explaining an API misuse that must not compile, a compile_fail example can express the expectation, but be careful: a failure for an unrelated typo is not evidence that the intended restriction was tested. Keep such examples small and review the actual compiler diagnostic.

Documentation tests are particularly valuable after public API changes. Internal unit tests can keep passing while the README example stops compiling because an import or return type changed. Executable examples connect the user’s first interaction with the library to the same verification discipline used for implementation code.

Sources: Rustdoc: documentation tests

Use desktop Cargo commands with clear scope

In a desktop Cargo project, cargo test runs the package’s relevant test targets under Cargo’s normal selection rules. cargo test --lib focuses on library unit tests, while cargo test --doc focuses on documentation tests. An integration test file can be selected with --test and its target name. These commands are useful for narrowing a failure, but a narrow successful run does not establish that every other target still works.

The shell block below assumes a local Cargo package with the usual library layout. It is not a command sequence for Crabrix’s on-device project terminal. To explore the validation logic inside Crabrix, run the complete main program earlier in this article. When you move a project to a desktop environment, keep those behavioral assertions and organize them into the test targets that match its public boundaries.

Run the smallest relevant target while developing, then the appropriate broader package checks before sharing the change. If a feature changes conditional compilation, include the feature configurations you actually support. Testing one convenient default cannot establish that every optional path compiles.

# Desktop Cargo package, not the Crabrix on-device terminal
cargo test --lib
cargo test --doc
cargo test

Make one public example a documentation test

For a concrete desktop experiment, create a library package named limit_rules with cargo new --lib limit_rules. Replace its src/lib.rs with the code below. The public function has a documentation example that imports it as an external caller would. The assertion checks the maximum accepted value. An internal unit test checks a rejected value through the same public function, giving the two checks different responsibilities.

Run cargo test inside that package on your desktop. Cargo runs the unit test and rustdoc runs the documentation example. Try changing the function's public name without updating the example: the doctest should expose the stale import. Try changing the upper endpoint: the example should expose the changed behavior. These are useful failures because they correspond to promises made to a caller reading the documentation, rather than details of a private helper.

/// Accept a reading limit from 1 through 60, inclusive.
///
/// ```
/// use limit_rules::accepts;
/// assert!(accepts(60));
/// ```
pub fn accepts(value: u16) -> bool { (1..=60).contains(&value) }

#[cfg(test)]
mod tests {
    use super::accepts;
    #[test]
    fn rejects_zero() { assert!(!accepts(0)); }
}

Keep a regression suite that earns its maintenance

After fixing a defect, write a test that fails on the defective implementation and passes on the repaired one. Whenever practical, demonstrate that difference before finalizing the fix. This avoids a common trap: adding a test near the changed code that never actually exercises the reported failure. The fixture should be traceable to a behavior, not merely to the file you edited.

Remove duplication when tests repeat the same promise through the same path. Preserve distinct cases when they guard different boundaries, even if the assertions look similar. A compact suite with clear failures is easier to trust than a large suite whose helper layers and shared setup make every failure ambiguous. Maintenance quality affects whether people investigate failures or learn to ignore them.

For your next small Rust function, write one ordinary case, one empty or missing case, one malformed case and one precise boundary case where those categories apply. Explain each expected result before running the program. That habit turns testing from a final checkbox into a practical method for discovering what the function should mean.

Explore the free Crabrix lessons →