Skip to content
Home
Rust Testing: Unit Tests, Integration Tests, and Doc Tests

Rust Testing: Unit Tests, Integration Tests, and Doc Tests

Rust Rust 8 min read 1516 words Beginner ExcellentWiki Editorial Team

Rust’s testing story is built directly into the language and toolchain. The #[test] attribute, cargo test, and the standard test harness give you a solid foundation. On top of that, Rust’s module system naturally separates test code from production code, and the type system catches many bugs before tests even run.

This guide covers unit tests, integration tests, documentation tests, property-based testing, and mocking — with practical examples for each.

Unit Tests

Unit tests live alongside the code they test, typically in a tests module:

// src/math.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
---

pub fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
---

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

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

    #[test]
    fn test_divide_success() {
        assert_eq!(divide(10, 2), Ok(5));
    }

    #[test]
    fn test_divide_by_zero() {
        assert!(divide(10, 0).is_err());
        assert_eq!(divide(10, 0), Err("Division by zero".to_string()));
    }
---

The #[cfg(test)] attribute ensures the test module is compiled only during cargo test. The #[test] attribute marks functions as tests. assert_eq! and assert_ne! are the workhorses for equality assertions.

Testing Panics

#[test]
#[should_panic(expected = "index out of bounds")]
fn test_panic() {
    let v = vec![1, 2, 3];
    v[10];
---

The expected parameter is optional but recommended. It matches a substring of the panic message.

Using Result in Tests

Tests can return Result<(), E> instead of panicking:

#[test]
fn test_with_result() -> Result<(), String> {
    let result = divide(10, 2)?;
    assert_eq!(result, 5);
    Ok(())
---

This enables using the ? operator in tests. The test fails if Err is returned, and the error message is printed.

Test Attributes

#[test]
#[ignore = "Requires external service"]
fn test_slow_operation() {
    // This test is skipped by default
---

#[test]
#[allow(unused_must_use)]
fn test_with_output() {
    // Run with: cargo test -- --nocapture
    println!("This output is hidden by default");
---

Run ignored tests with cargo test -- --ignored. Show stdout with cargo test -- --nocapture.

Integration Tests

Integration tests live in the tests/ directory at the crate root. Each file in tests/ is compiled as a separate crate:

// tests/api_tests.rs
use my_crate::*;

#[test]
fn test_full_workflow() {
    let result = my_crate::process_data("input");
    assert!(result.is_ok());
---

#[test]
fn test_error_case() {
    let result = my_crate::process_data("");
    assert!(result.is_err());
---

Shared Test Utilities

Create tests/common/mod.rs for shared setup code:

// tests/common/mod.rs
pub fn setup_test_db() -> Database {
    let db = Database::new_in_memory();
    db.run_migrations();
    db
---
// tests/user_tests.rs
mod common;

#[test]
fn test_create_user() {
    let db = common::setup_test_db();
    let user = db.create_user("alice@example.com");
    assert!(user.is_ok());
---

Files in tests/common/ (with mod.rs) are not compiled as separate test crates. Only files directly in tests/ become test binaries.

Integration Test for Binary Crates

If your crate is a binary (src/main.rs), you cannot import it from tests/. Refactor logic into a library (src/lib.rs) and re-export from main:

// src/lib.rs
pub fn run(config: Config) -> Result<(), Error> { /* ... */ }

// src/main.rs
fn main() {
    let config = Config::parse();
    my_app::run(config).unwrap();
---

// tests/integration.rs
use my_app::run;

Doc Tests

Documentation examples are automatically tested:

/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// use my_crate::add;
///
/// assert_eq!(add(2, 3), 5);
/// assert_eq!(add(-1, 1), 0);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
---

Run cargo test and every code block in doc comments is compiled and executed. This keeps documentation in sync with the code.

Hiding Code from Docs

/// # Only tested, not shown in docs
/// ```
/// # // Lines starting with # are hidden in output
/// # fn hidden_setup() {
/// #     // This is hidden from the rendered docs
/// # }
/// assert!(true);
/// ```

Lines prefixed with # are compiled and tested but not shown in the generated documentation. Use this for setup code that would distract from the example.

Failing Doc Tests

/// ```should_panic
/// let v = vec![1, 2, 3];
/// v[10];
/// ```
/// ```compile_fail
/// let x: i32 = "hello";
/// ```

Property-Based Testing

Property-based testing generates many random inputs and checks that invariants hold. The proptest crate is the most popular:

# Cargo.toml
[dev-dependencies]
proptest = "1"
use proptest::prelude::*;

proptest! {
    #![proptest_config = ProptestConfig::with_cases(10000)]

    #[test]
    fn reverse_reverses(a: Vec<i32>) {
        let reversed: Vec<i32> = a.iter().copied().rev().collect();
        let double_reversed: Vec<i32> = reversed.iter().copied().rev().collect();
        assert_eq!(a, double_reversed);
    }

    #[test]
    fn add_commutes(a: i32, b: i32) {
        assert_eq!(add(a, b), add(b, a));
    }

    #[test]
    fn sort_is_stable(mut a: Vec<i32>) {
        let sorted = {
            let mut b = a.clone();
            b.sort();
            b
        };
        a.sort();
        assert_eq!(a, sorted);
    }
---

Proptest shrinks failing cases — when it finds a failure, it tries to find a simpler input that also fails. The result is minimal reproduction cases.

Custom Strategies

fn email_strategy() -> impl Strategy<Value = String> {
    "[a-z]+@[a-z]+\\.[a-z]{2,4}"
---

proptest! {
    #[test]
    fn test_email_validation(email in email_strategy()) {
        assert!(validate_email(&email).is_ok());
    }
---

Mocking

Rust does not have built-in mocking like Mockito or unittest.mock. Instead, use trait-based dependency injection:

pub trait DataStore {
    fn get_user(&self, id: u64) -> Option<User>;
    fn save_user(&self, user: &User) -> Result<(), Error>;
---

pub struct UserService<T: DataStore> {
    store: T,
---

impl<T: DataStore> UserService<T> {
    pub fn new(store: T) -> Self {
        Self { store }
    }

    pub fn get_display_name(&self, id: u64) -> String {
        self.store
            .get_user(id)
            .map(|u| format!("{} {}", u.first_name, u.last_name))
            .unwrap_or_else(|| "Unknown".to_string())
    }
---

Test with a mock implementation:

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

    struct MockStore {
        users: Vec<User>,
    }

    impl DataStore for MockStore {
        fn get_user(&self, id: u64) -> Option<User> {
            self.users.iter().find(|u| u.id == id).cloned()
        }

        fn save_user(&self, _user: &User) -> Result<(), Error> {
            Ok(())
        }
    }

    #[test]
    fn test_get_display_name_found() {
        let store = MockStore {
            users: vec![User { id: 1, first_name: "Alice".into(), last_name: "Smith".into() }],
        };
        let service = UserService::new(store);
        assert_eq!(service.get_display_name(1), "Alice Smith");
    }

    #[test]
    fn test_get_display_name_not_found() {
        let store = MockStore { users: vec![] };
        let service = UserService::new(store);
        assert_eq!(service.get_display_name(999), "Unknown");
    }
---

For complex mocking, the mockall crate generates mock implementations automatically:

use mockall::predicate::*;
use mockall::*;

#[automock]
trait DataStore {
    fn get_user(&self, id: u64) -> Option<User>;
---

#[test]
fn test_with_mockall() {
    let mut mock = MockDataStore::new();
    mock.expect_get_user()
        .with(eq(1))
        .times(1)
        .returning(|_| Some(User { id: 1, /* ... */ }));
    mock.expect_get_user()
        .with(eq(999))
        .returning(|_| None);

    let service = UserService::new(mock);
    // Test expectations...
---

Test Organization

Test Module Structure

src/
├── lib.rs
├── auth.rs
│   └── tests/
│       └── auth_tests.rs  (inline)
└── db.rs
    └── tests/
        └── db_tests.rs    (inline)
tests/
├── api_tests.rs
├── integration_tests.rs
└── common/
    └── mod.rs

Keep unit tests close to the code they test. Use integration tests for end-to-end scenarios that exercise multiple modules.

Cargo Test Command Reference

cargo test                    # Run all tests
cargo test test_name          # Filter by test name
cargo test -- --test-threads=1  # Run sequentially
cargo test -- --nocapture     # Show stdout
cargo test -- --ignored       # Run only ignored tests
cargo test --doc              # Run only doc tests
cargo test -p my_crate        # Test a specific package

Best Practices

  • Test public API, not internals — Test behavior observable from outside the module. Internal implementation details should not require test changes when refactoring.
  • One assertion per test — Each test should verify one behavior. Multiple assertions make it harder to identify which condition failed.
  • Use descriptive test namestest_add_positive_numbers is better than test_add.
  • Test error paths — Happy paths are easy. Error handling, edge cases, and boundary conditions are where bugs hide.
  • Keep tests fast — A test suite that takes minutes discourages running it frequently. Move slow integration tests behind #[ignore].

Summary

Rust provides unit tests (inline with #[test]), integration tests (in tests/), and doc tests (in doc comments). Property-based testing with proptest finds edge cases by generating random inputs. Trait-based mocking keeps tests isolated. Together, these tools let you write comprehensive test suites that catch bugs early and stay reliable as your code evolves.

Unit and Integration Tests

Rust’s test framework distinguishes between unit tests and integration tests. Unit tests live in the same file as the code being tested, in a #[cfg(test)] module, and have access to private functions. Integration tests live in a tests/ directory at the crate root and test the public API only. The cargo test command runs both with different flags for filtering. Use #[should_panic] for tests expecting panics and Result<T, E> return types for tests with fallible setup.

Test Organization and Performance

Group related tests into modules for better organization. Use cargo test -- --test-threads=1 for tests that share resources. Benchmarks use cargo bench with the #[bench] attribute or the criterion crate for statistical analysis. Doc tests (/// ```rust) ensure examples in documentation stay correct and serve as executable documentation.

FAQ

What background knowledge is recommended? A basic understanding of programming fundamentals will help, but many concepts are explained from first principles.

How can I practice these skills? Start with small projects, contribute to open source, and build a portfolio. Consistent practice is more effective than occasional deep dives.

What tools do I need to get started? Most topics require only a text editor, the relevant runtime, and package manager. Specific tool recommendations are included throughout.

For a comprehensive overview, read our article on Getting Started With Rust.

For a comprehensive overview, read our article on Rust Async Programming.

Section: Rust 1516 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top