Testing is not an afterthought in Rust—it’s woven into the language’s DNA. The Rust compiler and toolchain provide first-class support for writing, organizing, and running tests. In this article, we’ll explore testing comprehensively: from basic unit tests to advanced mocking strategies and property-based testing. You’ll learn why Rust’s approach to testing is powerful and how to use it effectively in real-world projects.
Why Testing Matters in Rust
Rust’s compiler catches many bugs at compile time through its type system and borrow checker. However, it cannot catch logic errors, incorrect business logic, or integration issues. This is where testing becomes essential.
Rust’s testing philosophy emphasizes:
- Tests are first-class citizens - The language has built-in support for tests
- Tests live alongside code - Tests stay in the same file as the code they test
- Easy to run - A single command runs all tests
- Type safety extends to tests - The same safety guarantees apply to test code
Unit Testing: The Foundation
Basic Unit Test Structure
In Rust, you write unit tests in the same file as your code, typically in a #[cfg(test)] module.
// filepath: src/calculator.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_positive_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_subtract_positive_numbers() {
assert_eq!(subtract(5, 3), 2);
}
#[test]
fn test_add_negative_numbers() {
assert_eq!(add(-2, -3), -5);
}
}
The #[cfg(test)] attribute tells the compiler to only include this module when running tests. This keeps test code out of production binaries.
Running Tests
# Run all tests
cargo test
# Run tests in a specific module
cargo test calculator
# Run tests matching a specific name
cargo test test_add
# Run tests sequentially (by default, Rust runs tests in parallel)
cargo test -- --test-threads=1
# Run a single test and show output (even if it passes)
cargo test -- --nocapture test_add_positive_numbers
Assert Macros
Rust provides several assertion macros for different scenarios:
// filepath: src/assertion_examples.rs
#[cfg(test)]
mod tests {
#[test]
fn assert_equal_example() {
let result = 2 + 2;
assert_eq!(result, 4); // Panics if not equal
}
#[test]
fn assert_not_equal_example() {
let result = 2 + 2;
assert_ne!(result, 5); // Panics if equal
}
#[test]
fn assert_true_example() {
let condition = true;
assert!(condition); // Panics if false
}
#[test]
#[should_panic]
fn test_panic_expected() {
panic!("This is expected");
}
#[test]
fn test_with_custom_message() {
let x = 2;
assert_eq!(x, 3, "Expected {} to equal 3, but got different value", x);
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_specific_panic() {
let v = vec![1, 2, 3];
let _ = &v[10]; // This will panic with "index out of bounds"
}
}
Testing With Result Types
Modern Rust tests can also return Result, which is cleaner than using #[should_panic]:
// filepath: src/result_tests.rs
#[cfg(test)]
mod tests {
#[test]
fn returns_result_example() -> Result<(), String> {
let result = 2 + 2;
if result == 4 {
Ok(())
} else {
Err(String::from("Math is broken!"))
}
}
#[test]
fn with_question_mark() -> Result<(), String> {
let value = parse_number("42")?;
assert_eq!(value, 42);
Ok(())
}
fn parse_number(s: &str) -> Result<i32, String> {
s.parse().map_err(|_| "Invalid number".to_string())
}
}
Integration Testing
Integration tests verify that multiple components work together correctly. They live in a separate directory and test the public API of your crate.
Project Structure
my_project/
├── src/
│ ├── lib.rs
│ └── calculator.rs
├── tests/ <-- Integration tests directory
│ └── integration_tests.rs
└── Cargo.toml
Writing Integration Tests
// filepath: tests/integration_tests.rs
use my_project::calculator::{add, subtract};
#[test]
fn integration_test_calculation_sequence() {
let result = add(10, 5);
let final_result = subtract(result, 3);
assert_eq!(final_result, 12);
}
#[test]
fn integration_test_complex_workflow() {
let x = add(5, 5);
let y = add(x, 10);
let z = subtract(y, 5);
assert_eq!(z, 20);
}
Key differences from unit tests:
- Integration tests are in a separate
tests/directory - They test the public API of your crate
- Each file in
tests/is compiled as a separate binary - They don’t have access to private module internals
Shared Test Utilities
When you have multiple integration test files, you can share common setup code:
// filepath: tests/common/mod.rs
pub fn setup_test_database() -> TestDB {
TestDB::new()
}
pub struct TestDB {
data: Vec<String>,
}
impl TestDB {
pub fn new() -> Self {
TestDB { data: Vec::new() }
}
}
// filepath: tests/test_database.rs
mod common;
use common::setup_test_database;
#[test]
fn test_with_shared_setup() {
let db = setup_test_database();
// Use the test database
}
Mocking Strategies
Mocking is crucial for testing code that depends on external services, file systems, or complex dependencies. Let’s explore several mocking approaches.
Strategy 1: Trait-Based Mocking (Recommended)
The idiomatic Rust approach to mocking is using traits. Design your code to depend on abstractions (traits), making it easy to swap real implementations with mocks.
// filepath: src/user_service.rs
use std::collections::HashMap;
// Define the trait abstraction
pub trait UserRepository {
fn get_user(&self, id: u32) -> Option<User>;
fn save_user(&mut self, user: User) -> Result<(), String>;
}
#[derive(Clone, Debug, PartialEq)]
pub struct User {
pub id: u32,
pub name: String,
pub email: String,
}
// Real implementation
pub struct DatabaseUserRepository {
// In real code, this would be a database connection
users: HashMap<u32, User>,
}
impl DatabaseUserRepository {
pub fn new() -> Self {
DatabaseUserRepository {
users: HashMap::new(),
}
}
}
impl UserRepository for DatabaseUserRepository {
fn get_user(&self, id: u32) -> Option<User> {
self.users.get(&id).cloned()
}
fn save_user(&mut self, user: User) -> Result<(), String> {
self.users.insert(user.id, user);
Ok(())
}
}
// Service that depends on the trait, not the concrete implementation
pub struct UserService<R: UserRepository> {
repository: R,
}
impl<R: UserRepository> UserService<R> {
pub fn new(repository: R) -> Self {
UserService { repository }
}
pub fn get_user_email(&self, id: u32) -> Option<String> {
self.repository
.get_user(id)
.map(|user| user.email)
}
pub fn update_user_name(&mut self, id: u32, new_name: String) -> Result<(), String> {
if let Some(mut user) = self.repository.get_user(id) {
user.name = new_name;
self.repository.save_user(user)?;
Ok(())
} else {
Err("User not found".to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Mock implementation
struct MockUserRepository {
users: HashMap<u32, User>,
}
impl MockUserRepository {
fn new() -> Self {
MockUserRepository {
users: HashMap::new(),
}
}
fn with_user(mut self, user: User) -> Self {
self.users.insert(user.id, user);
self
}
}
impl UserRepository for MockUserRepository {
fn get_user(&self, id: u32) -> Option<User> {
self.users.get(&id).cloned()
}
fn save_user(&mut self, user: User) -> Result<(), String> {
self.users.insert(user.id, user);
Ok(())
}
}
#[test]
fn test_get_user_email() {
let user = User {
id: 1,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
};
let mock_repo = MockUserRepository::new().with_user(user);
let service = UserService::new(mock_repo);
assert_eq!(
service.get_user_email(1),
Some("[email protected]".to_string())
);
}
#[test]
fn test_get_nonexistent_user() {
let mock_repo = MockUserRepository::new();
let service = UserService::new(mock_repo);
assert_eq!(service.get_user_email(999), None);
}
#[test]
fn test_update_user_name() {
let user = User {
id: 1,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
};
let mock_repo = MockUserRepository::new().with_user(user);
let mut service = UserService::new(mock_repo);
let result = service.update_user_name(1, "Bob".to_string());
assert!(result.is_ok());
// Note: In real tests, we'd verify the repository was updated
// This example is simplified
}
}
Strategy 2: Using the mockito Crate
For mocking HTTP requests, use mockito:
// Cargo.toml
// [dev-dependencies]
// mockito = "1.2"
// filepath: src/http_client.rs
use std::error::Error;
pub struct ApiClient;
impl ApiClient {
pub async fn fetch_user_data(user_id: u32) -> Result<String, Box<dyn Error>> {
let url = format!("https://api.example.com/users/{}", user_id);
let response = reqwest::get(&url).await?;
Ok(response.text().await?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::mock;
#[tokio::test]
async fn test_fetch_user_data() {
let _m = mock("GET", mockito::Matcher::Regex(r"^https://api\.example\.com/users/1$".into()))
.with_status(200)
.with_body(r#"{"id": 1, "name": "Alice"}"#)
.create();
// In a real test, you would configure the client to use mockito's server
// This example is simplified for illustration
}
}
Strategy 3: Using proptest for Property-Based Testing
Property-based testing generates random inputs and verifies that properties hold true:
// Cargo.toml
// [dev-dependencies]
// proptest = "1.4"
// filepath: src/sorting.rs
pub fn bubble_sort(arr: &mut [i32]) {
let n = arr.len();
for i in 0..n {
for j in 0..n - i - 1 {
if arr[j] > arr[j + 1] {
arr.swap(j, j + 1);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::proptest;
#[test]
fn test_bubble_sort_example() {
let mut arr = vec![3, 1, 4, 1, 5, 9, 2, 6];
bubble_sort(&mut arr);
assert_eq!(arr, vec![1, 1, 2, 3, 4, 5, 6, 9]);
}
proptest! {
#[test]
fn prop_sorted_array_is_sorted(mut vec in prop::collection::vec(-1000i32..1000, 0..100)) {
bubble_sort(&mut vec);
// Property: After sorting, each element is <= the next element
for i in 0..vec.len() - 1 {
assert!(vec[i] <= vec[i + 1]);
}
}
#[test]
fn prop_sorted_array_has_same_length(mut vec in prop::collection::vec(-1000i32..1000, 0..100)) {
let original_len = vec.len();
bubble_sort(&mut vec);
// Property: Sorting doesn't change the length
assert_eq!(vec.len(), original_len);
}
#[test]
fn prop_sorted_array_has_same_elements(mut vec in prop::collection::vec(-1000i32..1000, 0..100)) {
let mut expected = vec.clone();
expected.sort();
bubble_sort(&mut vec);
// Property: All elements are preserved (multiset equality)
let mut sorted_vec = vec.clone();
sorted_vec.sort();
assert_eq!(sorted_vec, expected);
}
}
}
Test Fixtures and Setup
For complex tests, you often need to set up shared state.
// filepath: src/bank_account.rs
pub struct BankAccount {
balance: f64,
}
impl BankAccount {
pub fn new(initial_balance: f64) -> Result<Self, String> {
if initial_balance < 0.0 {
Err("Initial balance cannot be negative".to_string())
} else {
Ok(BankAccount {
balance: initial_balance,
})
}
}
pub fn deposit(&mut self, amount: f64) -> Result<(), String> {
if amount <= 0.0 {
Err("Deposit amount must be positive".to_string())
} else {
self.balance += amount;
Ok(())
}
}
pub fn withdraw(&mut self, amount: f64) -> Result<(), String> {
if amount <= 0.0 {
Err("Withdrawal amount must be positive".to_string())
} else if amount > self.balance {
Err("Insufficient funds".to_string())
} else {
self.balance -= amount;
Ok(())
}
}
pub fn balance(&self) -> f64 {
self.balance
}
}
#[cfg(test)]
mod tests {
use super::*;
// Fixture: A helper function to create a test account
fn create_test_account() -> BankAccount {
BankAccount::new(1000.0).expect("Failed to create test account")
}
#[test]
fn test_deposit() {
let mut account = create_test_account();
assert!(account.deposit(500.0).is_ok());
assert_eq!(account.balance(), 1500.0);
}
#[test]
fn test_withdraw_success() {
let mut account = create_test_account();
assert!(account.withdraw(300.0).is_ok());
assert_eq!(account.balance(), 700.0);
}
#[test]
fn test_withdraw_insufficient_funds() {
let mut account = create_test_account();
assert!(account.withdraw(2000.0).is_err());
assert_eq!(account.balance(), 1000.0); // Balance unchanged
}
#[test]
fn test_negative_initial_balance() {
assert!(BankAccount::new(-100.0).is_err());
}
}
Common Pitfalls and Best Practices
Pitfall 1: Not Testing Edge Cases
// ❌ Bad: Only tests the happy path
#[test]
fn test_divide() {
assert_eq!(divide(10, 2), 5);
}
// ✅ Good: Tests edge cases
#[test]
fn test_divide_happy_path() {
assert_eq!(divide(10, 2), 5);
}
#[test]
#[should_panic(expected = "Division by zero")]
fn test_divide_by_zero() {
divide(10, 0);
}
#[test]
fn test_divide_with_remainder() {
assert_eq!(divide(10, 3), 3); // Integer division
}
Pitfall 2: Tests That Are Too Interdependent
// ❌ Bad: Tests depend on execution order
#[cfg(test)]
mod tests {
use std::sync::Mutex;
lazy_static::lazy_static! {
static ref COUNTER: Mutex<i32> = Mutex::new(0);
}
#[test]
fn test_first() {
let mut c = COUNTER.lock().unwrap();
*c = 0;
}
#[test]
fn test_second() {
// Depends on test_first running first!
let c = COUNTER.lock().unwrap();
assert_eq!(*c, 0);
}
}
// ✅ Good: Each test is independent
#[cfg(test)]
mod tests {
use std::sync::Mutex;
fn setup() -> Mutex<i32> {
Mutex::new(0)
}
#[test]
fn test_counter_independent_1() {
let counter = setup();
let mut c = counter.lock().unwrap();
*c = 1;
assert_eq!(*c, 1);
}
#[test]
fn test_counter_independent_2() {
let counter = setup();
let mut c = counter.lock().unwrap();
*c = 2;
assert_eq!(*c, 2);
}
}
Pitfall 3: Testing Implementation Details
// ❌ Bad: Tests implementation details
#[test]
fn test_internal_vector_size() {
let list = LinkedList::new();
// LinkedList's internal representation shouldn't be tested!
assert_eq!(list.nodes.len(), 0);
}
// ✅ Good: Tests public behavior
#[test]
fn test_list_is_empty() {
let list: LinkedList<i32> = LinkedList::new();
assert!(list.is_empty());
}
Best Practice 1: Use Meaningful Test Names
// ❌ Unclear
#[test]
fn test_1() {
assert!(process_payment(100.0));
}
// ✅ Clear
#[test]
fn test_process_payment_with_valid_amount_succeeds() {
assert!(process_payment(100.0));
}
#[test]
fn test_process_payment_with_zero_amount_fails() {
assert!(!process_payment(0.0));
}
Best Practice 2: One Assertion Per Test (When Possible)
// ❌ Multiple unrelated assertions
#[test]
fn test_user_operations() {
let user = create_user("Alice", "[email protected]");
assert_eq!(user.name, "Alice");
assert_eq!(user.email, "[email protected]");
assert!(user.is_active);
assert_ne!(user.id, 0);
}
// ✅ Focused tests
#[test]
fn test_user_has_correct_name() {
let user = create_user("Alice", "[email protected]");
assert_eq!(user.name, "Alice");
}
#[test]
fn test_user_has_correct_email() {
let user = create_user("Alice", "[email protected]");
assert_eq!(user.email, "[email protected]");
}
#[test]
fn test_user_is_active_by_default() {
let user = create_user("Alice", "[email protected]");
assert!(user.is_active);
}
Best Practice 3: Use should_panic Carefully
// Better: Use Result-based tests
#[test]
fn test_invalid_parse() -> Result<(), String> {
let result = "not_a_number".parse::<i32>();
assert!(result.is_err());
Ok(())
}
// Or test the specific behavior
#[test]
fn test_panic_on_invalid_parse() {
let result = std::panic::catch_unwind(|| {
"not_a_number".parse::<i32>().expect("Expected number")
});
assert!(result.is_err());
}
Advanced: Integration with CI/CD
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test --verbose
- name: Run tests with all features
run: cargo test --all-features --verbose
- name: Run doc tests
run: cargo test --doc
- name: Check code coverage
uses: codecov/codecov-action@v3
Testing Strategies Summary
| Strategy | Use Case | Pros | Cons |
|---|---|---|---|
| Unit Tests | Test individual functions | Fast, isolated | Limited scope |
| Integration Tests | Test components together | Tests real behavior | Slower, harder to debug |
| Property-Based Tests | Verify invariants | Catches edge cases | Requires property thinking |
| Trait Mocking | Replace dependencies | Idiomatic Rust | Manual implementation |
| Mock Crates | Mock HTTP/external services | Complete | Adds dependencies |
Pros and Cons of Different Approaches
Trait-Based Mocking
Pros:
- Idiomatic Rust
- Zero runtime overhead
- Type-safe
- Works without additional crates
Cons:
- Requires upfront design
- More verbose for simple cases
- Must create mock implementations manually
Mock Crates (mockito, mockall)
Pros:
- Less boilerplate for complex scenarios
- Can mock external HTTP services
- Powerful for advanced use cases
Cons:
- Adds dependencies
- Runtime overhead
- Less Rustic approach
- Steeper learning curve
Property-Based Testing vs. Deterministic Tests
Pros (Property-Based):
- Finds edge cases you wouldn’t think of
- Provides regression testing
- Excellent for algorithms
Cons (Property-Based):
- Slower than deterministic tests
- Requires thinking in properties
- Can be flaky if not designed carefully
Further Resources and Alternative Technologies
Books and Articles
- “The Rust Programming Language” (Official Book) - Chapter on Testing: https://doc.rust-lang.org/book/ch11-00-testing.html
- “Rust by Example” - Testing section: https://doc.rust-lang.org/rust-by-example/testing.html
- “Effective Rust” - Item 17: Use tests to clarify the interface: https://www.oreilly.com/library/view/effective-rust/9781098150587/
Testing Crates
criterion(Benchmarking): https://bheisler.github.io/criterion.rs/book/proptest(Property-based testing): https://docs.rs/proptest/latest/proptest/mockito(HTTP mocking): https://github.com/lipanski/mockitomockall(Advanced mocking with macros): https://github.com/asomers/mockalltestcontainers(Docker containers in tests): https://github.com/testcontainers/testcontainers-rs
Complementary Technologies
- Code Coverage:
tarpaulin,grcov - Fuzzing:
cargo-fuzzfor finding bugs with random inputs - Benchmarking:
criterionfor performance regression testing - Test Parallelization: Built-in, but use
--test-threads=1to serialize
CI/CD Platforms with Rust Support
- GitHub Actions - https://github.com/features/actions
- GitLab CI - https://docs.gitlab.com/ee/ci/
- Travis CI - https://www.travis-ci.com/
- CircleCI - https://circleci.com/
Conclusion
Rust’s testing capabilities are powerful and idiomatic. By combining unit tests, integration tests, trait-based mocking, and property-based testing, you can build a comprehensive test suite that catches bugs early and gives you confidence in your code.
The key principles are:
- Write tests alongside code - Use
#[cfg(test)]modules - Design for testability - Use traits to abstract dependencies
- Test behavior, not implementation - Focus on what your code does, not how it does it
- Use the right tool for the job - Unit tests for logic, integration tests for workflows, property tests for invariants
- Keep tests independent - Each test should be runnable in isolation
With these practices in place, you’ll write more reliable, maintainable Rust code.
- Handle errors gracefully in your tests
- Advanced Traits in Rust - Design traits for better testability
- Concurrency in Rust: Sharing Data - Test concurrent code safely
- Publishing a Rust Crate - Include tests when publishing
Happy testing! 🧪🦀
Comments