4e433158cd
---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---
159 lines
7.2 KiB
Markdown
159 lines
7.2 KiB
Markdown
# 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.
|
|
|
|
```rust
|
|
#[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.
|
|
|
|
```rust
|
|
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.
|
|
|
|
```rust
|
|
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.
|
|
|
|
```rust
|
|
#[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.
|
|
|
|
```rust
|
|
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.
|
|
|
|
```rust
|
|
/// 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.md` — `t.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.md` — `Send`/`Sync` tests and ownership-based property tests.
|
|
- `languages/rs-async.md` — `#[tokio::test]` patterns from that doc.
|
|
- `languages/rs-tooling.md` — `cargo test` flags (`--doc`, `--test-threads`) detailed here. |