Fake Objects in Testing: When Fakes Beat Mocks

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.

Read more

Start now free