Rust / Intermediate / Lesson 12
Testing, debugging, and repeatable builds
Turn contracts into boundary tests, reproduce failures with exact build information, and choose diagnostic tools that answer a specific question.
On this page
Lesson goals
- Distinguish unit, integration, and documentation tests.
- Check success, failure categories, and state preservation.
- Separate ordinary debug tools from optional nightly tooling.
Test behavior without copying the implementation
A meaningful test supplies inputs and checks the public contract rather than repeating the same algorithm as the function. Cover empty work, exact boundaries, rejected requests, and relevant state afterward. Rust assert macros remain active in ordinary release builds, while debug_assert can be disabled by build configuration. The first complete program uses assertions as a small executable verification harness. A production package normally places named tests in a test module or integration-test file and runs them with cargo test. A success message should appear only after every required check succeeds.
Keep arithmetic safe at its own limits
The batch function rounds a nonnegative count upward using quotient and remainder. It avoids the common count + width - 1 expression, which can overflow before division. Zero width returns None before any division occurs. The maximum accepted u32 count remains safe because width one gives no remainder, and larger widths leave ample room for the extra unit. The four assertions distinguish zero work, an exact multiple, a remainder, and invalid width. They remain useful even if a later implementation changes its internal arithmetic.
fn batches(count: u32, width: u32) -> Option<u32> {
if width == 0 { return None; }
Some(count / width + u32::from(count % width != 0))
}
fn main() {
assert_eq!(batches(0, 4), Some(0));
assert_eq!(batches(8, 4), Some(2));
assert_eq!(batches(9, 4), Some(3));
assert_eq!(batches(2, 0), None);
println!("4 cases passed");
}
4 cases passed
The line prints only after all assertions pass. None expresses invalid width rather than a divide-by-zero panic.
Choose the layer that can observe the obligation
Unit tests can live beside implementation and access private items through an appropriate child test module. Integration tests under tests act as separate crates and exercise the public library interface. Documentation tests check examples in public documentation. Use each layer for the obligation it can observe. The second executable checks a binary-search membership wrapper against a present value, an absent interior value, and an empty slice. Searching only for an insertion position is not the same as proving equality; these cases protect that distinction. Inputs must satisfy the sorted-slice precondition.
fn contains(sorted: &[u32], target: u32) -> bool {
sorted.binary_search(&target).is_ok()
}
fn main() {
let values = [2, 5, 8];
assert!(contains(&values, 5));
assert!(!contains(&values, 6));
assert!(!contains(&[], 1));
println!("3 searches passed");
}
3 searches passed
The wrapper reports membership from binary_search success, not merely a nonempty candidate position.
Preserve a minimal reproducer
Record the failing input, source revision, rustc version, edition, target, features, and complete command. Reduce the case without removing the failure. rustc --explain can clarify an error code, while compiler spans show where a move or borrow originates and is later used. Runtime debugging may use debug information, backtraces, and a platform debugger. Logging should show domain state without leaking secrets. Tests that mutate environment variables, shared files, or global state can interfere when run concurrently; isolate resources rather than assuming the default test scheduler serializes all cases.
Tools supply evidence with boundaries
cargo fmt and Clippy support consistency and review when the relevant components are installed; warnings are evidence, not a complete correctness proof. Miri can detect many undefined-behavior issues in executed Rust paths, but requires a supported nightly setup and does not model every external interaction. Sanitizer support also depends on target and toolchain configuration. This course verification uses stable Rust and standard-library-only examples, without silently requiring those optional tools. Once a focused fix passes its relevant checks, preserve the regression test and avoid repeating unrelated broad work without a reason.
Common errors and fixes
A passing test suite misses malformed input.
- Why it happens
- Only ordinary success paths were covered.
- How to fix it
- Add rejection, limit, and state-preservation cases.
- Prevent it next time
- Derive tests from the whole contract.
Parallel tests affect each other.
- Why it happens
- They share mutable external resources or globals.
- How to fix it
- Use isolated temporary resources and explicit synchronization.
- Prevent it next time
- Document unavoidable shared test state.
A nightly diagnostic command fails on stable.
- Why it happens
- Optional tooling requirements were not stated.
- How to fix it
- Use the supported configuration or a stable-compatible check.
- Prevent it next time
- Separate baseline checks from optional diagnostics.
Check your understanding
1. Add cases for batches(1, 4) and batches(u32::MAX, 1). Explain what distinct mistakes they detect.
Show worked solution
The expected results are Some(1) and Some(u32::MAX). The first detects an implementation using floor division alone. The maximum case detects an overflow-prone rounding expression that adds width or another unnecessary offset. Both cases check the external contract without requiring the implementation to use quotient and remainder specifically.
2. A bug reproduces only with one Cargo feature combination. Specify the evidence and regression placement needed to preserve the repair.
Show worked solution
Record the exact feature flags, default-feature setting, target, edition, and compiler version alongside the minimal input. Add a test compiled under that supported combination and include the command in the project checks. If the defect crosses a public boundary, an integration test can catch accidental reliance on internal configuration. A default-only local test run is insufficient evidence.
Key takeaways
- Test boundaries, rejection, and resulting state.
- Keep failures reproducible across build configurations.
- Distinguish stable baseline tools from optional diagnostics.