Skip to content
AgileLogics
8 min read

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 test runs tests in parallel threads; a static counter or a fixed port makes tests flaky.
  • Using std::thread::sleep to "wait for" async work. Await the result or use tokio::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].