Files
atelier/languages/rs-testing.md
T
Jon Chery 4e433158cd docs(P03): complete language-derived extension — v0.4
---ci---
project: atelier
phase: 3
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.3
requirements:
  covered: [ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105]
  partial: []
---/ci---
2026-08-05 16:07:21 +00:00

7.2 KiB

Rust Testing — Derived Application

Applies Atelier's domain principles to Rust testing specifically. Derives from domains/ docs; introduces no new P-rules (D-063). See languages/rust.md for the language first-principles stub.

#[test] and Co-located Tests (Testing P1 Tests as Specification, C2 Clarity)

  • #[test] on functions in a #[cfg(test)] mod tests block: tests co-located with source, compiled only in cargo test. A test file far from its subject rots (Documentation P5 Discoverability).
  • Test names read as a spec: fn create_user_rejects_invalid_email() — a reader understands the unit from the name. Avoid fn test_user_1().
  • assert! / assert_eq! / assert_ne! over raw panic!: the macros produce readable failure output (assertion failed: left == right, left: 5, right: 3). Raw panic! gives a message only (Testing P6 Failure Specificity).
  • Applies Testing P1: the test is a specification; the failure message is the spec violation.
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn create_user_rejects_invalid_email() {
        let r = create_user("not-an-email");
        assert!(matches!(r, Err(Error::Validation(_))));
    }

    #[test]
    fn create_user_returns_persisted_id() {
        let u = create_user("a@b.co").unwrap();
        assert!(!u.id.is_empty());
    }
}

proptest and Property Tests (Testing P9 Edge Case Coverage, Testing P1 Tests as Specification)

  • proptest (or quickcheck) for invariant tests: declare a property (parse(serialize(x)) == x), the framework generates hundreds of inputs and shrinks failures to a minimal counterexample (Testing P9).
  • Strategy over hand-written generators: proptest::collection::vec(any::<u32>(), 0..100) generates arbitrary Vec<u32>; do not hand-roll a generator for each property.
  • proptest! macro or proptest! { ... } block: each case (name) => { ... } is a property. The block is the spec (Testing P1).
  • Property tests complement, not replace, example tests: examples document the happy path; properties cover the edge space. Both are required.
use proptest::prelude::*;

proptest! {
    #[test]
    fn roundtrips_id(s in "[a-z0-9]{1,32}") {
        let id = UserId::new(&s).unwrap();
        assert_eq!(id.as_str(), s);
    }

    #[test]
    fn rejects_invalid_id(s in "[^a-z0-9]+") {
        assert!(UserId::new(&s).is_err());
    }
}

Mock Discipline (Testing P2 Independence, Testing P7 Realism)

  • Mock at the trait, not the struct: trait Store { fn get(&self, id: &str) -> Result<User, Error>; } in production; #[automock] trait Store (via mockall) in test. The trait is the contract.
  • mockall for generated mocks: #[automock] trait Repo {} generates MockRepo with expect_* methods. Each expectation is per-test; no shared mock state (Testing P2 Independence).
  • Mock the boundary, not the unit: mock Repo, not UserService (the unit). Mocking the unit under test tests the mock (Testing P7 realism).
  • No #[cfg(test)] on production code paths to inject mocks: instead, accept the trait as a generic or dyn parameter. Test-only branches in production code are dead code in prod.
use mockall::*;

#[automock]
trait UserRepo {
    fn get(&self, id: &str) -> Result<User, Error>;
}

#[test]
fn get_user_returns_not_found() {
    let mut repo = MockUserRepo::new();
    repo.expect_get()
        .with(eq("abc"))
        .returning(|_| Err(Error::NotFound));
    let svc = UserService::new(Box::new(repo));
    assert!(matches!(svc.get_user("abc"), Err(Error::NotFound)));
}

Async Tests (Concurrency P10 Test for Race Conditions, Testing P1 Tests as Specification)

  • #[tokio::test] for async fn tests: runs the coroutine on a tokio runtime. Without it, an async fn test returns a future, never awaited (silently passes).
  • #[tokio::test(flavor = "multi_thread")] for concurrency-sensitive tests: multi-thread runtime surfaces races that single-thread misses (Concurrency P10).
  • tokio::time::pause() and advance() for time: freeze and advance the runtime clock deterministically. No tokio::time::sleep(real) in tests.
  • Race-sensitive tests use loom for model-checking: loom simulates all thread interleavings; it catches races -race-style detectors miss. Use for lock-free data structures.
#[tokio::test]
async fn async_fetch_returns_user() {
    let u = fetch_user("abc").await.unwrap();
    assert!(!u.name.is_empty());
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_cache_is_safe() {
    let c = Arc::new(Cache::new());
    let mut h = vec![];
    for i in 0..10 {
        let c = c.clone();
        h.push(tokio::spawn(async move { c.get(&i.to_string()).await; }));
    }
    for x in h { x.await.unwrap(); }
}

Determinism and Time (Testing P3 Determinism, Testing P9 Edge Case Coverage)

  • No SystemTime::now() or Instant::now() in code under test: inject a Clock trait. In tests, a fake clock advances deterministically.
  • tokio::time::pause() for async time: freezes the runtime clock; tokio::time::advance(dur) moves it. A sleep(5s) in test resolves instantly.
  • --test-threads=1 to reproduce order coupling: by default, cargo test runs tests in parallel; a test that passes alone but fails in a suite has hidden shared state. -1 reproduces.
trait Clock { fn now(&self) -> std::time::Instant; }

struct FakeClock(std::time::Instant);
impl Clock for FakeClock {
    fn now(&self) -> std::time::Instant { self.0 }
}

#[test]
fn user_has_created_at() {
    let clk = FakeClock(std::time::Instant::now());
    let u = create_user_with_clock("a@b.co", &clk).unwrap();
    assert_eq!(u.created_at, clk.now());
}

Doc Tests (Documentation P1 Documentation is Code, Testing P1 Tests as Specification)

  • cargo test --doc runs /// fenced blocks: a /// example with #-hidden setup is a tested artifact; a stale output fails the build (Documentation P1).
  • no_run for examples that should compile but not run: ```rust,no_run — type-checks the example without executing. Use for examples that need a DB.
  • ignore for examples that should not compile-check: ```rust,ignore — skips entirely. Rare; prefer no_run.
  • Applies Testing P1: the doc example is the spec; the doc test is the spec's regression test.
/// Fetch a user by id.
///
/// # Example
///
/// ```
/// # use mycrate::{get_user, Error};
/// let u = get_user("abc").unwrap();
/// assert!(!u.name.is_empty());
/// ```
pub fn get_user(id: &str) -> Result<User, Error> { /* ... */ }

Cross-References

  • domains/testing/pyramid.md — where unit/property/doc tests sit; proptest is the property layer.
  • domains/testing/fixtures.mdt.Cleanup-equivalent (Drop in tests) as fixture discipline.
  • domains/testing/first-principles.md — Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage.
  • domains/concurrency/first-principles.md — Concurrency P10 (test for races), loom model-checking.
  • languages/rs-ownership.mdSend/Sync tests and ownership-based property tests.
  • languages/rs-async.md#[tokio::test] patterns from that doc.
  • languages/rs-tooling.mdcargo test flags (--doc, --test-threads) detailed here.