# Testing & tooling

> Write unit, integration and doc tests with cargo test, test errors and panics, and keep code clean with clippy, rustfmt and CI.

Rust roadmap · Core language · 16 min read · https://levelcode.org/roadmaps/rust/testing-rust

Testing is built into Rust: no framework to choose, no runner to configure. `cargo test` finds and runs unit tests next to your code, integration tests in their own folder, and code examples in your documentation. Add clippy and rustfmt and you have a full quality toolchain from day one. This lesson covers the three kinds of tests, assertions and test organization, testing errors and panics, test doubles with traits, and a CI setup that catches problems before review.

## Unit tests

Unit tests live in the same file as the code, in a module marked `#[cfg(test)]`, which is compiled only when testing:

```rust
pub fn slugify(title: &str) -> String {
    title
        .split_whitespace()
        .map(|w| w.to_lowercase())
        .collect::<Vec<_>>()
        .join("-")
}

#[cfg(test)]
mod tests {
    use super::*; // bring the parent module's items into scope

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

    #[test]
    fn collapses_repeated_whitespace() {
        assert_eq!(slugify("  a   b "), "a-b");
    }

    #[test]
    fn empty_input_gives_empty_slug() {
        assert!(slugify("").is_empty());
    }
}
```

```text
$ cargo test
running 3 tests
test tests::collapses_repeated_whitespace ... ok
test tests::empty_input_gives_empty_slug ... ok
test tests::lowercases_and_joins_words ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```

Because the test module is a child of the module under test, it can call **private** functions. That's deliberate: unit tests check the pieces, including the internal ones.

A test passes if it returns without panicking. The assertion macros:

| Macro | Checks |
| --- | --- |
| `assert!(cond)` | `cond` is true |
| `assert_eq!(left, right)` | Equality; prints both values on failure (needs `Debug` and `PartialEq`) |
| `assert_ne!(left, right)` | Inequality |
| `matches!(value, pattern)` | Returns `bool`; use inside `assert!` to check enum variants |

All of them take an optional format message: `assert_eq!(got, 3, "after inserting {item}")`.

Running tests selectively:

```bash
cargo test slugify            # only tests whose name contains "slugify"
cargo test -- --nocapture     # show println! output from passing tests
cargo test -- --test-threads=1 # run serially (tests run in parallel by default)
cargo test --doc              # only documentation tests
```

> [!NOTE]
> Tests run in parallel threads within one process. Tests that touch shared state — environment variables, a fixed file path, a shared database table — need isolation: a unique temp directory per test, a fresh schema, or `--test-threads=1` as a last resort.

## Testing errors and panics

Tests can return `Result`, which lets you use `?` instead of unwrapping each step:

```rust
use std::num::ParseIntError;

fn parse_pair(s: &str) -> Result<(i32, i32), ParseIntError> {
    let (a, b) = s.split_once(',').unwrap_or((s, "0"));
    Ok((a.trim().parse()?, b.trim().parse()?))
}

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

    #[test]
    fn parses_two_numbers() -> Result<(), ParseIntError> {
        assert_eq!(parse_pair("3, 4")?, (3, 4));
        Ok(())
    }

    #[test]
    fn rejects_non_numbers() {
        let err = parse_pair("3, x").unwrap_err();
        assert_eq!(err.to_string(), "invalid digit found in string");
    }
}
```

For your own error enums, assert on the variant rather than the message, so rewording a message doesn't break tests:

```rust
// assert!(matches!(result, Err(ConfigError::OutOfRange(70000))));
```

When a function is *supposed* to panic (a violated precondition), mark the test `#[should_panic]`, ideally with the expected message:

```rust
pub struct Percent(u8);

impl Percent {
    pub fn new(v: u8) -> Self {
        assert!(v <= 100, "percent must be <= 100, got {v}");
        Percent(v)
    }
}

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

    #[test]
    #[should_panic(expected = "must be <= 100")]
    fn rejects_over_100() {
        Percent::new(101);
    }
}
```

Mark slow tests `#[ignore]` and run them explicitly with `cargo test -- --ignored`.

## Integration tests

Integration tests live in a top-level `tests/` folder. Each file there is compiled as a **separate crate** that uses your library like any external user would, so only `pub` items are visible:

```text
my_lib/
├── src/lib.rs
└── tests/
    ├── api.rs
    └── common/
        └── mod.rs   # shared helpers; a subfolder isn't treated as a test file
```

