Skip to content

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.


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.


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().

welcome.ts
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.");
}
}
welcome.test.ts
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.toHaveBeenCalled verify 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.


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:

Terminal window
cargo add mockall --dev
Cargo.toml
[dev-dependencies]
mockall = "0.14.0"
src/lib.rs
//! 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 tests
test tests::does_not_send_when_user_is_unknown ... ok
test tests::emails_a_known_user ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The 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.


#[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 from mockall::predicate (eq, gt, ge, function, always, …). It is the analogue of toHaveBeenCalledWith, 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.


ConceptJest / VitestRust (mockall)
What you mocka module, function, or object via vi.mock / vi.fna trait, via #[automock] or mock!
Substitution mechanismruntime monkey-patching of importsthe type system — you pass a different concrete type
Build cost in productionthe test framework is a dependency[dev-dependencies]; zero code in release builds
Canned returnmockFn.mockResolvedValue(x)`.returning(
Argument checkexpect(fn).toHaveBeenCalledWith(a) (after the fact).with(eq(a)) (enforced at call time)
Call-count checktoHaveBeenCalledTimes(n) (after the fact).times(n) (verified on drop)
Call orderingmanual / mock.calls inspectionSequence makes ordering a first-class expectation
Unexpected callreturns undefined, test may pass silentlypanics immediately: “No matching expectation found”
Type safety of the doublethe fake can drift from the interfacethe 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.


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 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The 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 found

The 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 test
use 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.


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().

src/lib.rs
/// 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 tests
test tests::expired_token_is_invalid ... ok
test tests::token_within_ttl_is_valid ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Tip: 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 the mockall types). mockall gives 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 test
test tests::clock_is_read_exactly_once ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

RefCell 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:

src/lib.rs
/// 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 test
test tests::parses_temperature_from_response ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Use 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 tests
test tests::returning_can_capture_and_count ... ok
test tests::withf_custom_matcher ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Inject 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.


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.

src/lib.rs
//! 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 tests
test tests::charges_then_marks_paid_in_order ... ok
test tests::payment_failure_does_not_mark_paid ... ok
test tests::unknown_order_never_charges ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

What this buys you that a naive Vitest suite often misses:

  • Sequence turns “charge before marking paid” into an enforced expectation. Reorder the production logic and the test fails — no manual mock.calls index 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 when checkout (which owns them) goes out of scope, with no trailing assertion lines.


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 tests
Solution
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 test
test tests::banner_respects_flag ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Exercise 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 tests
Solution
#[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 test
test tests::deposit_is_audited_once ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Exercise 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:

  1. Miss: get returns None, then fetch returns "Ada", then put is called with the key and "Ada" — in that order (use Sequence).
  2. Hit: get returns Some("Grace"); assert fetch and put are 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 tests
Solution
#[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 tests
test tests::hit_skips_origin ... ok
test 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