| name | rust-testing |
| description | Rust testing patterns including unit tests, integration tests, doc tests, benchmarks, property-based testing, and coverage. Follows TDD methodology with idiomatic Rust practices. |
Rust Testing Patterns
Comprehensive Rust testing patterns for writing reliable, maintainable tests following TDD methodology.
When to Activate
- Writing new Rust functions or methods
- Adding test coverage to existing code
- Creating benchmarks for performance-critical code
- Implementing property-based tests for input validation
- Following TDD workflow in Rust projects
TDD Workflow for Rust
The RED-GREEN-REFACTOR Cycle
RED → Write a failing test first
GREEN → Write minimal code to pass the test
REFACTOR → Improve code while keeping tests green
REPEAT → Continue with next requirement
Step-by-Step TDD in Rust
pub fn add(a: i32, b: i32) -> i32 {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Unit Tests
Basic Unit Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_positive_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_negative_numbers() {
assert_eq!(add(-1, -2), -3);
}
#[test]
fn test_mixed_signs() {
assert_eq!(add(-1, 1), 0);
}
}
Testing Error Cases
#[test]
fn test_divide_success() {
assert_eq!(divide(10, 2), Ok(5));
}
#[test]
fn test_divide_by_zero() {
assert_eq!(divide(10, 0), Err(MathError::DivisionByZero));
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_index_out_of_bounds() {
let v = vec![1, 2, 3];
let _ = v[10];
}
Testing with Result Return
#[test]
fn test_parse_config() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::from_str("host=localhost\nport=8080")?;
assert_eq!(config.host, "localhost");
assert_eq!(config.port, 8080);
Ok(())
}
Parameterized Tests with Macros
macro_rules! test_cases {
($($name:ident: $input:expr => $expected:expr),* $(,)?) => {
$(
#[test]
fn $name() {
assert_eq!(process($input), $expected);
}
)*
};
}
test_cases! {
empty_string: "" => "",
single_word: "hello" => "HELLO",
multiple_words: "hello world" => "HELLO WORLD",
with_numbers: "test123" => "TEST123",
}
Alternative: Parameterized with Loop
#[test]
fn test_parse_cases() {
let cases = vec![
("42", Ok(42)),
("-1", Ok(-1)),
("abc", Err(ParseError::InvalidDigit)),
("", Err(ParseError::Empty)),
("99999999999999999999", Err(ParseError::Overflow)),
];
for (input, expected) in cases {
let result = parse_number(input);
assert_eq!(result, expected, "failed for input: {input:?}");
}
}
Integration Tests
Test Files in tests/ Directory
use my_project::{Config, Server};
#[tokio::test]
async fn test_health_endpoint() -> anyhow::Result<()> {
let server = spawn_test_server().await;
let resp = reqwest::get(&format!("{}/health", server.addr())).await?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await?, "OK");
Ok(())
}
#[tokio::test]
async fn test_create_user() -> anyhow::Result<()> {
let server = spawn_test_server().await;
let client = reqwest::Client::new();
let resp = client
.post(&format!("{}/users", server.addr()))
.json(&serde_json::json!({"name": "Alice", "email": "alice@example.com"}))
.send()
.await?;
assert_eq!(resp.status(), 201);
Ok(())
}
Shared Test Helpers
use my_project::Server;
pub async fn spawn_test_server() -> TestServer {
let config = Config::test_default();
let server = Server::new(config).await.expect("test server setup failed");
let addr = server.local_addr();
let handle = tokio::spawn(async move { server.run().await });
TestServer { addr, _handle: handle }
}
pub struct TestServer {
addr: std::net::SocketAddr,
_handle: tokio::task::JoinHandle<()>,
}
impl TestServer {
pub fn addr(&self) -> String {
format!("http://{}", self.addr)
}
}
Doc Tests
Documentation Examples as Tests
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn parse(input: &str) -> Result<Config, ConfigError> {
}
Hiding Setup in Doc Tests
Mocking and Test Doubles
Trait-Based Mocking (Manual)
pub trait UserRepository: Send + Sync {
fn find_by_id(&self, id: u64) -> Result<Option<User>>;
fn save(&self, user: &User) -> Result<()>;
}
#[cfg(test)]
struct MockUserRepo {
users: std::sync::Mutex<Vec<User>>,
}
#[cfg(test)]
impl MockUserRepo {
fn new() -> Self {
Self { users: std::sync::Mutex::new(vec![]) }
}
fn with_users(users: Vec<User>) -> Self {
Self { users: std::sync::Mutex::new(users) }
}
}
#[cfg(test)]
impl UserRepository for MockUserRepo {
fn find_by_id(&self, id: u64) -> Result<Option<User>> {
Ok(self.users.lock().unwrap().iter().find(|u| u.id == id).cloned())
}
fn save(&self, user: &User) -> Result<()> {
self.users.lock().unwrap().push(user.clone());
Ok(())
}
}
#[test]
fn test_user_service() {
let repo = MockUserRepo::with_users(vec![
User { id: 1, name: "Alice".into() },
]);
let service = UserService::new(repo);
let user = service.get_profile(1).unwrap().unwrap();
assert_eq!(user.name, "Alice");
}
Using mockall Crate
use mockall::automock;
#[automock]
pub trait EmailService {
fn send(&self, to: &str, subject: &str, body: &str) -> Result<()>;
}
#[test]
fn test_registration_sends_email() {
let mut mock_email = MockEmailService::new();
mock_email
.expect_send()
.withf(|to, subject, _| to == "alice@example.com" && subject == "Welcome!")
.times(1)
.returning(|_, _, _| Ok(()));
let service = RegistrationService::new(mock_email);
service.register("Alice", "alice@example.com").unwrap();
}
Property-Based Testing
Using proptest
use proptest::prelude::*;
proptest! {
#[test]
fn test_sort_preserves_length(ref v in prop::collection::vec(any::<i32>(), 0..100)) {
let mut sorted = v.clone();
sorted.sort();
prop_assert_eq!(sorted.len(), v.len());
}
#[test]
fn test_sort_is_idempotent(ref v in prop::collection::vec(any::<i32>(), 0..100)) {
let mut sorted1 = v.clone();
sorted1.sort();
let mut sorted2 = sorted1.clone();
sorted2.sort();
prop_assert_eq!(sorted1, sorted2);
}
#[test]
fn test_encode_decode_roundtrip(s in "\\PC*") {
let encoded = encode(&s);
let decoded = decode(&encoded).unwrap();
prop_assert_eq!(s, decoded);
}
}
Using quickcheck
#[cfg(test)]
mod tests {
use quickcheck::quickcheck;
quickcheck! {
fn prop_reverse_reverse_is_identity(xs: Vec<i32>) -> bool {
let mut reversed = xs.clone();
reversed.reverse();
reversed.reverse();
reversed == xs
}
fn prop_add_commutative(a: i32, b: i32) -> bool {
add(a, b) == add(b, a)
}
}
}
Async Testing
With tokio::test
#[tokio::test]
async fn test_fetch_user() {
let client = TestClient::new();
let user = client.get_user(42).await.unwrap();
assert_eq!(user.name, "Alice");
}
#[tokio::test]
async fn test_concurrent_requests() {
let client = TestClient::new();
let (user1, user2) = tokio::join!(
client.get_user(1),
client.get_user(2),
);
assert!(user1.is_ok());
assert!(user2.is_ok());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_with_multi_thread_runtime() {
}
Benchmarks
Using Criterion
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench_parse_json(c: &mut Criterion) {
let input = include_str!("../testdata/sample.json");
c.bench_function("parse_json", |b| {
b.iter(|| parse_json(black_box(input)))
});
}
fn bench_parse_json_sizes(c: &mut Criterion) {
let mut group = c.benchmark_group("parse_json_sizes");
for size in [100, 1000, 10000] {
let input = generate_json(size);
group.bench_with_input(
criterion::BenchmarkId::from_parameter(size),
&input,
|b, input| b.iter(|| parse_json(black_box(input))),
);
}
group.finish();
}
criterion_group!(benches, bench_parse_json, bench_parse_json_sizes);
criterion_main!(benches);
[[bench]]
name = "parsing_bench"
harness = false
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
Running Benchmarks
cargo bench
cargo bench -- parse_json
cargo bench -- --output-format=json | criterion-table
Test Coverage
Using cargo-tarpaulin
cargo install cargo-tarpaulin
cargo tarpaulin --out Html --output-dir coverage/
cargo tarpaulin --out Stdout
cargo tarpaulin --ignore-tests
cargo tarpaulin --packages my-crate --out Lcov
Coverage Targets
| Code Type | Target |
|---|
| Critical business logic | 100% |
| Public APIs | 90%+ |
| General code | 80%+ |
| Generated/derive code | Exclude |
Test Organization
Directory Structure
my-project/
├── src/
│ ├── lib.rs
│ ├── parser.rs # Unit tests at bottom of each file
│ └── models/
│ └── user.rs # Unit tests at bottom
├── tests/ # Integration tests
│ ├── common/
│ │ └── mod.rs # Shared test helpers
│ ├── api_test.rs
│ └── db_test.rs
├── benches/ # Benchmarks
│ └── parsing_bench.rs
└── testdata/ # Test fixtures
├── valid_config.toml
└── sample.json
Test Module Convention
pub fn parse(input: &str) -> Result<Ast> {
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> &'static str {
include_str!("../testdata/sample.txt")
}
#[test]
fn test_parse_valid() {
let ast = parse(sample_input()).unwrap();
assert!(!ast.nodes.is_empty());
}
#[test]
fn test_parse_empty_input() {
assert!(parse("").is_err());
}
}
Test Fixtures and Helpers
Using include_str! / include_bytes!
#[test]
fn test_parse_fixture() {
let input = include_str!("../testdata/valid_config.toml");
let config = Config::parse(input).unwrap();
assert_eq!(config.host, "localhost");
}
Temporary Directories with tempfile
use tempfile::TempDir;
#[test]
fn test_file_operations() -> Result<()> {
let dir = TempDir::new()?;
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, "hello")?;
let content = std::fs::read_to_string(&file_path)?;
assert_eq!(content, "hello");
Ok(())
}
Testing Commands
cargo test
cargo test -- --nocapture
cargo test test_parse
cargo test parser::tests
cargo test --doc
cargo test --test api_test
cargo test -- --test-threads=1
cargo test -- --ignored
cargo test -- --list
cargo tarpaulin
Best Practices
DO:
- Write tests FIRST (TDD)
- Use
#[cfg(test)] to gate test modules
- Test behavior, not implementation details
- Use
assert_eq! with descriptive messages: assert_eq!(got, want, "case: {name}")
- Use
Result return types in tests for cleaner error handling
- Keep test helpers in
#[cfg(test)] modules or tests/common/mod.rs
- Test error paths, not just happy paths
DON'T:
- Use
unwrap() without good reason in tests — prefer ? with Result-returning tests
- Test private implementation details — test through the public API
- Ignore flaky tests — fix or mark with
#[ignore] and document why
- Over-mock — prefer real implementations when feasible
- Skip testing error cases — they're often where bugs hide
Integration with CI/CD
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test --all-features
- name: Run clippy
run: cargo clippy -- -D warnings
- name: Check formatting
run: cargo fmt -- --check
- name: Install cargo-tarpaulin
uses: taiki-e/install-action@cargo-tarpaulin
- name: Coverage
run: cargo tarpaulin --out Xml
Remember: Rust's type system catches many bugs at compile time, but tests verify runtime behavior, edge cases, and integration correctness. Write tests that complement the compiler's guarantees.