Mocking
25 min read
In Jest or Vitest you reach for vi.fn(), jest.mock(), or a manual fake whenever a unit under test depends on a database, an HTTP call, or the clock. Rust has no module-level monkey-patching — there is no jest.mock("./db") that rewrites imports at load time. Instead, Rust pushes you toward a cleaner pattern you already know from TypeScript: program against a trait (interface), inject the dependency, and substitute a test double. For the boilerplate-heavy cases, the mockall crate generates the double for you.
Quick Overview
Section titled “Quick Overview”A mock is a stand-in for a real dependency that lets a test run fast, deterministically, and offline — and lets you assert how the dependency was used. In Rust the idiomatic foundation is dependency injection through a trait: your code accepts something that implements a trait, and tests pass either a small hand-written struct or a mockall-generated Mock… type. Unlike Vitest’s vi.mock, nothing is patched at runtime; the substitution happens through Rust’s ordinary type system, which means the compiler checks your test doubles just like production code.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”A WelcomeService that looks up a user’s email and sends a welcome message. The two collaborators — the repository and the mailer — are described by interfaces, then mocked with Vitest’s vi.fn().
export interface EmailSender { send(to: string, subject: string, body: string): Promise<string>;}
export interface UserRepo { findEmail(userId: number): Promise<string | null>;}
export class WelcomeService { constructor( private readonly repo: UserRepo, private readonly mailer: EmailSender, ) {}
async welcome(userId: number): Promise<string> { const email = await this.repo.findEmail(userId); if (email === null) { throw new Error(`no email for user ${userId}`); } return this.mailer.send(email, "Welcome!", "Thanks for joining."); }}import { describe, it, expect, vi } from "vitest";import { WelcomeService, type EmailSender, type UserRepo } from "./welcome";
describe("WelcomeService", () => { it("emails a known user", async () => { const repo: UserRepo = { findEmail: vi.fn().mockResolvedValue("ada@x.com") }; const mailer: EmailSender = { send: vi.fn().mockResolvedValue("msg-1") };
const svc = new WelcomeService(repo, mailer); const id = await svc.welcome(42);
expect(id).toBe("msg-1"); expect(mailer.send).toHaveBeenCalledWith( "ada@x.com", "Welcome!", "Thanks for joining.", ); expect(mailer.send).toHaveBeenCalledTimes(1); });
it("does not send when the user is unknown", async () => { const repo: UserRepo = { findEmail: vi.fn().mockResolvedValue(null) }; const mailer: EmailSender = { send: vi.fn() };
const svc = new WelcomeService(repo, mailer); await expect(svc.welcome(7)).rejects.toThrow("no email for user 7"); expect(mailer.send).not.toHaveBeenCalled(); });});Running npx vitest run:
RUN v4.1.7
Test Files 1 passed (1) Tests 2 passed (2)Three things a JavaScript developer leans on here:
- Interfaces describe the collaborators (
EmailSender,UserRepo). vi.fn()builds a fake function with a canned return value (mockResolvedValue).- The same fake records calls, so
toHaveBeenCalledWith/toHaveBeenCalledTimes/not.toHaveBeenCalledverify the interaction.
Rust keeps the first idea wholesale, replaces vi.fn() with a generated mock, and — crucially — checks expectations at compile time and at the moment the mock is dropped.
Rust Equivalent
Section titled “Rust Equivalent”The same service, with EmailSender and UserRepo as traits and the mocks generated by mockall’s #[automock]. Add the crate as a dev-dependency so it never ships in your release binary:
cargo add mockall --dev[dev-dependencies]mockall = "0.14.0"//! A welcome-email service with two injected dependencies, mocked in tests.
/// Sends transactional email. The real implementation talks to a provider.#[cfg_attr(test, mockall::automock)]pub trait EmailSender { /// Returns the provider's message id on success. fn send(&self, to: &str, subject: &str, body: &str) -> Result<String, String>;}
/// Reads user contact details from storage.#[cfg_attr(test, mockall::automock)]pub trait UserRepo { fn find_email(&self, user_id: u64) -> Result<Option<String>, String>;}
/// Orchestrates the welcome flow. Generic over its dependencies so the/// concrete types (real or mock) are chosen by the caller.pub struct WelcomeService<R, S> { repo: R, mailer: S,}
impl<R: UserRepo, S: EmailSender> WelcomeService<R, S> { pub fn new(repo: R, mailer: S) -> Self { WelcomeService { repo, mailer } }
/// Look up the user's email and send the welcome message. pub fn welcome(&self, user_id: u64) -> Result<String, String> { let email = self .repo .find_email(user_id)? .ok_or_else(|| format!("no email for user {user_id}"))?;
self.mailer.send(&email, "Welcome!", "Thanks for joining.") }}
#[cfg(test)]mod tests { use super::*; use mockall::predicate::*;
#[test] fn emails_a_known_user() { let mut repo = MockUserRepo::new(); repo.expect_find_email() .with(eq(42u64)) .times(1) .returning(|_| Ok(Some("ada@example.com".to_string())));
let mut mailer = MockEmailSender::new(); mailer .expect_send() .with( eq("ada@example.com"), eq("Welcome!"), eq("Thanks for joining."), ) .times(1) .returning(|_, _, _| Ok("msg-1".to_string()));
let svc = WelcomeService::new(repo, mailer); assert_eq!(svc.welcome(42), Ok("msg-1".to_string())); }
#[test] fn does_not_send_when_user_is_unknown() { let mut repo = MockUserRepo::new(); repo.expect_find_email().returning(|_| Ok(None));
let mut mailer = MockEmailSender::new(); // No call expected; `never()` makes the intent explicit and fails loudly. mailer.expect_send().never();
let svc = WelcomeService::new(repo, mailer); assert_eq!(svc.welcome(7), Err("no email for user 7".to_string())); }}Running cargo test:
running 2 teststest tests::does_not_send_when_user_is_unknown ... oktest tests::emails_a_known_user ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe shape mirrors the Vitest version almost line for line: with(...) is toHaveBeenCalledWith, times(1) is toHaveBeenCalledTimes(1), never() is not.toHaveBeenCalled(), and returning(...) is mockResolvedValue. The big difference is when mismatches are caught — see Detailed Explanation.
Detailed Explanation
Section titled “Detailed Explanation”#[cfg_attr(test, mockall::automock)] — generate a mock, only in test builds
Section titled “#[cfg_attr(test, mockall::automock)] — generate a mock, only in test builds”#[cfg_attr(test, mockall::automock)]pub trait EmailSender { fn send(&self, to: &str, subject: &str, body: &str) -> Result<String, String>;}#[automock] is a procedural macro that reads a trait and emits a struct named Mock<TraitName> (here MockEmailSender) that implements the trait. Wrapping it in #[cfg_attr(test, ...)] means “apply this attribute only when compiling tests.” In a normal cargo build, the macro does not run, the MockEmailSender type does not exist, and mockall is not linked — which is exactly why it belongs in [dev-dependencies]. (#[cfg_attr] and #[cfg(test)] are the same conditional-compilation machinery introduced in Unit Tests; attributes themselves are covered in Macros.)
Note:
#[automock]is the closest Rust gets to Jest’s “auto-mock a whole module.” But it operates on one trait, not a file of exports, and it generates a concrete type you instantiate explicitly — there is no hidden module rewriting.
MockEmailSender::new() and expect_* — programming the double
Section titled “MockEmailSender::new() and expect_* — programming the double”let mut mailer = MockEmailSender::new();mailer .expect_send() .with(eq("ada@example.com"), eq("Welcome!"), eq("Thanks for joining.")) .times(1) .returning(|_, _, _| Ok("msg-1".to_string()));For every trait method send, #[automock] generates a matching expect_send() builder. Each call to expect_send() creates one expectation — a rule describing what arguments to match, how many times it may be called, and what to return:
.with(...)sets argument matchers, one per parameter, using predicates frommockall::predicate(eq,gt,ge,function,always, …). It is the analogue oftoHaveBeenCalledWith, except it is enforced as the call happens..times(1)sets the call-count expectation (also.times(0..=3),.never(),.once())..returning(closure)supplies the canned result. The closure receives the same arguments the method was called with, so the return value can depend on the input.
The mock must be mut because each expect_* mutates its internal expectation list.
eq(42u64) — predicates and the u64 suffix
Section titled “eq(42u64) — predicates and the u64 suffix”repo.expect_find_email().with(eq(42u64)).times(1).returning(|_| Ok(Some("ada@example.com".to_string())));eq builds a predicate that matches by ==. The u64 suffix on 42 is not decorative: find_email takes a u64, and without the suffix Rust would default the literal to i32, so eq(42) would be eq(42i32) and fail to type-check against a u64 parameter. This is the kind of static check that has no equivalent in vi.fn(), where a number is just a number.
returning(|_, _, _| ...) — closures stand in for the implementation
Section titled “returning(|_, _, _| ...) — closures stand in for the implementation”The closure passed to .returning takes one parameter per method argument; here we ignore all three with _. The closure must satisfy Send because mockall mocks are thread-safe by default (so they work in Rust’s parallel test harness). If you try to capture a non-Send type like Rc<…>, the compiler rejects it — reach for Arc and an atomic instead (shown in Best Practices).
Verification happens at drop, not at an explicit assert
Section titled “Verification happens at drop, not at an explicit assert”This is the single most important mental-model shift from Jest/Vitest. In Vitest you pull facts out of a spy after the fact: expect(mailer.send).toHaveBeenCalledTimes(1). In mockall, the count is part of the expectation, and it is checked automatically when the mock value goes out of scope (its Drop runs). If times(1) was set but send was never called, the destructor panics and the test fails — even though there is no assertion line. There is nothing to remember to write; conversely, a mock you build but never use will still verify itself.
Tip: Because verification is tied to drop, keep mocks owned by the test (or by the service the test owns). The
WelcomeService::new(repo, mailer)call moves both mocks into the service, and when the service drops at the end of the test, the mocks drop with it and verify.
Key Differences
Section titled “Key Differences”| Concept | Jest / Vitest | Rust (mockall) |
|---|---|---|
| What you mock | a module, function, or object via vi.mock / vi.fn | a trait, via #[automock] or mock! |
| Substitution mechanism | runtime monkey-patching of imports | the type system — you pass a different concrete type |
| Build cost in production | the test framework is a dependency | [dev-dependencies]; zero code in release builds |
| Canned return | mockFn.mockResolvedValue(x) | `.returning( |
| Argument check | expect(fn).toHaveBeenCalledWith(a) (after the fact) | .with(eq(a)) (enforced at call time) |
| Call-count check | toHaveBeenCalledTimes(n) (after the fact) | .times(n) (verified on drop) |
| Call ordering | manual / mock.calls inspection | Sequence makes ordering a first-class expectation |
| Unexpected call | returns undefined, test may pass silently | panics immediately: “No matching expectation found” |
| Type safety of the double | the fake can drift from the interface | the mock implements the trait; drift is a compile error |
Two consequences stand out.
Mocks are strict by default. A Vitest vi.fn() with no implementation returns undefined and the test often limps on. A mockall mock with no matching expectation panics. That strictness catches “my code called the dependency in a way I did not anticipate” bugs that silently pass in JavaScript.
You design for mocking up front. Because there is no runtime patching, code that hard-codes reqwest::get(...) or SystemTime::now() is hard to mock. The Rust answer is to depend on a trait and inject it — which is exactly the dependency-inversion discipline good TypeScript already follows, now enforced by the language.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: An unfulfilled times expectation fails at drop, not at the call site
Section titled “Pitfall 1: An unfulfilled times expectation fails at drop, not at the call site”If you declare times(1) but never invoke the method, the failure surfaces when the mock is dropped — with a message that points at the trait, not at any line you wrote:
#[cfg_attr(test, mockall::automock)]pub trait Repo { fn save(&self, id: u64) -> Result<(), String>;}
#[cfg(test)]mod tests { use super::*;
#[test] fn missing_expected_call_fails() { let mut repo = MockRepo::new(); repo.expect_save().times(1).returning(|_| Ok(())); // We never call repo.save(...), so the `times(1)` is violated. test fails }}Real output from cargo test:
thread 'tests::missing_expected_call_fails' panicked at src/lib.rs:1:18:MockRepo::save: Expectation(<anything>) called 0 time(s) which is fewer than expected 1note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceThe src/lib.rs:1:18 location is the #[automock] attribute, because that is where the generated Drop code lives. Read the message, not the line: it names the method (MockRepo::save) and the count mismatch.
Pitfall 2: Calling a method with no matching expectation
Section titled “Pitfall 2: Calling a method with no matching expectation”A mockall mock will not improvise. Call a method you did not set up — or call it with arguments your .with predicate rejects — and it panics:
#[cfg_attr(test, mockall::automock)]pub trait Repo { fn save(&self, id: u64) -> Result<(), String>;}
#[cfg(test)]mod tests { use super::*;
#[test] fn calling_with_no_expectation_set() { let repo = MockRepo::new(); let _ = repo.save(1); // no expect_save() was set up }}Real output:
thread 'tests::calling_with_no_expectation_set' panicked at src/lib.rs:1:18:MockRepo::save(1): No matching expectation foundThe same No matching expectation found appears when you set expect_save().with(eq(1u64)) but the code calls save(999). Coming from Vitest — where an un-stubbed vi.fn() quietly returns undefined — this strictness is a feature: it tells you precisely which interaction you forgot to model.
Pitfall 3: Putting #[automock] on the trait unconditionally
Section titled “Pitfall 3: Putting #[automock] on the trait unconditionally”It is tempting to write a bare #[mockall::automock]. That generates MockRepo in every build, links mockall into your release binary, and adds Mock… types to your public API:
#[mockall::automock] // generated even in `cargo build --release`pub trait Repo { fn save(&self, id: u64) -> Result<(), String>;}Gate it with #[cfg_attr(test, mockall::automock)] so the mock exists only under cargo test, matching the [dev-dependencies] placement. (If a downstream crate’s tests need your mock, that is the one case for an unconditional #[automock] plus a normal dependency — but it is the exception.)
Pitfall 4: Trying to mock a concrete type or an external trait with #[automock]
Section titled “Pitfall 4: Trying to mock a concrete type or an external trait with #[automock]”#[automock] only works on a trait you are defining (you have to put the attribute on it). You cannot annotate reqwest::Client or a trait from another crate. For those, use mockall’s mock! macro, which generates a mock for a struct you describe inline (shown in Best Practices). The general lesson: if a dependency is a concrete type with no trait, introduce a trait of your own and depend on that — your code becomes both testable and decoupled from the vendor.
Pitfall 5: Capturing a non-Send value in returning
Section titled “Pitfall 5: Capturing a non-Send value in returning”// inside a testuse std::rc::Rc;use std::cell::Cell;let calls = Rc::new(Cell::new(0));let c = Rc::clone(&calls);gw.expect_charge().returning(move |_, _| { c.set(c.get() + 1); Ok(()) });// does not compile (error[E0277]): `Rc<Cell<i32>>` is not `Send`The real error names the bound directly:
error[E0277]: `Rc<Cell<u32>>` cannot be shared between threads safely = help: within `{closure@src/lib.rs:...}`, the trait `Send` is not implemented for `Rc<Cell<u32>>`note: required by a bound in `__charge::Expectation::returning`mockall requires the closure to be Send so mocks can be used across the test harness’s threads. Swap Rc<Cell<T>> for Arc<AtomicU32> (or Arc<Mutex<T>>) — the thread-safe versions covered in Concurrency.
Best Practices
Section titled “Best Practices”Prefer a hand-written test double for simple traits
Section titled “Prefer a hand-written test double for simple traits”You do not need mockall for everything. When a trait has one or two methods and you only need a canned answer, a plain struct is clearer and adds no dependency. This is the direct equivalent of writing a small fake object literal in TypeScript instead of reaching for vi.fn().
/// Anything that can tell the current Unix time. Abstracting the clock/// makes time-dependent logic testable without sleeping or freezing.pub trait Clock { fn now_unix(&self) -> u64;}
/// Production implementation reads the real system clock.pub struct SystemClock;
impl Clock for SystemClock { fn now_unix(&self) -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() }}
/// A token is valid until `issued_at + ttl_secs`.pub fn token_is_valid(clock: &impl Clock, issued_at: u64, ttl_secs: u64) -> bool { clock.now_unix() <= issued_at + ttl_secs}
#[cfg(test)]mod tests { use super::*;
/// Hand-written stub: a clock frozen at a fixed instant. struct FixedClock { now: u64, }
impl Clock for FixedClock { fn now_unix(&self) -> u64 { self.now } }
#[test] fn token_within_ttl_is_valid() { let clock = FixedClock { now: 1_000 }; assert!(token_is_valid(&clock, 900, 200)); // expires at 1100 }
#[test] fn expired_token_is_invalid() { let clock = FixedClock { now: 2_000 }; assert!(!token_is_valid(&clock, 900, 200)); }}running 2 teststest tests::expired_token_is_invalid ... oktest tests::token_within_ttl_is_valid ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sTip: The terminology is worth knowing. A stub returns canned data and asserts nothing about how it was called (like
FixedClock). A spy records calls so the test can verify them. A mock has built-in expectations that self-verify (like themockalltypes).mockallgives you mocks; a hand-written struct can be any of the three.
Use a spy when you only need to count calls
Section titled “Use a spy when you only need to count calls”When the interesting fact is “was this called, and how often,” a small spy with interior mutability beats a full mock framework:
use std::cell::RefCell;
pub trait Clock { fn now_unix(&self) -> u64;}
pub fn token_is_valid(clock: &impl Clock, issued_at: u64, ttl_secs: u64) -> bool { clock.now_unix() <= issued_at + ttl_secs}
#[cfg(test)]mod tests { use super::*;
/// Hand-written spy: counts how many times the clock was read. #[derive(Default)] struct SpyClock { calls: RefCell<u32>, fixed: u64, }
impl Clock for SpyClock { fn now_unix(&self) -> u64 { *self.calls.borrow_mut() += 1; self.fixed } }
#[test] fn clock_is_read_exactly_once() { let clock = SpyClock { calls: RefCell::new(0), fixed: 500 }; let _ = token_is_valid(&clock, 400, 200); assert_eq!(*clock.calls.borrow(), 1); }}running 1 testtest tests::clock_is_read_exactly_once ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sRefCell provides interior mutability so the spy can mutate its counter through a shared &self reference — see RefCell and Mutex. (In a multithreaded test, use Arc<AtomicU32> instead, as in the capturing example below.)
Reach for mock! when you do not own the trait
Section titled “Reach for mock! when you do not own the trait”#[automock] must be attached to the trait, so for a third-party trait (or a concrete struct) use the mock! macro to describe the mock inline:
/// Pretend this trait comes from a third-party crate, so you cannot add/// `#[automock]` to its definition.pub trait HttpClient { fn get(&self, url: &str) -> Result<String, String>;}
pub struct WeatherService<C: HttpClient> { client: C,}
impl<C: HttpClient> WeatherService<C> { pub fn new(client: C) -> Self { WeatherService { client } } pub fn temperature(&self, city: &str) -> Result<i32, String> { let body = self.client.get(&format!("https://api/weather/{city}"))?; body.trim().parse::<i32>().map_err(|e| e.to_string()) }}
#[cfg(test)]mod tests { use super::*; use mockall::{mock, predicate::*};
// `mock!` generates `MockExternalHttp` implementing `HttpClient`. mock! { ExternalHttp {} impl HttpClient for ExternalHttp { fn get(&self, url: &str) -> Result<String, String>; } }
#[test] fn parses_temperature_from_response() { let mut client = MockExternalHttp::new(); client .expect_get() .with(eq("https://api/weather/oslo")) .returning(|_| Ok(" -3 ".to_string()));
let svc = WeatherService::new(client); assert_eq!(svc.temperature("oslo"), Ok(-3)); }}running 1 testtest tests::parses_temperature_from_response ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sUse withf for logic that eq can’t express, and Arc + atomics to capture state
Section titled “Use withf for logic that eq can’t express, and Arc + atomics to capture state”.with(eq(...)) matches by equality; .withf(closure) matches with arbitrary logic (the analogue of an expect.objectContaining / custom matcher). And when you need a spy-like counter inside a mockall mock, capture an Arc<AtomicU32> — which is Send:
use std::sync::Arc;
#[cfg_attr(test, mockall::automock)]pub trait PaymentGateway { fn charge(&self, cents: u64, currency: &str) -> Result<String, String>;}
#[cfg(test)]mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering};
#[test] fn withf_custom_matcher() { let mut gw = MockPaymentGateway::new(); gw.expect_charge() .withf(|cents, currency| *cents > 0 && currency == "USD") .returning(|_, _| Ok("ch_1".to_string())); assert_eq!(gw.charge(500, "USD"), Ok("ch_1".to_string())); }
#[test] fn returning_can_capture_and_count() { let calls = Arc::new(AtomicU32::new(0)); let calls_in_mock = Arc::clone(&calls);
let mut gw = MockPaymentGateway::new(); gw.expect_charge().returning(move |_, _| { calls_in_mock.fetch_add(1, Ordering::SeqCst); Ok("ch".to_string()) });
let _ = gw.charge(1, "USD"); let _ = gw.charge(2, "USD"); assert_eq!(calls.load(Ordering::SeqCst), 2); }}running 2 teststest tests::returning_can_capture_and_count ... oktest tests::withf_custom_matcher ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sInject the trait — generics for static dispatch, dyn for runtime choice
Section titled “Inject the trait — generics for static dispatch, dyn for runtime choice”Two idioms make code mockable, both seen above:
- Generic parameter (
WelcomeService<R, S>): monomorphized, zero-cost, the default. The compiler stamps out one version per concrete type, so a mock and the real type are distinct types. - Trait object (
Arc<dyn PaymentGateway>): one type regardless of the implementation, chosen at runtime. Use it when the dependency is selected dynamically or when generics would over-parameterize a large struct.
Both accept a Mock… exactly where they accept the real implementation — the substitution is just Rust’s polymorphism. See Trait Objects and Trait Bounds for the trade-offs.
Real-World Example
Section titled “Real-World Example”A complete order-processing service whose two collaborators — a repository and an audit log — are mocked. It exercises argument matching, call counts, ordering via Sequence, the never() guard, and a typed error enum. This is the full, compile-verified file.
//! An order-fulfillment service with injected, mockable collaborators.
/// Reads and writes orders. The real impl hits a database.#[cfg_attr(test, mockall::automock)]pub trait OrderRepo { fn find_total_cents(&self, order_id: u64) -> Result<Option<u64>, String>; fn mark_paid(&self, order_id: u64) -> Result<(), String>;}
/// Charges a payment method.#[cfg_attr(test, mockall::automock)]pub trait PaymentGateway { fn charge(&self, cents: u64) -> Result<String, String>;}
/// Domain error for the checkout flow.#[derive(Debug, PartialEq)]pub enum CheckoutError { UnknownOrder(u64), Repo(String), Payment(String),}
/// Orchestrates: look up the order total, charge it, then mark it paid.pub struct Checkout<R, P> { repo: R, gateway: P,}
impl<R: OrderRepo, P: PaymentGateway> Checkout<R, P> { pub fn new(repo: R, gateway: P) -> Self { Checkout { repo, gateway } }
/// Returns the payment's charge id on success. pub fn pay(&self, order_id: u64) -> Result<String, CheckoutError> { let total = self .repo .find_total_cents(order_id) .map_err(CheckoutError::Repo)? .ok_or(CheckoutError::UnknownOrder(order_id))?;
let charge_id = self .gateway .charge(total) .map_err(CheckoutError::Payment)?;
self.repo.mark_paid(order_id).map_err(CheckoutError::Repo)?; Ok(charge_id) }}
#[cfg(test)]mod tests { use super::*; use mockall::{predicate::*, Sequence};
#[test] fn charges_then_marks_paid_in_order() { // A Sequence asserts the calls happen in this exact order. let mut seq = Sequence::new();
let mut repo = MockOrderRepo::new(); repo.expect_find_total_cents() .with(eq(10u64)) .times(1) .in_sequence(&mut seq) .returning(|_| Ok(Some(4_999)));
let mut gateway = MockPaymentGateway::new(); gateway .expect_charge() .with(eq(4_999u64)) .times(1) .in_sequence(&mut seq) .returning(|_| Ok("ch_777".to_string()));
repo.expect_mark_paid() .with(eq(10u64)) .times(1) .in_sequence(&mut seq) .returning(|_| Ok(()));
let checkout = Checkout::new(repo, gateway); assert_eq!(checkout.pay(10), Ok("ch_777".to_string())); }
#[test] fn unknown_order_never_charges() { let mut repo = MockOrderRepo::new(); repo.expect_find_total_cents().returning(|_| Ok(None)); repo.expect_mark_paid().never();
let mut gateway = MockPaymentGateway::new(); gateway.expect_charge().never(); // must not charge a missing order
let checkout = Checkout::new(repo, gateway); assert_eq!(checkout.pay(404), Err(CheckoutError::UnknownOrder(404))); }
#[test] fn payment_failure_does_not_mark_paid() { let mut repo = MockOrderRepo::new(); repo.expect_find_total_cents().returning(|_| Ok(Some(1_500))); repo.expect_mark_paid().never(); // crucial: do not record a failed payment
let mut gateway = MockPaymentGateway::new(); gateway .expect_charge() .returning(|_| Err("card declined".to_string()));
let checkout = Checkout::new(repo, gateway); assert_eq!( checkout.pay(10), Err(CheckoutError::Payment("card declined".to_string())) ); }}Running cargo test:
running 3 teststest tests::charges_then_marks_paid_in_order ... oktest tests::payment_failure_does_not_mark_paid ... oktest tests::unknown_order_never_charges ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sWhat this buys you that a naive Vitest suite often misses:
Sequenceturns “charge before marking paid” into an enforced expectation. Reorder the production logic and the test fails — no manualmock.callsindex juggling.never()documents and enforces the safety-critical invariants (“do not charge a missing order,” “do not mark a failed payment as paid”). An unexpected call panics rather than slipping through.- The mocks self-verify on drop: every
times(1)is checked whencheckout(which owns them) goes out of scope, with no trailing assertion lines.
Further Reading
Section titled “Further Reading”mockallcrate documentation — the authoritative reference for#[automock],mock!, predicates, andSequence.mockall::predicatemodule —eq,function,gt,always, and friends.- The Rust Book — Test Organization — where mocks fit among unit and integration tests.
- Sibling topics in this section:
- Unit Tests —
#[test],#[cfg(test)], and the test harness the mocks run in. - Test Organization — private vs public testing; where to keep mocks.
- Assertions —
assert_eq!/assert!used alongside expectations. #[should_panic]andResulttests — testing the error paths the mocks simulate.- Integration Tests — black-box testing where you usually do not mock.
- Test Fixtures — building reusable mock setups and shared state.
- Property Testing, Benchmarking, Doc Tests, Coverage, TDD Workflow.
- Unit Tests —
- Foundations used above:
- Traits, Trait Bounds, Trait Objects — the dependency-injection mechanism.
RefCellandMutex— interior mutability for hand-written spies.- Concurrency —
Arcand atomics forSend-safe captured state. - Macros — how attribute macros like
#[automock]generate code. - Cargo Basics —
cargo add --devand[dev-dependencies].
Exercises
Section titled “Exercises”Exercise 1: A hand-written stub
Section titled “Exercise 1: A hand-written stub”Difficulty: Easy
Objective: Mock a dependency without any crate, using a small struct that implements a trait.
Instructions: Given the FeatureFlags trait and banner_text function below, write a #[cfg(test)] mod tests containing a stub StubFlags (holding a single bool) and two tests: one where the flag is on (expect "New and improved!") and one where it is off (expect "Welcome back."). No mockall needed.
pub trait FeatureFlags { fn is_enabled(&self, flag: &str) -> bool;}
pub fn banner_text(flags: &impl FeatureFlags) -> &'static str { if flags.is_enabled("new_banner") { "New and improved!" } else { "Welcome back." }}
// TODO: add a #[cfg(test)] mod tests with a StubFlags and two testsSolution
pub trait FeatureFlags { fn is_enabled(&self, flag: &str) -> bool;}
pub fn banner_text(flags: &impl FeatureFlags) -> &'static str { if flags.is_enabled("new_banner") { "New and improved!" } else { "Welcome back." }}
#[cfg(test)]mod tests { use super::*;
struct StubFlags { on: bool, }
impl FeatureFlags for StubFlags { fn is_enabled(&self, _flag: &str) -> bool { self.on } }
#[test] fn banner_respects_flag() { assert_eq!(banner_text(&StubFlags { on: true }), "New and improved!"); assert_eq!(banner_text(&StubFlags { on: false }), "Welcome back."); }}running 1 testtest tests::banner_respects_flag ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sExercise 2: #[automock] with a call-count expectation
Section titled “Exercise 2: #[automock] with a call-count expectation”Difficulty: Medium
Objective: Generate a mock with #[automock] and verify both the argument and the number of calls.
Instructions: Add mockall as a dev-dependency. Given the AuditLog trait and Account type below, write a test that mocks AuditLog, expects record to be called exactly once with "deposit 500", and then calls account.deposit(500). (Remember: the count is verified when the mock drops, so you do not write a final assertion for it.)
#[cfg_attr(test, mockall::automock)]pub trait AuditLog { fn record(&self, action: &str) -> Result<(), String>;}
pub struct Account<L> { log: L, balance: i64,}
impl<L: AuditLog> Account<L> { pub fn new(log: L) -> Self { Account { log, balance: 0 } } pub fn deposit(&mut self, cents: i64) -> Result<(), String> { self.balance += cents; self.log.record(&format!("deposit {cents}")) }}
// TODO: add a #[cfg(test)] mod testsSolution
#[cfg_attr(test, mockall::automock)]pub trait AuditLog { fn record(&self, action: &str) -> Result<(), String>;}
pub struct Account<L> { log: L, balance: i64,}
impl<L: AuditLog> Account<L> { pub fn new(log: L) -> Self { Account { log, balance: 0 } } pub fn deposit(&mut self, cents: i64) -> Result<(), String> { self.balance += cents; self.log.record(&format!("deposit {cents}")) }}
#[cfg(test)]mod tests { use super::*; use mockall::predicate::*;
#[test] fn deposit_is_audited_once() { let mut log = MockAuditLog::new(); log.expect_record() .with(eq("deposit 500")) .times(1) .returning(|_| Ok(()));
let mut account = Account::new(log); account.deposit(500).unwrap(); // `times(1)` is verified when `account` (and its `log`) drop here. }}running 1 testtest tests::deposit_is_audited_once ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sExercise 3: A read-through cache with ordered expectations
Section titled “Exercise 3: A read-through cache with ordered expectations”Difficulty: Hard
Objective: Mock two collaborators and assert the order of calls with Sequence, plus a never() guard on the happy path.
Instructions: Implement a ReadThrough<C, O> that, on read(key): returns the cached value on a hit; on a miss, fetches from the origin, writes it back with put, and returns it. Then write two tests:
- Miss:
getreturnsNone, thenfetchreturns"Ada", thenputis called with the key and"Ada"— in that order (useSequence). - Hit:
getreturnsSome("Grace"); assertfetchandputare never called.
#[cfg_attr(test, mockall::automock)]pub trait Cache { fn get(&self, key: &str) -> Option<String>; fn put(&self, key: &str, value: &str);}
#[cfg_attr(test, mockall::automock)]pub trait Origin { fn fetch(&self, key: &str) -> String;}
pub struct ReadThrough<C, O> { cache: C, origin: O,}
impl<C: Cache, O: Origin> ReadThrough<C, O> { // TODO: new() and read()}
// TODO: add testsSolution
#[cfg_attr(test, mockall::automock)]pub trait Cache { fn get(&self, key: &str) -> Option<String>; fn put(&self, key: &str, value: &str);}
#[cfg_attr(test, mockall::automock)]pub trait Origin { fn fetch(&self, key: &str) -> String;}
pub struct ReadThrough<C, O> { cache: C, origin: O,}
impl<C: Cache, O: Origin> ReadThrough<C, O> { pub fn new(cache: C, origin: O) -> Self { ReadThrough { cache, origin } } pub fn read(&self, key: &str) -> String { if let Some(hit) = self.cache.get(key) { return hit; } let value = self.origin.fetch(key); self.cache.put(key, &value); value }}
#[cfg(test)]mod tests { use super::*; use mockall::{predicate::*, Sequence};
#[test] fn miss_fetches_then_populates_cache() { let mut seq = Sequence::new();
let mut cache = MockCache::new(); cache .expect_get() .with(eq("user:1")) .times(1) .in_sequence(&mut seq) .returning(|_| None);
let mut origin = MockOrigin::new(); origin .expect_fetch() .with(eq("user:1")) .times(1) .in_sequence(&mut seq) .returning(|_| "Ada".to_string());
cache .expect_put() .with(eq("user:1"), eq("Ada")) .times(1) .in_sequence(&mut seq) .returning(|_, _| ());
let rt = ReadThrough::new(cache, origin); assert_eq!(rt.read("user:1"), "Ada"); }
#[test] fn hit_skips_origin() { let mut cache = MockCache::new(); cache .expect_get() .with(eq("user:2")) .returning(|_| Some("Grace".to_string())); cache.expect_put().never();
let mut origin = MockOrigin::new(); origin.expect_fetch().never();
let rt = ReadThrough::new(cache, origin); assert_eq!(rt.read("user:2"), "Grace"); }}running 2 teststest tests::hit_skips_origin ... oktest tests::miss_fetches_then_populates_cache ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s