Skip to content

14. Tests, docs, clippy and fmt

Full example: l14-testing/ — a small pricing library; run cargo test from that folder.

In .NET you pick xUnit, NUnit or MSTest, add analyzers from NuGet and configure dotnet format. In Java you add JUnit, Checkstyle or Error Prone and a formatter plugin to Maven or Gradle. Rust ships one standard answer for each, installed with the toolchain:

Job Rust C# Java
Run tests cargo test (test framework built in) dotnet test + xUnit/NUnit/MSTest mvn test + JUnit
API docs cargo doc (rustdoc) XML doc comments + DocFX Javadoc
Lints cargo clippy Roslyn analyzers Error Prone, SpotBugs
Formatting cargo fmt (rustfmt) dotnet format Spotless, google-java-format
Lint configuration [lints] in Cargo.toml .editorconfig, <WarningsAsErrors> plugin configuration

Because there is one of each, every Rust project looks the same to a newcomer: cargo test, cargo clippy, cargo fmt work everywhere.

Unit tests live in the same file as the code, in a child module compiled only for tests:

// Unit tests: a child module, so it can reach private items
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_discount_rounds_down() {
let cart = cart_with(&[("book", 1, 999)]);
assert_eq!(cart.total_with(Discount::Percent(10)), 899);
}
#[test]
fn zero_quantity_is_rejected() {
let mut cart = Cart::new();
assert_eq!(cart.add("pen", 0, 150), Err(PricingError::ZeroQuantity));
assert!(cart.lines.is_empty(), "a rejected line must not be stored"); // private field
}
#[test]
#[should_panic(expected = "discount above 100%")]
fn percent_above_100_panics() {
Cart::new().total_with(Discount::Percent(101));
}
// A test can return Result and use `?`
#[test]
fn free_item_error_message() -> Result<(), String> {
let err = Cart::new().add("gift", 1, 0).err().ok_or("expected an error")?;
assert_eq!(err.to_string(), "`gift` has no price");
Ok(())
}
#[test]
#[ignore = "slow: run with cargo test -- --ignored"]
fn many_lines() { /* … */ }
}
Rust xUnit JUnit 5
#[test] [Fact] @Test
assert_eq!(actual, expected) Assert.Equal(expected, actual) assertEquals(expected, actual)
assert!(cond, "message {x}") Assert.True(cond, "message") assertTrue(cond, "message")
#[should_panic(expected = "…")] Assert.Throws<T> assertThrows
#[ignore = "reason"] [Fact(Skip = "reason")] @Disabled("reason")
a helper function in mod tests a private helper or fixture @BeforeEach / helper

Rust has no attribute for parameterised tests or fixtures in the standard library; a loop over a table of cases, or a helper like cart_with, is the usual answer (crates such as rstest add them).

cargo test runs three kinds of tests and reports each:

Running unittests src\lib.rs (target\debug\deps\pricing-2041fb093a55ec20.exe)
running 7 tests
test tests::many_lines ... ignored, slow: run with cargo test -- --ignored
test tests::empty_cart_totals_zero ... ok
test tests::fixed_discount_never_goes_negative ... ok
test tests::free_item_error_message ... ok
test tests::percent_above_100_panics - should panic ... ok
test tests::percent_discount_rounds_down ... ok
test tests::zero_quantity_is_rejected ... ok
test result: ok. 6 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests\checkout.rs (target\debug\deps\checkout-8074f42be2f51ecc.exe)
running 1 test
test checkout_with_discount ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests pricing
running 2 tests
test src\lib.rs - Cart::total_with (line 90) - should panic ... ok
test src\lib.rs - Cart::add (line 59) ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s

A failing assert_eq! shows both sides:

test tests::percent_discount_rounds_down ... FAILED
failures:
---- tests::percent_discount_rounds_down stdout ----
thread 'tests::percent_discount_rounds_down' (409064) panicked at src\lib.rs:128:9:
assertion `left == right` failed
left: 899
right: 900

Useful options:

Command Does
cargo test discount runs only tests whose name contains discount
cargo test -- --ignored runs only the ignored tests
cargo test -- --nocapture shows println! output of passing tests (captured by default)
cargo test -- --test-threads=1 runs tests one at a time — they run in parallel by default

Parallel execution means tests must not share mutable global state, such as a file path or an environment variable, without coordinating.

Files in a tests/ folder next to src/ are integration tests. Each file is compiled as a separate crate that depends on your library, so it can only use the public API — like a separate Pricing.Tests project that references the assembly without InternalsVisibleTo:

tests/checkout.rs
//! Checkout scenarios, exercised through the public API only.
use pricing::{Cart, Discount, PricingError};
#[test]
fn checkout_with_discount() -> Result<(), PricingError> {
let mut cart = Cart::new();
cart.add("book", 2, 2_000)?;
cart.add("pen", 3, 150)?;
assert_eq!(cart.total_cents(), 4_450);
assert_eq!(cart.total_with(Discount::Percent(20)), 3_560);
Ok(())
}

