| name | rust-testing |
| description | Rust testing patterns — unit tests with mockall, integration tests with sqlx transactions, HTTP handler testing (axum), benchmarks (criterion), property tests (proptest), fuzzing, and CI with cargo-nextest. |
Rust Testing Patterns
Comprehensive testing strategies for Rust applications following TDD methodology.
When to Activate
- Writing unit tests with mock dependencies
- Testing database-dependent code with sqlx
- Testing axum HTTP handlers
- Setting up benchmarks with criterion
- Configuring cargo-nextest in CI/CD
- Using
mockall::automock to generate mock implementations from trait definitions
- Writing property-based tests with
proptest to verify invariants across randomly generated inputs
- Isolating integration tests using
#[sqlx::test] for automatic per-test transaction rollback
TDD in Rust
RED → Write a failing #[test]
GREEN → Write minimal implementation
REFACTOR → Improve while keeping tests green
Unit Tests (Co-located)
The idiomatic Rust approach: unit tests live in the same file, in a #[cfg(test)] module.
pub fn apply_discount(price: f64, tier: CustomerTier) -> f64 {
match tier {
CustomerTier::Standard => price,
CustomerTier::Silver => price * 0.95,
CustomerTier::Gold => price * 0.90,
CustomerTier::Platinum => price * 0.80,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_tier_no_discount() {
assert_eq!(apply_discount(100.0, CustomerTier::Standard), 100.0);
}
#[test]
fn platinum_tier_20_percent_off() {
assert_eq!(apply_discount(100.0, CustomerTier::Platinum), 80.0);
}
#[test]
fn discount_rounds_correctly() {
let result = apply_discount(33.33, CustomerTier::Gold);
assert!((result - 30.0).abs() < 0.01);
}
}
Mocking with mockall
[dev-dependencies]
mockall = "0.13"
use mockall::automock;
#[automock]
#[async_trait::async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: i64) -> Result<Option<User>, DbError>;
async fn save(&self, user: &NewUser) -> Result<User, DbError>;
async fn delete(&self, id: i64) -> Result<(), DbError>;
}
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
#[tokio::test]
async fn register_user_saves_and_returns_user() {
let mut mock = MockUserRepository::new();
mock.expect_save()
.with(predicate::function(|u: &NewUser| u.email == "alice@test.com"))
.times()
.(|u| (User { id: , email: u.email.(), name: u.name.() }));
= UserService::(Arc::(mock));
= service.(, )..();
(user.id, );
(user.email, );
}
() {
= MockUserRepository::();
mock.()
.()
.(|_| (DbError::ConnectionFailed));
= UserService::(Arc::(mock));
= service.(, ).;
(result.());
}
}
mockall Predicates
use mockall::predicate::*;
.with(eq(42))
.with(eq("hello"))
.with(function(|x: &i32| *x > 0))
.with(str::contains("@"))
.with(eq(1), eq("name"))
.with(always())
.times(1)
.times(2..=5)
.once()
.never()
Integration Tests with sqlx
[dev-dependencies]
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "macros", "migrate"] }
tokio = { version = "1", features = ["full"] }
use sqlx::PgPool;
async fn setup_db() -> PgPool {
let url = std::env::var("TEST_DATABASE_URL")
.expect("TEST_DATABASE_URL must be set for integration tests");
let pool = PgPool::connect(&url).await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
pool
}
#[sqlx::test]
async fn find_user_by_id_returns_none_when_not_found(pool: PgPool) {
let repo = PostgresUserRepo::new(pool);
let result = repo.find_by_id(9999).await.unwrap();
assert!(result.is_none());
}
#[sqlx::test]
async fn save_and_find_by_id(pool: PgPool) {
let repo = PostgresUserRepo::new(pool);
let = repo.(&NewUser {
name: .(),
email: .(),
})..();
(saved.id > );
= repo.(saved.id)..();
(found.().email, );
}
(pool: PgPool) {
= PostgresUserRepo::(pool);
= repo.(&NewUser { name: .(), email: .() })
..();
repo.(user.id)..();
= repo.(user.id)..();
(result.());
}
HTTP Handler Testing (axum)
use axum::{
body::Body,
http::{Request, StatusCode},
};
use tower::ServiceExt;
use serde_json::{json, Value};
fn test_app() -> axum::Router {
let state = AppState {
repo: Arc::new(InMemoryUserRepo::new()),
config: Arc::new(Config::test()),
};
router(state)
}
#[tokio::test]
async fn get_user_returns_200() {
let app = test_app();
let create_resp = app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/users")
.header("content-type", "application/json")
.body(Body::from(json!({"name": "Alice", "email": "a@test.com"}).to_string()))
.unwrap()
)
.await
.();
(create_resp.(), StatusCode::CREATED);
: Value = serde_json::(
&axum::body::(create_resp.(), ::MAX)..()
).();
= body[].().();
= app
.(
Request::()
.(())
.(Body::())
.()
)
.
.();
(response.(), StatusCode::OK);
: Value = serde_json::(
&axum::body::(response.(), ::MAX)..()
).();
(body[], );
}
() {
= ();
= app
.(Request::().().(Body::()).())
.
.();
(response.(), StatusCode::NOT_FOUND);
}
Property-Based Tests (proptest)
[dev-dependencies]
proptest = "1"
use proptest::prelude::*;
proptest! {
#[test]
fn email_roundtrip(
local in "[a-z]{1,20}",
domain in "[a-z]{2,10}"
) {
let raw = format!("{local}@{domain}.com");
let email = Email::parse(&raw).unwrap();
assert_eq!(email.as_str(), raw);
}
#[test]
fn sort_is_ordered(mut values: Vec<i32>) {
values.sort();
for i in 1..values.len() {
assert!(values[i-1] <= values[i]);
}
}
#[test]
fn discount_never_exceeds_price(price in 0.01f64..1_000_000.0) {
let discounted = apply_discount(price, CustomerTier::Platinum);
assert!(discounted <= price);
assert!(discounted >= );
}
}
Benchmarks (criterion)
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "my_bench"
harness = false
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
fn bench_sort(c: &mut Criterion) {
let mut group = c.benchmark_group("sort");
for size in [10, 100, 1000, 10_000].iter() {
group.bench_with_input(BenchmarkId::from_parameter(size), size, |b, &size| {
let data: Vec<i32> = (0..size).rev().collect();
b.iter(|| {
let mut v = data.clone();
v.sort();
black_box(v)
});
});
}
group.finish();
}
fn bench_string_format(c: &mut Criterion) {
c.bench_function("format_email", |b| {
b.iter(|| format!("{}@{}.com", black_box("alice"), black_box()))
});
}
criterion_group!(benches, bench_sort, bench_string_format);
criterion_main!(benches);
cargo bench
cargo bench -- bench_sort
cargo bench -- --save-baseline before
cargo bench -- --baseline before
Test Organization
src/
lib.rs # #[cfg(test)] mod tests { } — unit tests co-located
domain/
user.rs # unit tests inside
order.rs
tests/ # Integration tests — only use public API
common/
mod.rs # Shared helpers: setup_db(), build_app()
user_api.rs # Full HTTP roundtrip tests
user_repo.rs # Repository integration tests
benches/ # criterion benchmarks
throughput.rs
Shared Test Helpers
use sqlx::PgPool;
pub async fn test_pool() -> PgPool {
let url = std::env::var("TEST_DATABASE_URL").unwrap();
let pool = PgPool::connect(&url).await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
pool
}
pub fn test_user() -> NewUser {
NewUser {
name: "Test User".to_string(),
email: format!("test-{}@example.com", uuid::Uuid::new_v4()),
}
}
CLI Quick Reference
cargo test
cargo test test_name
cargo test domain::
cargo test -- --nocapture
cargo test -- --ignored
cargo test -- --test-threads=4
cargo nextest run
cargo nextest run --test-threads=8
cargo llvm-cov
cargo llvm-cov --html
cargo fuzz add fuzz_target_1
cargo fuzz run fuzz_target_1
cargo bench
CI/CD with cargo-nextest
- name: Install nextest
uses: taiki-e/install-action@nextest
- name: Run tests
run: cargo nextest run --profile ci
- name: Run benchmarks (verify compile)
run: cargo bench --no-run
[profile.ci]
fail-fast = false
test-threads = "num-cpus"
status-level = "fail"
Quick Reference
| Scenario | Tool/Pattern |
|---|
| Unit test | #[test] in #[cfg(test)] module |
| Async test | #[tokio::test] |
| Mock trait | mockall::automock |
| DB integration | #[sqlx::test] (isolated transaction) |
| HTTP handler | axum + tower::ServiceExt::oneshot |
| Property test | proptest! macro |
| Coverage | cargo llvm-cov |
For anti-patterns and common mistakes, see skill rust-testing-advanced.