Fake Objects in Testing: When Fakes Beat Mocks
Mocks and stubs get most of the attention in test double discussions, but fake objects solve a different and often more practical problem. When your test setup becomes a maze of when().thenReturn() chains, a fake is usually the right answer.
What Is a Fake Object?
A fake is a working implementation of an interface or class, built specifically for testing. Unlike a stub (which returns hardcoded data) or a mock (which records interactions), a fake has real behavior — just a simpler version of the production implementation.
The canonical example: an in-memory repository instead of one backed by a real database.
# Production implementation
class PostgresUserRepository:
def __init__(self, db_connection):
self.db = db_connection
def save(self, user):
self.db.execute(
"INSERT INTO users (id, email) VALUES (?, ?)",
[user.id, user.email]
)
def find_by_id(self, user_id):
row = self.db.query_one(
"SELECT * FROM users WHERE id = ?", [user_id]
)
return User(**row) if row else None
def find_by_email(self, email):
row = self.db.query_one(
"SELECT * FROM users WHERE email = ?", [email]
)
return User(**row) if row else None
# Fake implementation — no database, just a dict
class FakeUserRepository:
def __init__(self):
self._store = {}
def save(self, user):
self._store[user.id] = user
def find_by_id(self, user_id):
return self._store.get(user_id)
def find_by_email(self, email):
return next(
(u for u in self._store.values() if u.email == email),
None
)Both implement the same interface. The fake has real behavior — saves, finds, returns None on miss — without touching a database.
When to Use Fakes
Use a fake when:
The dependency has multiple methods your tests call. If you need to stub find_by_id, find_by_email, and find_all in every test, you're building a fake from scratch with extra steps — and a worse one, because stubs don't stay consistent with each other.
Test setup is drowning in stub configuration. If your Arrange section is longer than your Assert section, you probably need a fake.
You want tests to interact with state across calls. Stubs return the same value every time; they don't remember that you called save() before find(). Fakes do.
You're testing workflows, not units. Multi-step flows (register → verify email → login) are easier to test with a fake repository that persists state across method calls.
Fakes vs Stubs vs Mocks at a Glance
| Stub | Mock | Fake | |
|---|---|---|---|
| Has real logic | No | No | Yes |
| Remembers state | No | Only call records | Yes |
| Verifies behavior | No | Yes | Optional |
| Setup effort | Low | Low | Medium |
| Best for | Simple returns | Verifying calls | Complex dependencies |
Java Example: Fake Email Service
// Interface used by production code
public interface EmailService {
void sendWelcome(String to);
void sendPasswordReset(String to, String token);
List<String> getSentEmails(); // only on the fake
}
// Fake for testing
public class FakeEmailService implements EmailService {
private final List<String> sentEmails = new ArrayList<>();
private final Map<String, String> resetTokens = new HashMap<>();
@Override
public void sendWelcome(String to) {
sentEmails.add("welcome:" + to);
}
@Override
public void sendPasswordReset(String to, String token) {
sentEmails.add("reset:" + to);
resetTokens.put(to, token);
}
@Override
public List<String> getSentEmails() {
return Collections.unmodifiableList(sentEmails);
}
public String getResetToken(String email) {
return resetTokens.get(email);
}
}
// Test using the fake
@Test
void passwordResetFlowSendsEmailWithToken() {
FakeEmailService fakeEmail = new FakeEmailService();
UserService service = new UserService(new FakeUserRepo(), fakeEmail);
service.initiatePasswordReset("alice@example.com");
assertTrue(fakeEmail.getSentEmails().contains("reset:alice@example.com"));
assertNotNull(fakeEmail.getResetToken("alice@example.com"));
}The fake's extra methods (getResetToken) are a feature — they give tests visibility into the fake's state without changing the interface.
JavaScript: Fake Storage
// Production: uses localStorage
class TokenStorage {
save(key, value) { localStorage.setItem(key, value); }
get(key) { return localStorage.getItem(key); }
remove(key) { localStorage.removeItem(key); }
}
// Fake: in-memory map
class FakeTokenStorage {
constructor() { this._store = new Map(); }
save(key, value) { this._store.set(key, value); }
get(key) { return this._store.get(key) ?? null; }
remove(key) { this._store.delete(key); }
}
test('auth service stores token after login', () => {
const storage = new FakeTokenStorage();
const auth = new AuthService(storage, fakeApiClient);
auth.login('alice@example.com', 'password');
expect(storage.get('auth_token')).not.toBeNull();
});This is cleaner than jest.spyOn(localStorage, 'setItem').mockImplementation(...) for every test.
Testing Fakes Themselves
A fake with a bug is worse than no fake — your tests will pass against the fake and fail against the real implementation. Always test your fakes against the same contract.
import pytest
# Shared contract tests — run against both implementations
class UserRepositoryContract:
def make_repo(self):
raise NotImplementedError
def test_save_and_find(self):
repo = self.make_repo()
user = User(id='u1', email='alice@example.com')
repo.save(user)
assert repo.find_by_id('u1').email == 'alice@example.com'
def test_find_returns_none_on_miss(self):
repo = self.make_repo()
assert repo.find_by_id('nonexistent') is None
def test_find_by_email(self):
repo = self.make_repo()
user = User(id='u1', email='bob@example.com')
repo.save(user)
assert repo.find_by_email('bob@example.com').id == 'u1'
class TestFakeUserRepository(UserRepositoryContract):
def make_repo(self):
return FakeUserRepository()
class TestPostgresUserRepository(UserRepositoryContract):
def make_repo(self):
return PostgresUserRepository(test_db_connection())When both pass the same tests, you have confidence the fake matches the real implementation's behavior.
When Not to Use Fakes
For simple, one-off returns. If you just need get_config() to return {'timeout': 30}, a stub is one line. A fake is overkill.
When the interface is large. A repository with 3 methods is manageable as a fake. A service with 20 methods is not — you'll spend more time maintaining the fake than it saves.
When you're testing error handling. Stubs make it easy to inject specific errors. A fake that simulates failures gets complicated quickly.
The rule of thumb: if setting up stubs for a test takes more lines than the test's actual assertions, consider a fake. If building the fake would take more lines than a hundred tests' worth of stubs, don't bother.