Skip to content
intermediate Phase 15 · Testing, Benchmarks, and Profiling

Test Doubles with Traits

Abstract dependencies behind traits and provide in-memory fakes for tests.

40m
0 problems
Topic Progress 0%

Extracting a Trait So Your Storage Layer Can Lie

Your UserService calls a Postgres store, a Slack webhook, and the system clock. Testing the welcome flow now requires a database, a network, and the right time of day — so the test does not get written. The fix, picked up from Integration Tests and Test Layout, is a seam: name the capability as a trait, then let the test provide a different implementation.

pub trait UserStore {
    fn get(&self, id: u64) -> Result<Option<User>, DbError>;
    fn save(&mut self, user: User) -> Result<(), DbError>;
}

pub struct UserService<S: UserStore> {
    store: S,
}

impl<S: UserStore> UserService<S> {
    pub fn new(store: S) -> Self {
        Self { store }
    }

    pub fn rename(&mut self, id: u64, name: String) -> Result<(), DbError> {
        let mut user = self.store.get(id)?.ok_or(DbError::NotFound)?;
        user.name = name;
        self.store.save(user)
    }
}

Production wires in the real thing, tests wire in a fake, and the business logic compiles identically for both:

// main.rs
let service = UserService::new(PostgresStore::new(pool));

// tests/rename.rs
let service = UserService::new(FakeStore::default());

Draw the trait around the need, not the vendor. A trait that mirrors every method of the Postgres client — execute, prepare, transaction — just recreates the vendor's API in your own vocabulary and makes faking it as hard as faking the real thing. Model the capability instead: get, save, find_by_email. The narrower it is, the less a fake must implement, which is why a three-method trait is usually correct where a fifteen-method one is a mistake.

Generic bounds (S: UserStore) are the default choice because they keep static dispatch: the calls inline down to the concrete implementation, so production code pays zero dynamic-dispatch cost and tests pay a cost that does not matter. When you want dyn, you pay a vtable indirection but gain erasure:

pub struct UserService {
    store: Box<dyn UserStore>,
}

Use dyn at composition roots, when storing heterogeneous implementations in one collection, or when monomorphization would duplicate a large body across many types. The trade is binary size and inlining versus flexibility — you can start generic and switch the field to Box<dyn …> without touching the trait.

Extracting the trait also surfaces hidden dependencies you did not know you had. If rename needs "now" for the updated-at stamp, the compiler will refuse until you pass a clock: trait Clock { fn now(&self) -> Instant; } with a SystemClock in production and a FixedClock in tests. Deterministic time and injected randomness are half the reason this pattern exists — they turn "fails after 5 pm" into a test that passes at any hour.

Decision Rule of thumb
Trait granularity one capability per trait, verbs the business understands
S: Trait vs Box<dyn Trait> generic in hot paths, dyn at boundaries
Where the fake lives tests/ or a #[cfg(test)] module, never src/
Vendor trait reuse only if its surface already matches your need

Two constraints shape the trait's signature. It must be object-safe if you ever want Box<dyn UserStore> — so no Self: Sized methods, no generic methods without a dyn-compatible form. And if the dependency is async, async fn in traits works today for the generic form, while the dyn form needs an indirection like async_trait — the compiler will tell you which case you are in, and it is cheaper to discover it at seam-extraction time than during a refactor.

Interviewers ask "how do you unit-test code that talks to a database?" Lead with the trait seam, name the generic-versus-dyn trade-off, and finish with the state-keeping fake you build on top of it — the next chapter, In-Memory Fakes That Keep State. And once timing is out of the way, measuring instead of judging is Benchmarks with Criterion.

In-Memory Fakes That Keep State and Fail on Demand

A mock tells you how many times a method was called. A fake tells you what your system did to the world — and what your test actually cares about is the second one. An in-memory fake is a real, if simplified, implementation of the trait: it keeps state, honors the trait's contract, and lets you assert on outcomes.

#[derive(Default)]
pub struct FakeStore {
    rows: HashMap<u64, User>,
    saves: u32, // interaction counter, for when you do want counts
    fail_next: Option<DbError>, // error injection
}

impl UserStore for FakeStore {
    fn get(&self, id: u64) -> Result<Option<User>, DbError> {
        Ok(self.rows.get(&id).cloned())
    }

    fn save(&mut self, user: User) -> Result<(), DbError> {
        if let Some(err) = self.fail_next.take() {
            return Err(err);
        }
        self.saves += 1;
        self.rows.insert(user.id, user);
        Ok(())
    }
}

Now the test asserts on the observable result — read the state back after the operation:

#[test]
fn rename_persists_the_new_name() {
    let mut store = FakeStore::default();
    store.save(User { id: 1, name: "Ada".into() }).unwrap();

    let mut svc = UserService::new(store);
    svc.rename(1, "Ada L.".into()).unwrap();

    let saved = svc.store().get(1).unwrap().unwrap();
    assert_eq!(saved.name, "Ada L.");
}

