| name | rust-test-best-practice |
| description | Comprehensive guide to Rust automated testing covering unit, integration, and documentation tests. Includes practical patterns for async testing, property-based testing with proptest, mocking strategies using traits, and essential testing tools (cargo-nextest, criterion, tarpaulin). Emphasizes FIRST principles and CI/CD integration. Use this when: setting up new Rust projects, establishing testing standards for teams, improving existing test suites, implementing TDD workflows, or learning Rust testing ecosystem best practices. Particularly valuable for projects requiring high reliability and comprehensive test coverage. |
Rust Automated Testing Best Practices Guide
1. Test Organization Structure
Unit Tests: Use #[cfg(test)] modules within the same file
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_functionality() {
assert_eq!(2 + 2, 4);
}
}
Integration Tests: Place in tests/ directory to test public APIs
Documentation Tests: Embed examples in doc comments that double as tests
/// Adds two numbers together.
///
/// ```
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
2. Core Testing Principles
Use Descriptive Test Names: Clearly express test intent
#[test]
fn connection_timeout_returns_error_after_30_seconds() { }
Follow AAA Pattern: Arrange (setup), Act (execute), Assert (verify)
Test Boundary Conditions: Empty inputs, maximum values, error cases, edge scenarios
One Assertion Per Test: Focus each test on a single behavior (when practical)
3. Advanced Testing Techniques
Test Panics with #[should_panic]:
#[test]
#[should_panic(expected = "invalid input")]
fn test_panics_on_invalid_input() {
process_data(""); // Should panic
}
Async Testing: Use tokio::test or async-std::test
#[tokio::test]
async fn test_async_operation() {
let result = fetch_data().await;
assert!(result.is_ok());
}
Property-Based Testing: Use proptest to test invariants across random inputs
proptest! {
#[test]
fn reversing_twice_returns_original(s: String) {
let reversed_twice = s.chars().rev().collect::<String>()
.chars().rev().collect::<String>();
assert_eq!(s, reversed_twice);
}
}
4. Mocking with mockall
Important: Before using mockall, always read the latest official documentation at https://docs.rs/mockall for up-to-date API and best practices.
When to Use Mocks
Mock objects serve two main purposes:
- Isolation Testing: When testing a specific module, mock the inputs and outputs of other modules to isolate the module under test.
- Simulating Edge Cases: Mock boundary conditions to test scenarios that are difficult to trigger or reproduce in real-world situations.
Key Principle: Mock the trait boundaries (interfaces/dependencies), not concrete implementations.
Basic Usage
Add to Cargo.toml:
[dev-dependencies]
mockall = "0.14"
Using #[automock] (from official repository):
#[cfg(test)]
use mockall::{automock, mock, predicate::*};
#[cfg_attr(test, automock)]
trait MyTrait {
fn foo(&self, x: u32) -> u32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mytest() {
let mut mock = MockMyTrait::new();
mock.expect_foo()
.with(eq(4)) // Argument matcher
.times(1) // Expected call count
.returning(|x| x + 1); // Return value
assert_eq!(5, mock.foo(4));
}
}
Return Values
#[automock]
trait MyTrait {
fn foo(&self) -> u32;
fn bar(&self, x: u32, y: u32) -> u32;
}
let mut mock = MockMyTrait::new();
mock.expect_foo()
.return_const(42u32); // Constant value
mock.expect_bar()
.returning(|x, y| x + y); // Computed value
Matching Multiple Calls
#[automock]
trait Foo {
fn foo(&self, x: u32) -> u32;
}
let mut mock = MockFoo::new();
mock.expect_foo()
.with(eq(5))
.return_const(50u32);
mock.expect_foo()
.with(eq(6))
.return_const(60u32);
Sequences (Enforce Call Order)
#[automock]
trait Foo {
fn foo(&self);
}
let mut seq = Sequence::new();
let mut mock1 = MockFoo::new();
mock1.expect_foo()
.times(1)
.in_sequence(&mut seq)
.returning(|| ());
let mut mock2 = MockFoo::new();
mock2.expect_foo()
.times(1)
.in_sequence(&mut seq)
.returning(|| ());
5. Essential Testing Tools
- Cargo test: Built-in test runner with filtering via
--test - Cargo-nextest: Faster parallel test execution with better output
- Cargo-watch: Auto-run tests on file changes (
cargo watch -x test) - Tarpaulin/llvm-cov: Code coverage analysis
- Criterion: Statistical benchmarking framework
6. Test Execution Strategies
Run specific tests:
cargo test test_name
cargo test module_name::
cargo test --test integration_test_name
Run with output:
cargo test -- --nocapture # Show println! output
cargo test -- --show-output # Show output for passing tests
Parallel vs Sequential:
cargo test -- --test-threads=1 # Run serially
7. CI/CD Integration
Set up GitHub Actions to:
- Run tests on multiple Rust versions (stable, beta, nightly)
- Test all feature combinations:
cargo test --all-features - Check code coverage and fail if below threshold
- Run
cargo clippyandcargo fmt --check
Example workflow snippet:
- name: Run tests
run: |
cargo test --all-features --workspace
cargo test --doc
8. Performance and Benchmark Testing
Use criterion for reliable benchmarks:
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn benchmark_function(c: &mut Criterion) {
c.bench_function("my_function", |b| {
b.iter(|| my_function(black_box(100)))
});
}
criterion_group!(benches, benchmark_function);
criterion_main!(benches);
9. Test Quality Guidelines
Keep Tests FIRST:
- Fast: Tests should run quickly
- Isolated: No dependencies between tests
- Repeatable: Same results every time
- Self-validating: Pass or fail, no manual checking
- Timely: Written alongside or before production code
Maintain Test Code Quality: Apply same standards as production code—tests should be clean, readable, and maintainable.
Avoid Test Flakiness: Don't use sleep(), random values without seeds, or depend on external state.
10. Common Patterns
Test Fixtures: Use setup() helper functions or the rstest crate for parameterized tests
Custom Assertions: Create helper functions for complex assertions
Error Testing: Test both error types and error messages
#[test]
fn returns_correct_error() {
let result = fallible_operation();
assert!(matches!(result, Err(Error::InvalidInput)));
}
Key Takeaway: Comprehensive testing is a cornerstone of reliable Rust software. Invest in your test suite—it pays dividends in confidence, maintainability, and rapid development.