```rust
// tests/api.rs
mod common;

use my_lib::slugify;

#[test]
fn slugs_are_url_safe() {
    let slug = slugify(&common::sample_title());
    assert!(slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
}
```

Integration tests catch a different class of bug: an API that's awkward to use, an item you forgot to make public, modules that work alone but not together. They need a library crate (`src/lib.rs`); a binary-only package can't be imported, which is one more reason to keep `main.rs` thin.

## Doc tests

Code examples in `///` doc comments are compiled and run as tests:

```rust
/// Converts a title into a URL slug.
///
/// ```
/// use my_lib::slugify;
/// assert_eq!(slugify("Hello World"), "hello-world");
/// ```
pub fn slugify(title: &str) -> String {
    title.split_whitespace().map(str::to_lowercase).collect::<Vec<_>>().join("-")
}
```

Your documentation can't drift out of date, because a broken example fails `cargo test`. `cargo doc --open` renders the docs, with the examples, as HTML. Inside a doc example, a line starting with `# ` still runs but is hidden from the rendered docs, which is handy for setup code the reader doesn't need to see.

## Test doubles with traits

To test code that talks to a database, clock or HTTP API, depend on a **trait** and pass a fake in tests. This is plain dependency inversion; Rust needs no mocking framework for it:

```rust
pub trait Clock {
    fn now_secs(&self) -> u64;
}

pub struct SystemClock;

impl Clock for SystemClock {
    fn now_secs(&self) -> u64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("clock is after 1970")
            .as_secs()
    }
}

pub struct Session {
    pub expires_at: u64,
}

pub fn is_expired(session: &Session, clock: &impl Clock) -> bool {
    clock.now_secs() >= session.expires_at
}

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

    struct FixedClock(u64);

    impl Clock for FixedClock {
        fn now_secs(&self) -> u64 {
            self.0
        }
    }

    #[test]
    fn session_expires_at_exact_second() {
        let s = Session { expires_at: 1_000 };
        assert!(!is_expired(&s, &FixedClock(999)));
        assert!(is_expired(&s, &FixedClock(1_000)));
    }
}
```

Generic parameters mean production code pays nothing for this seam: `is_expired` is monomorphized for `SystemClock`. When you need call recording or expectations, the `mockall` crate generates mocks from traits, but hand-written fakes like `FixedClock` are usually clearer. The [test doubles](https://levelcode.org/concepts/test-doubles) concept explains stubs, fakes and mocks in general.

> [!TIP]
> Two crates worth knowing: `proptest` for property-based testing (generate thousands of random inputs and check an invariant holds, shrinking failures to a minimal case), and `insta` for snapshot testing of large outputs such as rendered text or JSON.

## Clippy, rustfmt and CI

**rustfmt** formats code to the community standard. Run `cargo fmt` and stop debating style; `cargo fmt --check` fails if anything's unformatted.

**Clippy** adds over 700 lints on top of the compiler's warnings: needless clones, inefficient patterns, likely bugs, unidiomatic code.

```text
$ cargo clippy
warning: this `if` has identical blocks
warning: you should consider adding a `Default` implementation for `Store`
warning: called `.iter().count()` on a `Vec`; use `.len()` instead
```

Configure lints per crate in `Cargo.toml`:

```toml
[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
unwrap_used = "warn"
```

A CI job that runs the full set takes a few lines:

```yaml
name: ci
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2
      - run: cargo fmt --check
      - run: cargo clippy --all-targets --all-features -- -D warnings
      - run: cargo test --all-features
```

`-D warnings` turns warnings into errors in CI only, so local development isn't blocked but nothing unclean gets merged. `rust-cache` caches `target/` and the registry between runs, which matters because cold Rust builds are slow.

## Practice

Write a `Stack<T>` and test it from all three angles.

1. In `src/lib.rs`, implement `pub struct Stack<T>` with `new`, `push`, `pop -> Option<T>`, `peek -> Option<&T>`, `len` and `is_empty`, backed by a `Vec<T>`.
2. Add unit tests covering: a new stack is empty; push then pop returns items in reverse order; `pop` on an empty stack returns `None`; `peek` doesn't remove.
3. Add a doc test on `push` showing typical use.
4. Add an integration test in `tests/stack.rs` that pushes 10,000 items and pops them all.
5. Run `cargo clippy` and fix whatever it reports (hint: it may ask for a `Default` impl).

**Solution**

```rust
// src/lib.rs
#[derive(Debug, Default)]
pub struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    pub fn new() -> Self {
        Self { items: Vec::new() }
    }

    /// Pushes an item onto the top of the stack.
    ///
    /// ```
    /// let mut s = stack::Stack::new();
    /// s.push(1);
    /// s.push(2);
    /// assert_eq!(s.peek(), Some(&2));
    /// ```
    pub fn push(&mut self, item: T) {
        self.items.push(item);
    }

    pub fn pop(&mut self) -> Option<T> {
        self.items.pop()
    }

    pub fn peek(&self) -> Option<&T> {
        self.items.last()
    }

    pub fn len(&self) -> usize {
        self.items.len()
    }

    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }
}

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

    #[test]
    fn new_stack_is_empty() {
        let s: Stack<i32> = Stack::new();
        assert!(s.is_empty());
        assert_eq!(s.len(), 0);
    }

    #[test]
    fn pops_in_reverse_order() {
        let mut s = Stack::new();
        s.push("a");
        s.push("b");
        assert_eq!(s.pop(), Some("b"));
        assert_eq!(s.pop(), Some("a"));
    }

    #[test]
    fn pop_on_empty_returns_none() {
        let mut s: Stack<u8> = Stack::new();
        assert_eq!(s.pop(), None);
    }

    #[test]
    fn peek_does_not_remove() {
        let mut s = Stack::new();
        s.push(7);
        assert_eq!(s.peek(), Some(&7));
        assert_eq!(s.len(), 1);
    }
}
```

```rust
// tests/stack.rs
use stack::Stack;

