3 min read
On this page

Unit & Integration Tests

Rust has first-class testing built into the language and build system. No test framework to install, no configuration files to write. Put #[test] above a function, run cargo test, and you are testing. The convention of placing tests next to the code they test — in the same file — means tests stay synchronized with the implementation.

#[test] & #[cfg(test)]

Unit tests live in a #[cfg(test)] module inside the source file:

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

pub fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("division by zero".into())
    } else {
        Ok(a / b)
    }
}

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

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn test_add_negative() {
        assert_eq!(add(-1, 1), 0);
    }

    #[test]
    fn test_divide() {
        let result = divide(10.0, 3.0).unwrap();
        assert!((result - 3.333).abs() < 0.01);
    }

    #[test]
    fn test_divide_by_zero() {
        let result = divide(10.0, 0.0);
        assert!(result.is_err());
    }
}
$ cargo test
running 4 tests
test tests::test_add ... ok
test tests::test_add_negative ... ok
test tests::test_divide ... ok
test tests::test_divide_by_zero ... ok

test result: ok. 4 passed; 0 failed; 0 ignored

#[cfg(test)] ensures the test module is only compiled when running tests. It adds zero overhead to your release binary.

Assert Macros

Rust provides three core assertion macros:

#[cfg(test)]
mod tests {
    #[test]
    fn assert_examples() {
        // Boolean assertion
        assert!(2 + 2 == 4);

        // Equality
        assert_eq!(vec![1, 2, 3].len(), 3);

        // Inequality
        assert_ne!("hello", "world");

        // Custom messages
        let status = 404;
        assert_eq!(status, 200, "Expected 200 OK, got {}", status);
    }
}

Custom messages are invaluable for debugging test failures. Always add them when the assertion is not self-explanatory.

Testing Private Functions

Unlike most languages, Rust lets you test private functions directly. The test module is a child of the module it tests, so it has access to everything:

fn internal_hash(input: &str) -> u64 {
    // Private function — not pub
    input.bytes().fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64))
}

pub fn is_valid_token(token: &str) -> bool {
    internal_hash(token) % 7 == 0
}

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

    #[test]
    fn test_internal_hash() {
        // We CAN test the private function
        let hash = internal_hash("hello");
        assert!(hash > 0);
    }

    #[test]
    fn test_hash_deterministic() {
        assert_eq!(internal_hash("test"), internal_hash("test"));
    }

    #[test]
    fn test_is_valid_token() {
        // Also test the public interface
        let result = is_valid_token("some-token");
        // We just verify it returns without panicking
        assert!(result || !result);
    }
}

Whether you should test private functions is a design question. If a private function has complex logic, test it. If it is a trivial helper, test it through the public API.

#[should_panic] for Expected Failures

When a function should panic under certain conditions, use #[should_panic]:

pub struct NonEmptyVec<T> {
    inner: Vec<T>,
}

impl<T> NonEmptyVec<T> {
    pub fn new(first: T) -> Self {
        NonEmptyVec { inner: vec![first] }
    }

    pub fn first(&self) -> &T {
        &self.inner[0]
    }

    pub fn push(&mut self, item: T) {
        self.inner.push(item);
    }

    pub fn remove(&mut self, index: usize) -> T {
        if self.inner.len() == 1 {
            panic!("cannot remove last element from NonEmptyVec");
        }
        self.inner.remove(index)
    }
}

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

    #[test]
    #[should_panic(expected = "cannot remove last element")]
    fn test_remove_last_panics() {
        let mut v = NonEmptyVec::new(42);
        v.remove(0); // should panic
    }

    #[test]
    fn test_remove_non_last() {
        let mut v = NonEmptyVec::new(1);
        v.push(2);
        let removed = v.remove(0);
        assert_eq!(removed, 1);
        assert_eq!(*v.first(), 2);
    }
}

The expected parameter matches against the panic message. Use it to ensure the right panic fires, not an unrelated one.

Integration Tests

Integration tests live in a tests/ directory at the crate root. Each file is compiled as a separate crate that can only access your crate's public API:

my-crate/
  src/
    lib.rs
  tests/
    api_tests.rs
    parsing_tests.rs
// tests/api_tests.rs
use my_crate::{User, UserStore};

#[test]
fn test_create_and_retrieve_user() {
    let mut store = UserStore::new();
    store.add(User::new(1, "Alice".into()));

    let user = store.get(1).unwrap();
    assert_eq!(user.name, "Alice");
}

#[test]
fn test_missing_user_returns_none() {
    let store = UserStore::new();
    assert!(store.get(999).is_none());
}

Integration tests verify that your public API works correctly as a consumer would use it. They catch issues that unit tests miss: visibility problems, broken re-exports, incorrect public signatures.

Test Organization

A well-organized test suite uses both unit and integration tests:

src/
  lib.rs           # pub mod declarations
  parser.rs        # unit tests at bottom of file
  validator.rs     # unit tests at bottom of file
  store.rs         # unit tests at bottom of file
tests/
  smoke_tests.rs   # high-level integration tests
  parser_edge_cases.rs  # focused integration tests

Shared test utilities go in tests/common/mod.rs. Other integration test files import them with mod common;.

Running Tests

cargo test                          # run all tests
cargo test test_parse               # run tests matching "test_parse"
cargo test --lib                    # unit tests only
cargo test --test api_tests         # specific integration test file
cargo test -- --nocapture           # show println! output
cargo test -- --test-threads=1      # run tests sequentially
cargo test -- --ignored             # run only #[ignore] tests

#[ignore] for Slow Tests

Mark slow or expensive tests with #[ignore] so they do not run by default:

#[test]
#[ignore]
fn test_large_dataset_processing() {
    let data = generate_large_dataset(1_000_000);
    let result = process(data);
    assert!(result.len() > 0);
}

Run ignored tests explicitly with cargo test -- --ignored or cargo test -- --include-ignored to run everything.

Returning Result from Tests

Tests can return Result for cleaner error handling:

#[test]
fn test_parsing() -> Result<(), Box<dyn std::error::Error>> {
    let config = parse_config("port = 8080")?;
    assert_eq!(config.port, 8080);
    Ok(())
}

This avoids .unwrap() chains and produces better error messages on failure.

Common Pitfalls

  • Tests that depend on each other — tests run in parallel by default. If test A writes to a file and test B reads it, they will flake. Each test must set up its own state.
  • Testing implementation instead of behavior — testing that an internal HashMap has exactly 3 entries is fragile. Test the observable behavior: "after adding 3 items, count returns 3."
  • Ignoring #[cfg(test)] — without this attribute, test utilities compile into your production binary. Always wrap the test module.
  • Not testing error paths — the happy path is easy to test. The error paths are where bugs live. Test every Err variant your functions can return.
  • Over-mocking in unit tests — if you mock everything, your test verifies the mock, not the code. Start with real implementations; mock only external dependencies.
  • Flaky tests from timing — tests that depend on sleep or wall-clock time break under load. Use injected clocks or channels for synchronization instead.

Key Takeaways

  • #[test] functions in #[cfg(test)] modules are unit tests. Files in tests/ are integration tests.
  • Rust lets you test private functions from the test module — use this for complex internal logic.
  • #[should_panic] verifies expected panics. Use the expected parameter to match the message.
  • Integration tests can only access your public API, making them ideal for verifying the consumer experience.
  • Tests run in parallel by default. Each test must be independent and set up its own state.
  • Return Result from tests for cleaner error handling and better failure messages.