Testing Strategies
Prerequisites: Working with databases using SQLx
Level 2 covered the mechanics: #[test], integration tests in tests/, and
doc tests. This lesson is about testing code that talks to the outside world
— databases, email, HTTP — without making every test slow or flaky.
The dev-dependencies used below:
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
mockall = "0.15"
proptest = "1"
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
serde_json = "1"
Depend on traits, fake them in tests
Put each external dependency behind a small trait. Production code gets the real implementation; tests get a hand-written fake that records what happened.
pub trait Mailer {
fn send(&self, to: &str, subject: &str) -> Result<(), String>;
}
pub struct Signup<M: Mailer> {
mailer: M,
}
impl<M: Mailer> Signup<M> {
pub fn new(mailer: M) -> Self {
Signup { mailer }
}
pub fn register(&self, email: &str) -> Result<(), String> {
if !email.contains('@') {
return Err(format!("invalid email: {email}"));
}
self.mailer.send(email, "Welcome!")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[derive(Default)]
struct FakeMailer {
sent: RefCell<Vec<String>>,
}
impl Mailer for &FakeMailer {
fn send(&self, to: &str, _subject: &str) -> Result<(), String> {
self.sent.borrow_mut().push(to.to_string());
Ok(())
}
}
#[test]
fn sends_welcome_mail() {
let mailer = FakeMailer::default();
Signup::new(&mailer).register("ada@example.com").unwrap();
assert_eq!(*mailer.sent.borrow(), ["ada@example.com"]);
}
}
Implementing the trait for &FakeMailer lets the test keep ownership of the
fake and inspect it afterwards. Fakes are plain Rust: easy to read, and they
fail with ordinary assertion messages.
Mocks with mockall
When you need to assert on exact calls (arguments, counts, order), mockall
generates a mock from the trait:
#[cfg_attr(test, mockall::automock)]
pub trait Mailer {
fn send(&self, to: &str, subject: &str) -> Result<(), String>;
}
pub fn notify_all(mailer: &dyn Mailer, users: &[&str]) -> usize {
users.iter().filter(|u| mailer.send(u, "News").is_ok()).count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_successful_sends() {
let mut mock = MockMailer::new();
mock.expect_send()
.withf(|to, _| to == "bad@example.com")
.times(1)
.returning(|_, _| Err("bounced".into()));
mock.expect_send().times(2).returning(|_, _| Ok(()));
let sent = notify_all(&mock, &["a@example.com", "bad@example.com", "b@example.com"]);
assert_eq!(sent, 2);
}
}
Use mocks sparingly. Tests that pin down every call break whenever the implementation changes, even if the behaviour didn't. Fakes that check outcomes age better.
Property-based testing with proptest
Example-based tests check the cases you thought of. Property tests generate hundreds of random inputs and check a rule that must always hold; when one fails, proptest shrinks it to a minimal failing input.
pub fn slugify(s: &str) -> String {
s.split_whitespace()
.map(str::to_lowercase)
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn slug_has_no_whitespace(s in "\\PC*") {
prop_assert!(!slugify(&s).chars().any(char::is_whitespace));
}
#[test]
fn slugify_is_idempotent(s in "[a-zA-Z ]{0,40}") {
let once = slugify(&s);
prop_assert_eq!(slugify(&once), once);
}
}
}
"\\PC*" is a regex strategy meaning "any printable characters". Good
properties: round-trips (decode(encode(x)) == x), idempotence, invariants
(sorted output is sorted), or agreement with a simple reference
implementation.
Testing an Axum router without a server
A Router is a tower::Service. ServiceExt::oneshot sends it a single
request in memory — no port, no network. Using the app() function from the
Axum lesson:
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt; // for .collect()
use tower::ServiceExt; // for .oneshot()
#[tokio::test]
async fn create_then_fetch() {
let state = AppState::default();
let response = app(state.clone())
.oneshot(
Request::post("/todos")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"test"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let response = app(state)
.oneshot(Request::get("/todos/1").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["title"], "test");
}
}
#[tokio::test] is #[test] plus a fresh runtime per test. Because the
state is passed in, each test builds its own isolated app.
For database code, test against a real database rather than mocking SQL.
SQLx's #[sqlx::test] creates a fresh test database per test and applies
your migrations.
Common mistakes
- Mocking everything. Mock at the boundary (network, clock, email), not between your own modules.
- Sharing global state between tests.
cargo testruns tests in parallel threads; a static counter or a fixed port makes tests flaky. - Using
std::thread::sleepto "wait for" async work. Await the result or usetokio::time::pause()to control time in tests. - Testing private implementation details so every refactor breaks the suite.
Practice
Write a proptest checking that for any Vec<i32>, sorting it produces a
vector of the same length where every element is <= the next.
Show solution
#[cfg(test)]
mod tests {
use proptest::prelude::*;
proptest! {
#[test]
fn sort_is_ordered(mut v in prop::collection::vec(any::<i32>(), 0..100)) {
let len = v.len();
v.sort();
prop_assert_eq!(v.len(), len);
prop_assert!(v.windows(2).all(|w| w[0] <= w[1]));
}
}
}
prop::collection::vec(any::<i32>(), 0..100) generates vectors of up to 99
random integers. Try breaking the test on purpose — comment out
v.sort() — and proptest reports a shrunk minimal failing input such as
[0, -1].