Rust Testing: Unit Tests, Integration Tests, and Doc Tests
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.rsKeep 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 packageBest 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 names —
test_add_positive_numbersis better thantest_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.