Test Doubles: Dummies, Nulls, Stubs, Fakes, Spies, and Mocks
When people say “I’ll just mock it out”, they usually mean something much more specific than they realise. The umbrella term is test double - any object that stands in for a real dependency in a test. Gerard Meszaros catalogued five distinct types in xUnit Test Patterns, and Martin Fowler clarified the distinctions in his essay Mocks Aren’t Stubs. This post adds a sixth — the null object — which sits between dummies and stubs and becomes essential once you start building null construction patterns. Each type makes a different trade-off between simplicity, control, and what it can verify.
Using the wrong type doesn’t break tests, but it does create confusion: a spy masquerading as a mock, or a fake called a stub, muddles what the test is actually checking. Getting the vocabulary right makes tests easier to read and easier to reason about.
The Shared Domain
All examples use the same system under test: UserRegistrationService. It depends on two collaborators - an EmailService that sends a welcome email, and a UserRepository that persists users.
from typing import Protocol
class User:
def __init__(self, id: str, email: str) -> None:
self.id = id
self.email = email
class EmailService(Protocol):
def send(self, to: str, subject: str, body: str) -> None: ...
class UserRepository(Protocol):
def find_by_email(self, email: str) -> User | None: ...
def save(self, user: User) -> None: ...
class UserRegistrationService:
def __init__(self, repo: UserRepository, email: EmailService) -> None:
self._repo = repo
self._email = email
def register(self, email: str, password: str) -> User:
if "@" not in email:
raise ValueError(f"Invalid email: {email}")
if self._repo.find_by_email(email) is not None:
raise ValueError(f"{email} is already registered")
user = User(id=email, email=email)
self._repo.save(user)
self._email.send(email, "Welcome!", f"Thanks for joining, {email}.")
return user #[derive(Clone, Debug)]
pub struct User {
pub id: String,
pub email: String,
}
pub trait EmailService {
fn send(&mut self, to: &str, subject: &str, body: &str);
}
pub trait UserRepository {
fn find_by_email(&self, email: &str) -> Option<User>;
fn save(&mut self, user: &User);
}
pub struct UserRegistrationService<R: UserRepository, E: EmailService> {
pub repo: R,
pub email: E,
}
impl<R: UserRepository, E: EmailService> UserRegistrationService<R, E> {
pub fn new(repo: R, email: E) -> Self {
Self { repo, email }
}
pub fn register(&mut self, email: &str, _password: &str) -> Result<User, String> {
if !email.contains('@') {
return Err(format!("Invalid email: {email}"));
}
if self.repo.find_by_email(email).is_some() {
return Err(format!("{email} is already registered"));
}
let user = User { id: email.into(), email: email.into() };
self.repo.save(&user);
self.email.send(email, "Welcome!", &format!("Thanks for joining, {email}."));
Ok(user)
}
} #include <optional>
#include <stdexcept>
#include <string>
struct User {
std::string id;
std::string email;
};
struct EmailService {
virtual void send(const std::string& to,
const std::string& subject,
const std::string& body) = 0;
virtual ~EmailService() = default;
};
struct UserRepository {
virtual std::optional<User> find_by_email(const std::string& email) const = 0;
virtual void save(const User& user) = 0;
virtual ~UserRepository() = default;
};
class UserRegistrationService {
UserRepository& repo_;
EmailService& email_;
public:
UserRegistrationService(UserRepository& repo, EmailService& email)
: repo_(repo), email_(email) {}
User register_user(const std::string& email, const std::string& /*password*/) {
if (email.find('@') == std::string::npos)
throw std::invalid_argument("Invalid email: " + email);
if (repo_.find_by_email(email))
throw std::runtime_error(email + " is already registered");
User user{email, email};
repo_.save(user);
email_.send(email, "Welcome!", "Thanks for joining, " + email + ".");
return user;
}
};Dummy
A dummy is passed to satisfy an interface requirement but is never actually invoked. If the code accidentally calls it, the dummy makes the test fail loudly - that is the point.
The key is identifying what the test is really exercising. Here we are testing email format validation, which is the first thing register does. The format check raises immediately if @ is absent, before the service ever consults the repository or sends an email. Neither collaborator is ever reached, so both are dummies: anything that panics on any call proves the test path never touches them.
import pytest
class DummyUserRepository:
def find_by_email(self, email: str) -> None:
raise AssertionError("find_by_email should not be called")
def save(self, user) -> None:
raise AssertionError("save should not be called")
class DummyEmailService:
def send(self, to: str, subject: str, body: str) -> None:
raise AssertionError("send should not be called")
def test_register_raises_for_invalid_email():
service = UserRegistrationService(DummyUserRepository(), DummyEmailService())
with raises(ValueError, match="Invalid email"):
service.register("not-an-email", "secret") struct DummyUserRepository;
impl UserRepository for DummyUserRepository {
fn find_by_email(&self, _email: &str) -> Option<User> {
panic!("find_by_email should not be called")
}
fn save(&mut self, _user: &User) {
panic!("save should not be called")
}
}
struct DummyEmailService;
impl EmailService for DummyEmailService {
fn send(&mut self, _to: &str, _subject: &str, _body: &str) {
panic!("send should not be called")
}
}
#[test]
fn register_raises_for_invalid_email() {
let mut service = UserRegistrationService::new(DummyUserRepository, DummyEmailService);
let result = service.register("not-an-email", "secret");
assert!(result.unwrap_err().contains("Invalid email"));
} struct DummyUserRepository : UserRepository {
std::optional<User> find_by_email(const std::string&) const override {
throw std::logic_error("find_by_email should not be called");
}
void save(const User&) override {
throw std::logic_error("save should not be called");
}
};
struct DummyEmailService : EmailService {
void send(const std::string&, const std::string&, const std::string&) override {
throw std::logic_error("send should not be called");
}
};
void test_register_raises_for_invalid_email() {
DummyUserRepository repo;
DummyEmailService email;
UserRegistrationService service{repo, email};
try {
service.register_user("not-an-email", "secret");
assert(false && "expected exception");
} catch (const std::invalid_argument&) {
// pass - format check fired before reaching either collaborator
}
}Null
A null object satisfies the interface and silently does nothing. Every method is a no-op — no return value, no side effect, no panic. Where a dummy proves a code path is never reached (by exploding if it is), a null object absorbs calls you know will happen but have no interest in verifying.
A null might look like a minimal fake, but the distinction matters. A fake has real logic — an in-memory store, a state machine — that must be kept in sync with the production type it replaces. A null has no logic to maintain. This makes nulls trivially composable: when a dependency graph is several layers deep (Manager → Publisher → WebSocket), each layer can wire itself with null objects through a new_null() factory, and the entire graph assembles in one call with zero real connections. Fakes cannot compose this way because each one needs careful wiring. The Null Construction pattern builds on this idea.
Here we want to test that register returns a user with the correct email. The email service will be called — register sends a welcome email after saving — but this test does not care about that side effect. A null email service lets the call pass through without noise. The repository still needs real behaviour (return None from find_by_email, accept save), so it uses a stub/fake — the null applies only to the collaborator this test ignores.
class NullEmailService:
def send(self, to: str, subject: str, body: str) -> None:
pass # called, but this test doesn't care
class StubUserRepository:
def find_by_email(self, email: str) -> None:
return None # email not taken
def save(self, user: User) -> None:
pass
def test_register_returns_user_with_correct_email():
service = UserRegistrationService(StubUserRepository(), NullEmailService())
user = service.register("alice@example.com", "secret")
assert user.email == "alice@example.com" struct NullEmailService;
impl EmailService for NullEmailService {
fn send(&mut self, _to: &str, _subject: &str, _body: &str) {
// called, but this test doesn't care
}
}
struct StubUserRepository;
impl UserRepository for StubUserRepository {
fn find_by_email(&self, _email: &str) -> Option<User> {
None // email not taken
}
fn save(&mut self, _user: &User) {}
}
#[test]
fn register_returns_user_with_correct_email() {
let mut service = UserRegistrationService::new(StubUserRepository, NullEmailService);
let user = service.register("alice@example.com", "secret").unwrap();
assert_eq!(user.email, "alice@example.com");
} struct NullEmailService : EmailService {
void send(const std::string&, const std::string&,
const std::string&) override {
// called, but this test doesn't care
}
};
struct StubUserRepository : UserRepository {
std::optional<User> find_by_email(const std::string&) const override {
return std::nullopt; // email not taken
}
void save(const User&) override {}
};
void test_register_returns_user_with_correct_email() {
StubUserRepository repo;
NullEmailService email;
UserRegistrationService service{repo, email};
User user = service.register_user("alice@example.com", "secret");
assert(user.email == "alice@example.com");
}Stub
A stub returns canned responses to specific calls. It does not care how many times it is called or with what arguments - it simply returns a predetermined value. Stubs are used to put the system under test into a particular state.
Here we want to verify the “duplicate email” path. The stub always reports that an email is already taken, regardless of what email is passed.
import pytest
class StubUserRepository:
def find_by_email(self, email: str) -> User:
return User(id="existing", email=email) # always occupied
def save(self, user: User) -> None:
pass # irrelevant
def test_register_raises_when_email_already_registered():
service = UserRegistrationService(StubUserRepository(), DummyEmailService())
with raises(ValueError, match="already registered"):
service.register("alice@example.com", "secret") struct StubUserRepository;
impl UserRepository for StubUserRepository {
fn find_by_email(&self, email: &str) -> Option<User> {
Some(User { id: "existing".into(), email: email.into() }) // always occupied
}
fn save(&mut self, _user: &User) {}
}
#[test]
fn register_raises_when_email_already_registered() {
let mut service = UserRegistrationService::new(StubUserRepository, DummyEmailService);
let result = service.register("alice@example.com", "secret");
assert!(result.unwrap_err().contains("already registered"));
} struct StubUserRepository : UserRepository {
std::optional<User> find_by_email(const std::string& email) const override {
return User{"existing", email}; // always occupied
}
void save(const User&) override {}
};
void test_register_raises_when_email_already_registered() {
StubUserRepository repo;
DummyEmailService email;
UserRegistrationService service{repo, email};
try {
service.register_user("alice@example.com", "secret");
assert(false && "expected exception");
} catch (const std::runtime_error&) {
// pass
}
}Fake
A fake has real, working logic - just a simplified version not suitable for production. The canonical example is an in-memory repository in place of a database. It correctly stores and retrieves data, but only lives in process memory.
Fakes are heavier to write than stubs, but they enable more realistic multi-step scenarios. Here, we register the same address twice and verify the second attempt fails - a test that requires the repository to actually remember the first registration.
import pytest
class FakeUserRepository:
def __init__(self) -> None:
self._store: dict[str, User] = {}
def find_by_email(self, email: str) -> User | None:
return self._store.get(email)
def save(self, user: User) -> None:
self._store[user.email] = user
class NullEmailService:
def send(self, to: str, subject: str, body: str) -> None:
pass
def test_cannot_register_same_email_twice():
repo = FakeUserRepository()
service = UserRegistrationService(repo, NullEmailService())
service.register("alice@example.com", "first_secret")
with raises(ValueError, match="already registered"):
service.register("alice@example.com", "second_secret") use std::collections::HashMap;
struct FakeUserRepository {
store: HashMap<String, User>,
}
impl FakeUserRepository {
fn new() -> Self {
Self { store: HashMap::new() }
}
}
impl UserRepository for FakeUserRepository {
fn find_by_email(&self, email: &str) -> Option<User> {
self.store.get(email).cloned()
}
fn save(&mut self, user: &User) {
self.store.insert(user.email.clone(), user.clone());
}
}
struct NullEmailService;
impl EmailService for NullEmailService {
fn send(&mut self, _to: &str, _subject: &str, _body: &str) {}
}
#[test]
fn cannot_register_same_email_twice() {
let mut service = UserRegistrationService::new(FakeUserRepository::new(), NullEmailService);
service.register("alice@example.com", "first_secret").unwrap();
let result = service.register("alice@example.com", "second_secret");
assert!(result.unwrap_err().contains("already registered"));
} #include <unordered_map>
struct FakeUserRepository : UserRepository {
std::unordered_map<std::string, User> store;
std::optional<User> find_by_email(const std::string& email) const override {
auto it = store.find(email);
return it != store.end() ? std::optional{it->second} : std::nullopt;
}
void save(const User& user) override {
store[user.email] = user;
}
};
struct NullEmailService : EmailService {
void send(const std::string&, const std::string&, const std::string&) override {}
};
void test_cannot_register_same_email_twice() {
FakeUserRepository repo;
NullEmailService email;
UserRegistrationService service{repo, email};
service.register_user("alice@example.com", "first_secret");
try {
service.register_user("alice@example.com", "second_secret");
assert(false && "expected exception");
} catch (const std::runtime_error&) {
// pass
}
}Spy
A spy is a test double that records what happens to it. It delegates normally (or does nothing), but keeps a log of every call - arguments, call count, order. After the system under test has run, you inspect the spy to verify the interaction occurred.
Spies are used when you care about side effects: did the email get sent? Was the right recipient used? Notice the structure: arrange the spy, act on the service, then assert against the spy’s recorded calls.
class SpyEmailService:
def __init__(self) -> None:
self.calls: list[dict[str, str]] = []
def send(self, to: str, subject: str, body: str) -> None:
self.calls.append({"to": to, "subject": subject, "body": body})
def test_welcome_email_is_sent_on_registration():
repo = FakeUserRepository()
spy = SpyEmailService()
service = UserRegistrationService(repo, spy)
service.register("alice@example.com", "secret")
assert len(spy.calls) == 1
assert spy.calls[0]["to"] == "alice@example.com" struct SpyEmailService {
calls: Vec<(String, String, String)>,
}
impl SpyEmailService {
fn new() -> Self {
Self { calls: Vec::new() }
}
}
impl EmailService for SpyEmailService {
fn send(&mut self, to: &str, subject: &str, body: &str) {
self.calls.push((to.into(), subject.into(), body.into()));
}
}
#[test]
fn welcome_email_is_sent_on_registration() {
let mut service = UserRegistrationService::new(FakeUserRepository::new(), SpyEmailService::new());
service.register("alice@example.com", "secret").unwrap();
assert_eq!(service.email.calls.len(), 1);
assert_eq!(service.email.calls[0].0, "alice@example.com");
} #include <vector>
#include <tuple>
struct SpyEmailService : EmailService {
std::vector<std::tuple<std::string, std::string, std::string>> calls;
void send(const std::string& to,
const std::string& subject,
const std::string& body) override {
calls.emplace_back(to, subject, body);
}
};
void test_welcome_email_is_sent_on_registration() {
FakeUserRepository repo;
SpyEmailService spy;
UserRegistrationService service{repo, spy};
service.register_user("alice@example.com", "secret");
assert(spy.calls.size() == 1);
assert(std::get<0>(spy.calls[0]) == "alice@example.com");
}Mock
A mock looks similar to a spy, but the order of operations is reversed. With a mock, you declare your expectations before the call, then verify them after. If the expectation is not met - wrong arguments, called too few or too many times - the test fails at the verification step.
This is the distinction Fowler draws most sharply: a spy is used for after-the-fact assertions; a mock asserts what should happen before it happens.
class MockEmailService:
def __init__(self, expected_to: str, expected_subject: str) -> None:
self._expected_to = expected_to
self._expected_subject = expected_subject
self._was_called = False
def send(self, to: str, subject: str, body: str) -> None:
assert to == self._expected_to, f"unexpected recipient: {to!r}"
assert subject == self._expected_subject, f"unexpected subject: {subject!r}"
self._was_called = True
def verify(self) -> None:
assert self._was_called, "send was never called"
def test_welcome_email_sent_with_correct_subject():
repo = FakeUserRepository()
mock_email = MockEmailService("alice@example.com", "Welcome!")
service = UserRegistrationService(repo, mock_email)
service.register("alice@example.com", "secret")
mock_email.verify() struct MockEmailService {
expected_to: String,
expected_subject: String,
was_called: bool,
}
impl MockEmailService {
fn expecting(to: &str, subject: &str) -> Self {
Self {
expected_to: to.into(),
expected_subject: subject.into(),
was_called: false,
}
}
fn verify(&self) {
assert!(self.was_called, "send was never called");
}
}
impl EmailService for MockEmailService {
fn send(&mut self, to: &str, subject: &str, _body: &str) {
assert_eq!(to, self.expected_to, "unexpected recipient");
assert_eq!(subject, self.expected_subject, "unexpected subject");
self.was_called = true;
}
}
#[test]
fn welcome_email_sent_with_correct_subject() {
let mock = MockEmailService::expecting("alice@example.com", "Welcome!");
let mut service = UserRegistrationService::new(FakeUserRepository::new(), mock);
service.register("alice@example.com", "secret").unwrap();
service.email.verify();
} #include <cassert>
class MockEmailService : public EmailService {
std::string expected_to_;
std::string expected_subject_;
mutable bool was_called_ = false;
public:
MockEmailService(std::string expected_to, std::string expected_subject)
: expected_to_(std::move(expected_to))
, expected_subject_(std::move(expected_subject)) {}
void send(const std::string& to,
const std::string& subject,
const std::string& /*body*/) override {
assert(to == expected_to_);
assert(subject == expected_subject_);
was_called_ = true;
}
void verify() const {
assert(was_called_ && "send was never called");
}
};
void test_welcome_email_sent_with_correct_subject() {
FakeUserRepository repo;
MockEmailService mock{"alice@example.com", "Welcome!"};
UserRegistrationService service{repo, mock};
service.register_user("alice@example.com", "secret");
mock.verify();
}Summary
| Type | Returns a value? | Has working logic? | Verifies behaviour? |
|---|---|---|---|
| Dummy | No | No | No - fails if called |
| Null | No | No | No - silently succeeds |
| Stub | Yes (canned) | No | No |
| Fake | Yes | Yes (simplified) | No |
| Spy | Yes | Optional | After the call |
| Mock | Yes | Optional | Before the call (pre-set) |
The most common confusion is between spy and mock: both record or verify interactions with a collaborator, but a spy records calls and lets you assert afterwards, while a mock states upfront what it expects and fails immediately if those expectations are not met. Neither is better - they suit different testing styles.
The other frequent mistake is using a fake when a stub would do. Fakes are valuable but they are real code that has to be maintained. If you only need to return a fixed value, a stub is simpler and its intent is clearer.
Full Example
All six doubles together in one file, using the same domain.
from __future__ import annotations
from contextlib import contextmanager
from typing import Protocol
import re
@contextmanager
def raises(exc_type, match=None):
try:
yield
except exc_type as e:
if match and not re.search(match, str(e)):
raise AssertionError(f"Expected pattern {match!r} in {str(e)!r}")
else:
raise AssertionError(f"Expected {exc_type.__name__} to be raised")
# ── Domain ────────────────────────────────────────────────────────────────────
class User:
def __init__(self, id: str, email: str) -> None:
self.id = id
self.email = email
class EmailService(Protocol):
def send(self, to: str, subject: str, body: str) -> None: ...
class UserRepository(Protocol):
def find_by_email(self, email: str) -> User | None: ...
def save(self, user: User) -> None: ...
class UserRegistrationService:
def __init__(self, repo: UserRepository, email: EmailService) -> None:
self._repo = repo
self._email = email
def register(self, email: str, password: str) -> User:
if "@" not in email:
raise ValueError(f"Invalid email: {email}")
if self._repo.find_by_email(email) is not None:
raise ValueError(f"{email} is already registered")
user = User(id=email, email=email)
self._repo.save(user)
self._email.send(email, "Welcome!", f"Thanks for joining, {email}.")
return user
# ── Dummy ─────────────────────────────────────────────────────────────────────
class DummyUserRepository:
def find_by_email(self, email: str) -> None:
raise AssertionError("find_by_email should not be called")
def save(self, user: User) -> None:
raise AssertionError("save should not be called")
class DummyEmailService:
def send(self, to: str, subject: str, body: str) -> None:
raise AssertionError("send should not be called")
def test_register_raises_for_invalid_email():
service = UserRegistrationService(DummyUserRepository(), DummyEmailService())
with raises(ValueError, match="Invalid email"):
service.register("not-an-email", "secret")
# ── Null ──────────────────────────────────────────────────────────────────────
class NullUserRepository:
def find_by_email(self, email: str) -> None:
return None
def save(self, user: User) -> None:
pass
class NullEmailService:
def send(self, to: str, subject: str, body: str) -> None:
pass
def test_register_returns_user_with_correct_email():
service = UserRegistrationService(NullUserRepository(), NullEmailService())
user = service.register("alice@example.com", "secret")
assert user.email == "alice@example.com"
# ── Stub ──────────────────────────────────────────────────────────────────────
class StubUserRepository:
def find_by_email(self, email: str) -> User:
return User(id="existing", email=email)
def save(self, user: User) -> None:
pass
def test_register_raises_when_email_already_registered():
service = UserRegistrationService(StubUserRepository(), DummyEmailService())
with raises(ValueError, match="already registered"):
service.register("alice@example.com", "secret")
# ── Fake ──────────────────────────────────────────────────────────────────────
class FakeUserRepository:
def __init__(self) -> None:
self._store: dict[str, User] = {}
def find_by_email(self, email: str) -> User | None:
return self._store.get(email)
def save(self, user: User) -> None:
self._store[user.email] = user
def test_cannot_register_same_email_twice():
repo = FakeUserRepository()
service = UserRegistrationService(repo, NullEmailService())
service.register("alice@example.com", "first_secret")
with raises(ValueError, match="already registered"):
service.register("alice@example.com", "second_secret")
# ── Spy ───────────────────────────────────────────────────────────────────────
class SpyEmailService:
def __init__(self) -> None:
self.calls: list[dict[str, str]] = []
def send(self, to: str, subject: str, body: str) -> None:
self.calls.append({"to": to, "subject": subject, "body": body})
def test_welcome_email_is_sent_on_registration():
repo = FakeUserRepository()
spy = SpyEmailService()
service = UserRegistrationService(repo, spy)
service.register("alice@example.com", "secret")
assert len(spy.calls) == 1
assert spy.calls[0]["to"] == "alice@example.com"
# ── Mock ──────────────────────────────────────────────────────────────────────
class MockEmailService:
def __init__(self, expected_to: str, expected_subject: str) -> None:
self._expected_to = expected_to
self._expected_subject = expected_subject
self._was_called = False
def send(self, to: str, subject: str, body: str) -> None:
assert to == self._expected_to, f"unexpected recipient: {to!r}"
assert subject == self._expected_subject, f"unexpected subject: {subject!r}"
self._was_called = True
def verify(self) -> None:
assert self._was_called, "send was never called"
def test_welcome_email_sent_with_correct_subject():
repo = FakeUserRepository()
mock_email = MockEmailService("alice@example.com", "Welcome!")
service = UserRegistrationService(repo, mock_email)
service.register("alice@example.com", "secret")
mock_email.verify()
# ── Run all tests ─────────────────────────────────────────────────────────────
test_register_raises_for_invalid_email(); print("dummy .... passed")
test_register_returns_user_with_correct_email(); print("null ..... passed")
test_register_raises_when_email_already_registered(); print("stub ..... passed")
test_cannot_register_same_email_twice(); print("fake ..... passed")
test_welcome_email_is_sent_on_registration(); print("spy ...... passed")
test_welcome_email_sent_with_correct_subject(); print("mock ..... passed") #![cfg(test)]
use std::collections::HashMap;
// ── Domain ────────────────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct User {
pub id: String,
pub email: String,
}
pub trait EmailService {
fn send(&mut self, to: &str, subject: &str, body: &str);
}
pub trait UserRepository {
fn find_by_email(&self, email: &str) -> Option<User>;
fn save(&mut self, user: &User);
}
pub struct UserRegistrationService<R: UserRepository, E: EmailService> {
pub repo: R,
pub email: E,
}
impl<R: UserRepository, E: EmailService> UserRegistrationService<R, E> {
pub fn new(repo: R, email: E) -> Self {
Self { repo, email }
}
pub fn register(&mut self, email: &str, _password: &str) -> Result<User, String> {
if !email.contains('@') {
return Err(format!("Invalid email: {email}"));
}
if self.repo.find_by_email(email).is_some() {
return Err(format!("{email} is already registered"));
}
let user = User { id: email.into(), email: email.into() };
self.repo.save(&user);
self.email.send(email, "Welcome!", &format!("Thanks for joining, {email}."));
Ok(user)
}
}
// ── Dummy ─────────────────────────────────────────────────────────────────────
struct DummyUserRepository;
impl UserRepository for DummyUserRepository {
fn find_by_email(&self, _email: &str) -> Option<User> {
panic!("find_by_email should not be called")
}
fn save(&mut self, _user: &User) {
panic!("save should not be called")
}
}
struct DummyEmailService;
impl EmailService for DummyEmailService {
fn send(&mut self, _to: &str, _subject: &str, _body: &str) {
panic!("send should not be called")
}
}
#[test]
fn register_raises_for_invalid_email() {
let mut service = UserRegistrationService::new(DummyUserRepository, DummyEmailService);
let result = service.register("not-an-email", "secret");
assert!(result.unwrap_err().contains("Invalid email"));
}
// ── Null ──────────────────────────────────────────────────────────────────────
struct NullUserRepository;
impl UserRepository for NullUserRepository {
fn find_by_email(&self, _email: &str) -> Option<User> { None }
fn save(&mut self, _user: &User) {}
}
struct NullEmailService;
impl EmailService for NullEmailService {
fn send(&mut self, _to: &str, _subject: &str, _body: &str) {}
}
#[test]
fn register_returns_user_with_correct_email() {
let mut service = UserRegistrationService::new(NullUserRepository, NullEmailService);
let user = service.register("alice@example.com", "secret").unwrap();
assert_eq!(user.email, "alice@example.com");
}
// ── Stub ──────────────────────────────────────────────────────────────────────
struct StubUserRepository;
impl UserRepository for StubUserRepository {
fn find_by_email(&self, email: &str) -> Option<User> {
Some(User { id: "existing".into(), email: email.into() })
}
fn save(&mut self, _user: &User) {}
}
#[test]
fn register_raises_when_email_already_registered() {
let mut service = UserRegistrationService::new(StubUserRepository, DummyEmailService);
let result = service.register("alice@example.com", "secret");
assert!(result.unwrap_err().contains("already registered"));
}
// ── Fake ──────────────────────────────────────────────────────────────────────
struct FakeUserRepository {
store: HashMap<String, User>,
}
impl FakeUserRepository {
fn new() -> Self {
Self { store: HashMap::new() }
}
}
impl UserRepository for FakeUserRepository {
fn find_by_email(&self, email: &str) -> Option<User> {
self.store.get(email).cloned()
}
fn save(&mut self, user: &User) {
self.store.insert(user.email.clone(), user.clone());
}
}
#[test]
fn cannot_register_same_email_twice() {
let mut service = UserRegistrationService::new(FakeUserRepository::new(), NullEmailService);
service.register("alice@example.com", "first_secret").unwrap();
let result = service.register("alice@example.com", "second_secret");
assert!(result.unwrap_err().contains("already registered"));
}
// ── Spy ───────────────────────────────────────────────────────────────────────
struct SpyEmailService {
calls: Vec<(String, String, String)>,
}
impl SpyEmailService {
fn new() -> Self {
Self { calls: Vec::new() }
}
}
impl EmailService for SpyEmailService {
fn send(&mut self, to: &str, subject: &str, body: &str) {
self.calls.push((to.into(), subject.into(), body.into()));
}
}
#[test]
fn welcome_email_is_sent_on_registration() {
let mut service = UserRegistrationService::new(FakeUserRepository::new(), SpyEmailService::new());
service.register("alice@example.com", "secret").unwrap();
assert_eq!(service.email.calls.len(), 1);
assert_eq!(service.email.calls[0].0, "alice@example.com");
}
// ── Mock ──────────────────────────────────────────────────────────────────────
struct MockEmailService {
expected_to: String,
expected_subject: String,
was_called: bool,
}
impl MockEmailService {
fn expecting(to: &str, subject: &str) -> Self {
Self {
expected_to: to.into(),
expected_subject: subject.into(),
was_called: false,
}
}
fn verify(&self) {
assert!(self.was_called, "send was never called");
}
}
impl EmailService for MockEmailService {
fn send(&mut self, to: &str, subject: &str, _body: &str) {
assert_eq!(to, self.expected_to, "unexpected recipient");
assert_eq!(subject, self.expected_subject, "unexpected subject");
self.was_called = true;
}
}
#[test]
fn welcome_email_sent_with_correct_subject() {
let mock = MockEmailService::expecting("alice@example.com", "Welcome!");
let mut service = UserRegistrationService::new(FakeUserRepository::new(), mock);
service.register("alice@example.com", "secret").unwrap();
service.email.verify();
} #include <cassert>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <tuple>
#include <unordered_map>
#include <vector>
// ── Domain ────────────────────────────────────────────────────────────────────
struct User {
std::string id;
std::string email;
};
struct EmailService {
virtual void send(const std::string& to,
const std::string& subject,
const std::string& body) = 0;
virtual ~EmailService() = default;
};
struct UserRepository {
virtual std::optional<User> find_by_email(const std::string& email) const = 0;
virtual void save(const User& user) = 0;
virtual ~UserRepository() = default;
};
class UserRegistrationService {
UserRepository& repo_;
EmailService& email_;
public:
UserRegistrationService(UserRepository& repo, EmailService& email)
: repo_(repo), email_(email) {}
User register_user(const std::string& email, const std::string& /*password*/) {
if (email.find('@') == std::string::npos)
throw std::invalid_argument("Invalid email: " + email);
if (repo_.find_by_email(email))
throw std::runtime_error(email + " is already registered");
User user{email, email};
repo_.save(user);
email_.send(email, "Welcome!", "Thanks for joining, " + email + ".");
return user;
}
};
// ── Dummy ─────────────────────────────────────────────────────────────────────
struct DummyUserRepository : UserRepository {
std::optional<User> find_by_email(const std::string&) const override {
throw std::logic_error("find_by_email should not be called");
}
void save(const User&) override {
throw std::logic_error("save should not be called");
}
};
struct DummyEmailService : EmailService {
void send(const std::string&, const std::string&, const std::string&) override {
throw std::logic_error("send should not be called");
}
};
void test_register_raises_for_invalid_email() {
DummyUserRepository repo;
DummyEmailService email;
UserRegistrationService service{repo, email};
try {
service.register_user("not-an-email", "secret");
assert(false && "expected exception");
} catch (const std::invalid_argument&) {}
}
// ── Null ──────────────────────────────────────────────────────────────────────
struct NullUserRepository : UserRepository {
std::optional<User> find_by_email(const std::string&) const override {
return std::nullopt;
}
void save(const User&) override {}
};
struct NullEmailService : EmailService {
void send(const std::string&, const std::string&, const std::string&) override {}
};
void test_register_returns_user_with_correct_email() {
NullUserRepository repo;
NullEmailService email;
UserRegistrationService service{repo, email};
User user = service.register_user("alice@example.com", "secret");
assert(user.email == "alice@example.com");
}
// ── Stub ──────────────────────────────────────────────────────────────────────
struct StubUserRepository : UserRepository {
std::optional<User> find_by_email(const std::string& email) const override {
return User{"existing", email};
}
void save(const User&) override {}
};
void test_register_raises_when_email_already_registered() {
StubUserRepository repo;
DummyEmailService email;
UserRegistrationService service{repo, email};
try {
service.register_user("alice@example.com", "secret");
assert(false && "expected exception");
} catch (const std::runtime_error&) {}
}
// ── Fake ──────────────────────────────────────────────────────────────────────
struct FakeUserRepository : UserRepository {
std::unordered_map<std::string, User> store;
std::optional<User> find_by_email(const std::string& email) const override {
auto it = store.find(email);
return it != store.end() ? std::optional{it->second} : std::nullopt;
}
void save(const User& user) override {
store[user.email] = user;
}
};
void test_cannot_register_same_email_twice() {
FakeUserRepository repo;
NullEmailService email;
UserRegistrationService service{repo, email};
service.register_user("alice@example.com", "first_secret");
try {
service.register_user("alice@example.com", "second_secret");
assert(false && "expected exception");
} catch (const std::runtime_error&) {}
}
// ── Spy ───────────────────────────────────────────────────────────────────────
struct SpyEmailService : EmailService {
std::vector<std::tuple<std::string, std::string, std::string>> calls;
void send(const std::string& to,
const std::string& subject,
const std::string& body) override {
calls.emplace_back(to, subject, body);
}
};
void test_welcome_email_is_sent_on_registration() {
FakeUserRepository repo;
SpyEmailService spy;
UserRegistrationService service{repo, spy};
service.register_user("alice@example.com", "secret");
assert(spy.calls.size() == 1);
assert(std::get<0>(spy.calls[0]) == "alice@example.com");
}
// ── Mock ──────────────────────────────────────────────────────────────────────
class MockEmailService : public EmailService {
std::string expected_to_;
std::string expected_subject_;
mutable bool was_called_ = false;
public:
MockEmailService(std::string expected_to, std::string expected_subject)
: expected_to_(std::move(expected_to))
, expected_subject_(std::move(expected_subject)) {}
void send(const std::string& to,
const std::string& subject,
const std::string& /*body*/) override {
assert(to == expected_to_);
assert(subject == expected_subject_);
was_called_ = true;
}
void verify() const { assert(was_called_ && "send was never called"); }
};
void test_welcome_email_sent_with_correct_subject() {
FakeUserRepository repo;
MockEmailService mock{"alice@example.com", "Welcome!"};
UserRegistrationService service{repo, mock};
service.register_user("alice@example.com", "secret");
mock.verify();
}
int main() {
test_register_raises_for_invalid_email(); std::cout << "dummy .... passed\n";
test_register_returns_user_with_correct_email(); std::cout << "null ..... passed\n";
test_register_raises_when_email_already_registered(); std::cout << "stub ..... passed\n";
test_cannot_register_same_email_twice(); std::cout << "fake ..... passed\n";
test_welcome_email_is_sent_on_registration(); std::cout << "spy ...... passed\n";
test_welcome_email_sent_with_correct_subject(); std::cout << "mock ..... passed\n";
return 0;
}Further Reading
Martin Fowler’s Mocks Aren’t Stubs covers the classical vs. mockist philosophies in depth. The terms originate from Gerard Meszaros’ xUnit Test Patterns (2007).