No #[cfg(test)] is needed there: the whole folder only exists for tests. Integration tests can only use a library crate — there is no way to import functions from src/main.rs. That is one reason why binaries keep most of their logic in src/lib.rs, with a thin src/main.rs.

/// documents the next item, //! documents the enclosing module or crate. The content is Markdown:

/// Adds `quantity` items at `unit_cents` each.
///
/// # Errors
///
/// Returns [`PricingError::ZeroQuantity`] if `quantity` is 0 and
/// [`PricingError::FreeItem`] if `unit_cents` is 0.
///
/// # Examples
///
/// ```
/// use pricing::Cart;
///
/// let mut cart = Cart::new();
/// cart.add("pen", 2, 150)?;
/// assert_eq!(cart.total_cents(), 300);
/// # Ok::<(), pricing::PricingError>(())
/// ```
pub fn add(&mut self, name: &str, quantity: u32, unit_cents: u64) -> Result<(), PricingError> {
  • # Examples, # Errors, # Panics and # Safety are the conventional section names, like <example>, <exception> in C# or @throws in Javadoc.
  • [`PricingError::ZeroQuantity`] is an intra-doc link, resolved by the compiler like <see cref="…"/>.
  • Every code block is a doc test: cargo test compiles and runs it, so the examples in the documentation cannot rot. A line starting with # is compiled but hidden in the rendered page — here, the Ok(()) that lets the example use ?.
  • Code blocks accept attributes: should_panic, no_run (compile only), ignore, and compile_fail — the attribute this course uses for every “this does not compile” snippet.

cargo doc --open builds the HTML documentation for your crate and all its dependencies. With RUSTDOCFLAGS="-D warnings", broken links fail the build:

error: unresolved link to `Cart::most_expensive`
--> src\lib.rs:172:44
|
172 | /// Name of the most expensive line, see [`Cart::most_expensive`].
| ^^^^^^^^^^^^^^^^^^^^ the struct `Cart` has no field or associated item named `most_expensive`
|
= note: `-D rustdoc::broken-intra-doc-links` implied by `-D warnings`

cargo clippy runs several hundred lints on top of the compiler’s own warnings. Given this function:

pub fn describe(cart: &Cart) -> String {
if cart.lines.len() == 0 {
return String::from("empty");
}
let first = cart.lines.first().unwrap();
return first.0.clone();
}
warning: unneeded `return` statement
--> src\lib.rs:178:5
|
178 | return first.0.clone();
| ^^^^^^^^^^^^^^^^^^^^^^
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#needless_return
= note: `#[warn(clippy::needless_return)]` on by default
help: remove `return`
|
178 - return first.0.clone();
178 + first.0.clone()
|
warning: length comparison to zero
--> src\lib.rs:174:8
|
174 | if cart.lines.len() == 0 {
| ^^^^^^^^^^^^^^^^^^^^^ help: using `is_empty` is clearer and more explicit: `cart.lines.is_empty()`
warning: used `unwrap()` on an `Option` value
--> src\lib.rs:177:17
|
177 | let first = cart.lines.first().unwrap();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: if this value is `None`, it will panic
= help: consider using `expect()` to provide a better panic message

Many suggestions can be applied automatically with cargo clippy --fix. Lints are organised in groups: the default set (correctness, suspicious, style, complexity, perf), plus opt-in pedantic, nursery and restriction groups. unwrap_used above belongs to restriction, so it only fires because the package enables it.

Lint levels for a whole package go in Cargo.toml — the equivalent of severities in .editorconfig:

# Lint levels for the whole package, instead of #![deny] attributes in every file
[lints.rust]
missing_docs = "warn"
[lints.clippy]
unwrap_used = "warn"

In code, #[allow(lint)] silences a lint for one item. Prefer #[expect(lint, reason = "…")]: it silences the lint too, but warns when the lint no longer fires, so stale suppressions don’t pile up (like an unnecessary #pragma warning disable):

warning: this lint expectation is unfulfilled
--> src\lib.rs:173:10
|
173 | #[expect(clippy::len_zero, reason = "comparing with 0 reads better here")]
| ^^^^^^^^^^^^^^^^
|
= note: comparing with 0 reads better here
= note: `#[warn(unfulfilled_lint_expectations)]` on by default

Some lints also read options from a clippy.toml file (exercise 3).

cargo fmt formats the whole package; cargo fmt --check only reports differences and fails, which is what CI runs:

Diff in \\?\C:\…\p14\src\lib.rs:178:
return first.0.clone();
}
-pub fn line_count(cart: &Cart) -> usize { cart.lines.len() }
+pub fn line_count(cart: &Cart) -> usize {
+ cart.lines.len()
+}

There is one community style and almost nothing to configure (a rustfmt.toml can change a few options such as max_width). Formatting debates disappear from code review.

The course’s own workflow, .github/workflows/rust-examples.yml, is a typical Rust pipeline (excerpt):

- name: Formatting
run: cargo fmt --check
- name: Compile-fail doctests
run: cargo test --doc
- name: Clippy
run: cargo clippy --examples -- -D warnings
- name: Lesson 14 crate
working-directory: code/rust-for-csharp-java/l14-testing
env:
RUSTDOCFLAGS: -D warnings
run: |
cargo clippy --all-targets -- -D warnings
cargo test
cargo test -- --ignored
cargo doc --no-deps

-D warnings turns every warning into an error in CI only, so local builds stay pleasant while nothing lands with warnings.

Beyond the standard tools, these crates fill the remaining gaps: criterion for benchmarks (BenchmarkDotNet, JMH), proptest for property-based testing (FsCheck, jqwik), mockall for mocks (Moq, Mockito), and cargo-llvm-cov for coverage (Coverlet, JaCoCo).

  • cargo test, cargo doc, cargo clippy and cargo fmt come with the toolchain; there is nothing to choose.
  • Unit tests sit next to the code in #[cfg(test)] mod tests and can test private items; integration tests in tests/ see only the public API.
  • Every code example in /// docs is compiled and run, so documentation stays correct.
  • Configure lints in [lints]; use #[expect(…, reason = …)] rather than #[allow].
  • In CI: cargo fmt --check, cargo clippy -- -D warnings, cargo test, RUSTDOCFLAGS="-D warnings" cargo doc.
  1. Write fn parse_percent(text: &str) -> Result<u8, String> that accepts "15%" (surrounding spaces allowed), and rejects a missing %, non-numbers and values above 100. Write unit tests for valid input, each kind of error, and "300%" (which does not fit in a u8), including one test that returns Result.
Solution
pub fn parse_percent(text: &str) -> Result<u8, String> {
let digits = text
.trim()
.strip_suffix('%')
.ok_or_else(|| format!("`{text}` does not end with %"))?;
let value: u8 = digits
.parse()
.map_err(|e| format!("`{digits}` is not a number: {e}"))?;
if value > 100 {
return Err(format!("{value}% is above 100%"));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_percentages() {
assert_eq!(parse_percent("0%"), Ok(0));
assert_eq!(parse_percent(" 15% "), Ok(15));
assert_eq!(parse_percent("100%"), Ok(100));
}
#[test]
fn rejects_values_above_100() {
assert_eq!(parse_percent("150%"), Err("150% is above 100%".to_string()));
}
#[test]
fn rejects_missing_sign_and_garbage() {
assert!(parse_percent("15").is_err());
let err = parse_percent("abc%").unwrap_err();
assert!(err.contains("not a number"), "unexpected message: {err}");
}
#[test]
fn values_that_overflow_u8() -> Result<(), String> {
let err = parse_percent("300%").err().ok_or("300% should fail")?;
assert!(err.contains("not a number"), "unexpected message: {err}");
Ok(())
}
}

"300%" fails in parse::<u8>() before the > 100 check is reached, so its message is the “not a number” one — a test is the cheapest way to find that out.

  1. Document parse_percent with # Errors and # Examples sections. The example must use ? rather than unwrap(). What happens to cargo test if the example asserts parse_percent("15%")? == 16?
Solution
/// Parses a percentage such as `"15%"`.
///
/// # Errors
///
/// Returns an error if the text does not end with `%`, is not a number,
/// or is above 100.
///
/// # Examples
///
/// ```
/// use ex14::parse_percent;
///
/// assert_eq!(parse_percent("15%")?, 15);
/// assert!(parse_percent("150%").is_err());
/// # Ok::<(), String>(())
/// ```
pub fn parse_percent(text: &str) -> Result<u8, String> {

The hidden # Ok::<(), String>(()) line makes the example’s implicit main return Result<(), String>, which is what allows ?. If the example asserted 16, the doc test would panic and cargo test would fail under Doc-tests, pointing at the line in the doc comment: wrong documentation breaks the build.

  1. Make clippy::unwrap_used an error for the library, while still allowing unwrap() in unit tests. Check it with a function that calls unwrap().
Solution
Cargo.toml
[lints.clippy]
unwrap_used = "deny"
# clippy.toml, next to Cargo.toml
allow-unwrap-in-tests = true

cargo clippy --all-targets now reports only the library code:

error: used `unwrap()` on a `Result` value
--> src\lib.rs:35:5
|
35 | parse_percent(items[0]).unwrap()
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: if this value is an `Err`, it will panic
= help: consider using `expect()` to provide a better panic message

Without clippy.toml, the unwrap_err() in rejects_missing_sign_and_garbage is reported as well (used unwrap_err() on a Result value).