Contrast that with the mock style, which would assert expect_save.times(1) — a check that breaks the moment rename legitimately writes through a different internal path, even though the behavior never changed. Assert on state, not on call choreography, unless the interaction itself is the requirement. (The store() accessor in the test above is a two-line &S getter on UserService; a read-only accessor for assertions is a far smaller concession than a mock framework.)

Error injection is where a stateful fake earns its keep. Set fail_next and you can test the path production hits only under duress — a storage failure during save — without a network partition:

#[test]
fn rename_surfaces_storage_failure() {
    let mut store = FakeStore::default();
    store.save(User { id: 1, name: "Ada".into() }).unwrap();
    store.fail_next = Some(DbError::Unavailable);

    let mut svc = UserService::new(store);
    assert!(matches!(svc.rename(1, "Ada L.".into()), Err(DbError::Unavailable)));
}

One obligation, and it is the serious one: your fake must honor the trait's documented contract. If the real store rejects unknown ids with NotFound, the fake must too — otherwise the fake quietly certifies behavior the production system will never exhibit. Write the contract down on the trait:

pub trait UserStore {
    /// Returns `Err(NotFound)` never; returns `Ok(None)` for unknown ids.
    /// Saves are durable: a later `get` observes them.
    fn get(&self, id: u64) -> Result<Option<User>, DbError>;
    fn save(&mut self, user: User) -> Result<(), DbError>;
}
Double Answers Keep it when
Stub returns canned values the output is fixed and irrelevant to the logic
Fake "what state results?" behavior across calls is the thing under test
Mock/Spy "how many times, with what args?" the interaction is the requirement (e.g. exactly one email)
Real thing everything the contract itself must be verified somewhere

That last row is not optional. Keep a thin layer of tests against the real backend — SQLite in-process or Testcontainers in CI — because the fake is only as honest as your reading of the contract, and drift between the two is invisible from inside the test suite.

Fakes are the default; reach for recorded expectations only when counting calls is the actual requirement. How that looks with a purpose-built mock library — and when it becomes a trap — is the next chapter.

Interaction Counts with mockall and the Over-Mocking Trap

Sometimes the interaction is the requirement: an order must trigger exactly one welcome email, addressed to the buyer, before the response returns. No state in the world records that, so you need a double that records calls — which is what mockall provides.

use mockall::automock;

#[automock]
pub trait Notifier {
    fn notify(&self, user: &str, message: &str);
}

#[test]
fn welcomes_a_new_user_once() {
    let mut notifier = MockNotifier::new();
    notifier
        .expect_notify()
        .times(1)
        .withf(|user, msg| user == "ada" && msg.starts_with("Welcome"))
        .returning(|_, _| ());

    let svc = Service::new(Box::new(notifier));
    svc.register("ada").unwrap();
}

#[automock] generates a MockNotifier alongside your trait. expect_notify() opens an expectation; .times(1) sets the call count; .withf constrains the arguments with a closure; .returning supplies the output. The expectation is verified when the mock drops — if register never calls notify, the test fails with 0 when 1 expected.

Where mocks are the right tool

  • Verifying a side effect that leaves no state: exactly one email, one webhook, one metric increment.
  • Spies: let the fake do its normal work while recording what was passed — a fake with a sent: Vec<(String, String)> field is a spy, and often the cleaner shape.
  • Fixed outputs with no state: a stub returning Err(Timeout) to exercise a retry branch.

Where mocks become the bug

Mock-heavy suites fail for a specific reason: they assert on how the code does its work instead of what it produced. The symptoms are recognizable.

Smell What it looks like What to do instead
Refactor-fragile test breaks when you reorder internal calls, behavior unchanged assert on resulting state
Mocking what you own MockUserService inside service.rs tests if you own it, call it; only mock true boundaries
Expectation duplication .withf re-implements the logic under test keep constraints to values a test can state independently
Mock of a mock setup chain longer than the test body one seam, one fake

The rule that prevents most of it: mock the boundary, not the middle. Databases, HTTP clients, clocks, and message brokers are outside your process — mocking them is substitution. Your own struct three layers down is inside your process — mocking it is coupling the test to the implementation.

Two mechanical notes. #[automock] requires owned, Clone-able argument types when you use .returning or .withf on &str-style inputs — trait methods taking &str mock cleanly because closures receive &str directly, while generic methods need the explicit mockall::mock! macro instead of automock. And expectations run on drop, so an unfulfilled expectation fails even if your assertions passed: keep the mock alive until the end of the test rather than dropping it early inside a helper.

A middle ground exists between a hand-written fake and a generated mock: keep the stateful fake from the previous chapter and add a Vec of recorded calls to it — a spy. You get outcome assertions and assert_eq!(fake.sent, 1) without an expectation DSL, and the fake still behaves like the real implementation when the code under test reads state back.

Interview framing: be ready to argue both sides. "When would you not use a mock?" is the question that separates candidates who learned the library from candidates who learned testing — answer with the state-versus-interaction distinction, and mention that your default is an in-memory fake plus assertions on outcomes.