#[test]
fn handles_many_items() {
    let mut s = Stack::new();
    for i in 0..10_000 {
        s.push(i);
    }
    let popped: Vec<i32> = std::iter::from_fn(|| s.pop()).collect();
    assert_eq!(popped.len(), 10_000);
    assert_eq!(popped.first(), Some(&9_999));
    assert!(s.is_empty());
}
```

The package is named `stack`, so the doc test and integration test import it as `stack::Stack`. Clippy's `new_without_default` lint asks for a `Default` implementation whenever there's a no-argument `new`; deriving it satisfies the lint. It also expects `is_empty` whenever a public `len` exists, which is already there.

## Key takeaways

- Unit tests sit next to the code in a `#[cfg(test)] mod tests` and can test private functions.
- Integration tests in `tests/` are separate crates that see only your public API; doc tests keep examples correct.
- Tests can return `Result` and use `?`; assert on error variants with `matches!`, and use `#[should_panic(expected = …)]` for precondition panics.
- Fake dependencies by depending on traits and passing a test implementation; generics keep the seam free in production.
- Run `cargo fmt --check`, `cargo clippy -- -D warnings` and `cargo test` in CI on every push.

## Quiz

1. Why can a unit test in mod tests call a private function from the same file?

   A. #[test] disables privacy checks
   B. tests is a child module, and children can see their ancestors' private items
   C. Private functions are public in debug builds
   D. use super::* makes items public

   **Answer: B.** Privacy in Rust is module-based: items are visible to the module that defines them and its descendants. mod tests is a child, so it can reach private items; use super::* just brings them into scope.

2. Which kind of test checks that your public API is usable from outside the crate?

   A. A unit test with #[cfg(test)]
   B. A #[should_panic] test
   C. A benchmark
   D. An integration test in the tests/ directory

   **Answer: D.** Each file in tests/ is compiled as a separate crate that depends on your library, so it only sees pub items, like any user would.

3. What happens to a code example inside a /// doc comment when you run cargo test?

   A. It's compiled and run as a test
   B. It's ignored unless marked #[test]
   C. It's only checked for syntax
   D. It's rendered to HTML

   **Answer: A.** Doc tests compile and run code blocks in doc comments, so examples fail the build if the API changes under them. cargo doc renders them; cargo test runs them.

4. Why pass -D warnings to cargo clippy in CI?

   A. It makes clippy run faster
   B. It disables lints that are noisy
   C. It turns warnings into errors so lint problems fail the build instead of being merged
   D. It's required for clippy to check tests

   **Answer: C.** -D warnings denies all warnings for that run. Local builds stay permissive while CI refuses to merge code with warnings.
