Rust Testing: Unit Tests, Integration Tests, and Doc Tests
Rust treats testing as a first-class citizen. Unlike many languages where you need to install third-party testing frameworks, Rust ships with everything you need built into `cargo` and the standard...
Key Insights
- Rust’s testing framework is built into the language and cargo, providing three distinct test types: unit tests for internal logic, integration tests for public APIs, and doc tests that double as executable documentation.
- Unit tests live alongside your code with
#[cfg(test)]modules and can test private functions, while integration tests reside in a separatetests/directory and treat your crate as an external dependency. - Doc tests in
///comments ensure your documentation examples stay accurate and compile, making them invaluable for maintaining reliable API documentation.
Introduction to Rust’s Testing Philosophy
Rust treats testing as a first-class citizen. Unlike many languages where you need to install third-party testing frameworks, Rust ships with everything you need built into cargo and the standard library. This design decision reflects Rust’s broader philosophy: if something is important enough, it should be easy and standardized.
The testing framework supports three distinct types of tests, each serving a specific purpose. Unit tests verify individual functions and internal logic. Integration tests validate that your public API works correctly from a consumer’s perspective. Doc tests ensure your documentation examples actually compile and run. This multi-layered approach encourages comprehensive testing without requiring additional dependencies or complex setup.
Unit Tests: Testing Internal Logic
Unit tests in Rust live in the same file as the code they test, inside a module marked with #[cfg(test)]. This attribute tells the compiler to only compile this code when running cargo test, keeping it out of your release builds.
Here’s a basic unit test structure:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("division by zero"))
} else {
Ok(a / b)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
assert_eq!(add(-1, 1), 0);
assert_ne!(add(3, 3), 5);
}
#[test]
fn test_divide_success() {
assert_eq!(divide(10, 2).unwrap(), 5);
assert!(divide(10, 2).is_ok());
}
#[test]
fn test_divide_by_zero() {
assert!(divide(10, 0).is_err());
}
}
The #[test] attribute marks functions as test cases. Rust provides three primary assertion macros: assert! for boolean conditions, assert_eq! for equality checks, and assert_ne! for inequality. These macros panic when assertions fail, causing the test to fail.
One of unit tests’ key advantages in Rust is the ability to test private functions. Since tests live in the same module, they have access to private implementation details:
fn internal_calculation(x: i32) -> i32 {
x * 2 + 1
}
pub fn public_api(x: i32) -> i32 {
internal_calculation(x) + 10
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_internal_calculation() {
assert_eq!(internal_calculation(5), 11);
}
#[test]
fn test_public_api() {
assert_eq!(public_api(5), 21);
}
}
For testing error conditions, use the #[should_panic] attribute:
pub fn must_be_positive(x: i32) {
if x <= 0 {
panic!("Value must be positive");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "must be positive")]
fn test_negative_panics() {
must_be_positive(-1);
}
}
Modern Rust code often uses Result for error handling. Tests can return Result<(), E>, allowing you to use the ? operator:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_result() -> Result<(), String> {
let result = divide(10, 2)?;
assert_eq!(result, 5);
Ok(())
}
}
Integration Tests: Testing Public APIs
Integration tests live in the tests/ directory at the root of your project, alongside src/. Each file in this directory is compiled as a separate crate, treating your library as an external dependency. This means integration tests can only access your public API—exactly what your users will interact with.
Create tests/integration_test.rs:
use my_crate::*;
#[test]
fn test_public_interface() {
let result = public_api(5);
assert_eq!(result, 21);
}
#[test]
fn test_cross_module_behavior() {
// Test how multiple public components work together
let x = add(5, 3);
let y = divide(x, 2).unwrap();
assert_eq!(y, 4);
}
Integration tests are perfect for testing workflows that span multiple modules or validating that your public API behaves correctly from an outsider’s perspective. They catch issues that unit tests might miss, such as incorrect visibility modifiers or missing public exports.
When you need shared test utilities, create tests/common/mod.rs:
// tests/common/mod.rs
pub fn setup_test_data() -> Vec<i32> {
vec![1, 2, 3, 4, 5]
}
pub fn teardown() {
// Cleanup code
}
Then use it in your integration tests:
// tests/integration_test.rs
mod common;
#[test]
fn test_with_common_setup() {
let data = common::setup_test_data();
assert_eq!(data.len(), 5);
common::teardown();
}
The mod.rs pattern prevents Rust from treating common as a separate test file. Without this, cargo would try to run common as its own test suite.
Documentation Tests: Executable Examples
Documentation tests are Rust’s secret weapon for maintaining accurate documentation. Any code block in /// doc comments is automatically compiled and run as a test:
/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// use my_crate::add;
///
/// let result = add(2, 2);
/// assert_eq!(result, 4);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Run cargo test and this example becomes a test case. If your API changes and the example breaks, the test fails. This guarantees your documentation stays synchronized with your code.
Doc tests support the full range of Rust syntax, including error handling:
/// Divides two numbers.
///
/// # Examples
///
/// ```
/// use my_crate::divide;
///
/// let result = divide(10, 2)?;
/// assert_eq!(result, 5);
/// # Ok::<(), String>(())
/// ```
pub fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("division by zero"))
} else {
Ok(a / b)
}
}
Lines starting with # are hidden from the rendered documentation but executed during tests. This lets you add necessary boilerplate without cluttering your examples.
You can also test that code should panic or fail to compile:
/// This function panics on negative input.
///
/// # Examples
///
/// ```should_panic
/// use my_crate::must_be_positive;
///
/// must_be_positive(-1); // This will panic
/// ```
pub fn must_be_positive(x: i32) {
if x <= 0 {
panic!("Value must be positive");
}
}
Advanced Testing Techniques
As your test suite grows, organization becomes critical. Use nested test modules to group related tests:
#[cfg(test)]
mod tests {
use super::*;
mod addition {
use super::*;
#[test]
fn positive_numbers() {
assert_eq!(add(2, 2), 4);
}
#[test]
fn negative_numbers() {
assert_eq!(add(-2, -2), -4);
}
}
mod division {
use super::*;
#[test]
fn successful_division() {
assert!(divide(10, 2).is_ok());
}
#[test]
fn division_by_zero() {
assert!(divide(10, 0).is_err());
}
}
}
Sometimes tests need to run sequentially, especially when testing shared resources. Use the #[ignore] attribute for expensive tests:
#[test]
#[ignore]
fn expensive_integration_test() {
// This test only runs with: cargo test -- --ignored
}
Cargo provides powerful test filtering. Run specific tests by name:
cargo test addition # Runs all tests with "addition" in the name
cargo test -- --test-threads=1 # Run tests sequentially
cargo test -- --nocapture # Show println! output
Best Practices and Common Patterns
Choose unit tests for testing individual functions, algorithms, and internal logic. They’re fast, focused, and can test private implementation details. Use them liberally—aim for high coverage of your core logic.
Reserve integration tests for validating public APIs and testing how components work together. They’re slower because each test file compiles separately, but they catch integration issues that unit tests miss.
Write doc tests for every public function with non-trivial usage. They serve double duty: ensuring your examples work and providing clear usage documentation. Users read doc tests first—make them count.
Keep test code as clean as production code. Extract common setup into helper functions. Use descriptive test names that explain what they verify. A test named test_divide_by_zero_returns_error is infinitely more valuable than test_1.
Don’t chase 100% coverage blindly, but do test all critical paths, error conditions, and edge cases. If a bug would be catastrophic, write a test for it. If you fix a bug, write a test that would have caught it.
Rust’s testing framework makes it easy to write tests, so write them. Run cargo test frequently. Let the compiler and your tests catch bugs before your users do. That’s the Rust way.