With Integration Tests and Test Layout behind you, the test suite already runs everywhere with no external services — it can do that because the dependencies are behind a seam. The next measurement problem is different in kind: knowing whether your code got slower — Benchmarks with Criterion.

Quiz

1. UserService<S: UserStore> is generic over the store. What is the main production-side advantage of this over Box<dyn UserStore>?

Question 1 options

2. Your trait mirrors every method of the vendor's Postgres client (execute, prepare, begin_transaction, …). What is the main problem?

Question 2 options

3. A test needs to verify that exactly one welcome email was sent and what it contained. What is the most direct double for this?

Question 3 options

4. Which statement best separates when to fake versus when to mock?

Question 4 options

Flashcards

Question

What is a test seam in Rust, and how do you create one for a database dependency?

Answer

A seam is a place where behavior can be substituted without changing the caller. Create one by naming the capability as a small trait (UserStore with get/save), making the service generic over S: UserStore (or Box<dyn UserStore>), and letting construction take the implementation: production passes PostgresStore, tests pass a fake. Business logic compiles unchanged for both.

Question

S: Trait versus Box<dyn Trait> — when do you pick each?

Answer

Generic (S: Trait) gives static dispatch: monomorphized, inlinable, zero vtable cost — the default for hot paths and single-implementation cases. Box<dyn Trait> erases the type: one code path, heterogeneous collections, smaller binary at the price of dynamic dispatch. It requires an object-safe trait (no Self: Sized-only or generic methods).

Question

What makes an in-memory fake trustworthy, and how do you test error paths with one?

Answer

It honors the trait's documented contract (same errors, same durability guarantees as the real implementation) — otherwise it certifies behavior production will never show. Error paths come from injection: a fail_next: Option<DbError> field that the fake returns once, letting a test assert the caller surfaces DbError::Unavailable without any network failure.

Question

When is a mock library the right tool rather than a stateful fake?

Answer

When the interaction itself is the requirement: exactly one email/webhook/metric per event, with argument constraints — mockall's expect_x().times(1).withf(...).returning(...) verifies it on drop. It is the wrong tool when you could assert on resulting state instead, when it mocks a type you own (design smell), or when expectations grow longer than the test.

Question

Why must a few tests still run against the real dependency if you have a fake?

Answer

Because the fake encodes your reading of the contract, and drift is invisible from inside the suite: if the real store starts rejecting empty emails, the fake keeps passing. Keep a thin layer against the real thing (SQLite in-process, Testcontainers in CI) to pin the contract, while the bulk of tests stay fast and deterministic on the fake.

Revision Notes

Key Takeaways

  • 1. A seam is a small trait around the capability you use — modeling the vendor's whole API just recreates its complexity inside every fake
  • 2. Generic bounds keep static dispatch and inlining; Box<dyn Trait> trades a vtable for type erasure — and needs an object-safe trait
  • 3. An in-memory fake must honor the trait contract; a fake that is laxer than production certifies behavior that will never happen
  • 4. Assert on resulting state by default; use mocks only when the interaction (count, arguments) is the actual requirement
  • 5. Keep a thin suite against the real dependency so contract drift between fake and production cannot stay invisible

Interview Tips

  • • "How do you unit-test code that needs a database?" — extract a capability trait, make the service generic over it, inject an in-memory fake; mention you keep a thin real-backend tier to pin the contract
  • • "Mock or fake?" — lead with the state-versus-interaction distinction and give the one-email example; then name the failure mode of each (choreography-asserting tests, contract drift)
  • • "What's the cost of generics over Box<dyn>?" — monomorphization and inlining versus code size and a vtable; mention object-safety constraints and that you can switch the field without touching the trait
  • • "How do you test failure handling without breaking things?" — inject errors through the fake (fail_next) and assert the error propagates; contrast with a stub whose only job is returning Err

Cheat Sheet

Test Doubles with Traits

Seam extraction:

pub trait UserStore {              // capability, not vendor API
    fn get(&self, id: u64) -> Result<Option<User>, DbError>;
    fn save(&mut self, user: User) -> Result<(), DbError>;
}

pub struct UserService<S: UserStore> { store: S }   // static dispatch
// or: store: Box<dyn UserStore>                    // erased, object-safe

let prod = UserService::new(PostgresStore::new(pool));
let test = UserService::new(FakeStore::default());

Which double:

Double Assert on Reach for it when
Stub fixed return output is canned and irrelevant
Fake resulting state behavior across calls is under test
Spy (fake + recording) calls and state you need count + content, refactor-proof
Mock (mockall) interaction counts the call itself is the requirement
Real backend everything pinning the contract (thin, CI-only)

Mock with mockall:

#[automock] pub trait Notifier { fn notify(&self, u: &str, m: &str); }
let mut n = MockNotifier::new();
n.expect_notify().times(1).withf(|u, _| u == "ada")
 .returning(|_, _| ());   // verified when n drops

Smell list: mocking types you own · expectations longer than the test · tests that break when internals are reordered · duplicated setup → move to tests/common/mod.rs.

Continue Learning