Loading learning content...
Stubs return canned values. Mocks verify interactions. Both are powerful tools—but they share a fundamental limitation: they don't actually do anything.
Consider testing a ShoppingCartService that adds items, calculates totals, applies discounts, and tracks inventory. With stubs, you'd configure every response. With mocks, you'd verify every call. But what if you want to test the actual workflow—adding multiple items, removing some, applying a coupon, checking the final total?
class ShoppingCartService {
constructor(private cartRepository: CartRepository) {}
addItem(userId: string, productId: string, quantity: number): void {
const cart = this.cartRepository.findByUserId(userId);
cart.addItem(productId, quantity);
this.cartRepository.save(cart);
}
getTotal(userId: string): number {
const cart = this.cartRepository.findByUserId(userId);
return cart.calculateTotal();
}
}
With stubs, you'd have to manually configure each response for each state transition. It becomes unwieldy. What you really want is a CartRepository that actually works—stores data, retrieves it, maintains consistency—just without needing a real database.
By the end of this page, you will understand fakes as test doubles with real, working implementations. You'll learn when fakes are the superior choice, how to design them for reliability and maintainability, and patterns for creating fakes that provide production-like behavior without production complexity.
A fake is a test double that has a working implementation, but takes shortcuts that make it unsuitable for production. Unlike stubs (pre-programmed responses) or mocks (interaction verification), fakes actually do the work—just in a simplified way.
Key characteristics of fakes:
Comparing the three test doubles:
| Aspect | Stub | Mock | Fake |
|---|---|---|---|
| Primary purpose | Control inputs | Verify interactions | Provide working behavior |
| Has logic? | No | No | Yes |
| Maintains state? | Usually no | Records calls only | Yes |
| Assertion target | SUT's return value | Mock's received calls | SUT's behavior over time |
| Production-like? | No | No | Somewhat—simplified |
The classic example:
PostgresUserRepository — Uses PostgreSQL databaseUser objects based on configurationfindById, save calls for verificationInMemoryUserRepository — Uses a Map<string, User> instead of PostgreSQLUnlike stubs and mocks, fakes require actual implementation effort. They contain logic, can have bugs, and need testing themselves. However, this investment pays off in test reliability and readability—your tests work with something that behaves realistically.
The most common fake pattern is the in-memory implementation—replacing database, file, or network storage with in-memory data structures.
Why in-memory fakes work:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// Real interface - implemented by both production and fakeinterface UserRepository { findById(id: string): User | null; findByEmail(email: string): User | null; save(user: User): User; delete(id: string): void; findAll(): User[]; exists(id: string): boolean;} // FAKE: In-memory implementation with real behaviorclass InMemoryUserRepository implements UserRepository { private users: Map<string, User> = new Map(); private emailIndex: Map<string, string> = new Map(); // email -> id findById(id: string): User | null { const user = this.users.get(id); // Return a copy to simulate detached entities (like JPA) return user ? { ...user } : null; } findByEmail(email: string): User | null { const id = this.emailIndex.get(email.toLowerCase()); return id ? this.findById(id) : null; } save(user: User): User { // Generate ID if new (mimics auto-increment) if (!user.id) { user = { ...user, id: this.generateId() }; } // Handle email uniqueness constraint const existing = this.users.get(user.id); if (existing && existing.email !== user.email) { // Email changed - update index this.emailIndex.delete(existing.email.toLowerCase()); } // Check for email collision with OTHER users const emailOwner = this.emailIndex.get(user.email.toLowerCase()); if (emailOwner && emailOwner !== user.id) { throw new DuplicateEmailError(user.email); } // Save this.users.set(user.id, { ...user }); this.emailIndex.set(user.email.toLowerCase(), user.id); return { ...user }; // Return copy } delete(id: string): void { const user = this.users.get(id); if (user) { this.emailIndex.delete(user.email.toLowerCase()); this.users.delete(id); } } findAll(): User[] { return Array.from(this.users.values()).map(u => ({ ...u })); } exists(id: string): boolean { return this.users.has(id); } private generateId(): string { return 'user-' + Math.random().toString(36).substr(2, 9); } // Test helper: clear all data clear(): void { this.users.clear(); this.emailIndex.clear(); } // Test helper: get count without loading all count(): number { return this.users.size; }}Notice how the fake:
clear() and count() methods for test convenienceThis fake behaves like a database repository. Tests using it exercise real workflows.
Fakes shine when you need realistic behavior without production infrastructure. They're the right choice when stubs would be too cumbersome and real dependencies would be too slow or complex.
Use fakes when:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// USE CASE 1: Testing stateful workflowsdescribe('ShoppingCartService with fake repository', () => { let cartRepository: InMemoryCartRepository; let service: ShoppingCartService; beforeEach(() => { cartRepository = new InMemoryCartRepository(); service = new ShoppingCartService(cartRepository); }); it('should calculate total after multiple item additions', () => { const userId = 'user-1'; // Multi-step workflow - fake maintains state between calls service.addItem(userId, 'product-a', 2); // $10 each service.addItem(userId, 'product-b', 1); // $25 each service.addItem(userId, 'product-a', 1); // Add more of product-a const total = service.getTotal(userId); // Fake repository remembered all additions expect(total).toBe(55); // (3 × $10) + (1 × $25) }); it('should handle item removal correctly', () => { const userId = 'user-1'; service.addItem(userId, 'product-a', 5); service.removeItem(userId, 'product-a', 2); const cart = service.getCart(userId); expect(cart.getQuantity('product-a')).toBe(3); });}); // USE CASE 2: Testing business logic that reads back writesdescribe('InventoryService with fake repository', () => { it('should prevent overselling', () => { const inventoryRepo = new InMemoryInventoryRepository(); // Pre-populate inventory inventoryRepo.setStock('product-1', 10); const service = new InventoryService(inventoryRepo); // First order takes 7 units service.reserveStock('product-1', 7); // Second order requests 5 units - only 3 available expect(() => service.reserveStock('product-1', 5)) .toThrow(InsufficientStockError); // Verify remaining stock expect(inventoryRepo.getStock('product-1')).toBe(3); });}); // USE CASE 3: Testing complex queriesdescribe('ProductCatalog with fake repository', () => { it('should filter and paginate products', () => { const catalog = new InMemoryProductRepository(); // Seed with test data catalog.addProduct({ id: '1', name: 'Widget A', category: 'widgets', price: 10 }); catalog.addProduct({ id: '2', name: 'Widget B', category: 'widgets', price: 20 }); catalog.addProduct({ id: '3', name: 'Gadget A', category: 'gadgets', price: 30 }); catalog.addProduct({ id: '4', name: 'Widget C', category: 'widgets', price: 15 }); catalog.addProduct({ id: '5', name: 'Widget D', category: 'widgets', price: 25 }); const service = new ProductCatalogService(catalog); // Complex query: category filter + price range + pagination const results = service.search({ category: 'widgets', maxPrice: 22, page: 1, pageSize: 2, sortBy: 'price', }); expect(results.items).toHaveLength(2); expect(results.items[0].name).toBe('Widget A'); // $10, sorted first expect(results.items[1].name).toBe('Widget C'); // $15 expect(results.totalCount).toBe(3); // Widget A, C, B all match expect(results.hasNextPage).toBe(true); });}); // USE CASE 4: Testing event sourcingdescribe('AccountAggregate with fake event store', () => { it('should rebuild state from events', () => { const eventStore = new InMemoryEventStore(); // Simulate historical events eventStore.append('account-1', [ new AccountOpened('account-1', 'Alice', new Date('2024-01-01')), new MoneyDeposited('account-1', 100, new Date('2024-01-15')), new MoneyWithdrawn('account-1', 30, new Date('2024-01-20')), new MoneyDeposited('account-1', 50, new Date('2024-02-01')), ]); // Load aggregate - should rebuild from events const account = Account.loadFrom(eventStore, 'account-1'); expect(account.balance).toBe(120); // 100 - 30 + 50 expect(account.ownerName).toBe('Alice'); });});Fakes cannot replace integration testing entirely. Real databases have quirks (transaction isolation, connection limits, specific SQL syntax) that fakes can't replicate. Use fakes for unit and component testing, but still run integration tests against real infrastructure periodically.
Because fakes contain real logic, they require careful design. A buggy fake produces false positives (tests pass when production would fail) or false negatives (tests fail incorrectly).
Principles for designing reliable fakes:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
// A well-designed fake that mirrors production behavior interface OrderRepository { save(order: Order): Order; findById(id: string): Order | null; findByCustomerId(customerId: string): Order[]; findByStatus(status: OrderStatus): Order[]; countByStatusSince(status: OrderStatus, since: Date): number;} class InMemoryOrderRepository implements OrderRepository { private orders: Map<string, Order> = new Map(); private idSequence: number = 1; save(order: Order): Order { // Generate ID for new orders (mimics database auto-increment) if (!order.id) { order = { ...order, id: `order-${this.idSequence++}` }; } // Enforce invariants that the real database would enforce if (!order.customerId) { throw new ConstraintViolationError('customerId is required'); } if (order.items.length === 0) { throw new ConstraintViolationError('Order must have at least one item'); } // Check for concurrent modification (optimistic locking) const existing = this.orders.get(order.id); if (existing && existing.version !== order.version) { throw new OptimisticLockException( `Order ${order.id} was modified by another transaction` ); } // Increment version on save const savedOrder = { ...order, version: (order.version || 0) + 1, updatedAt: new Date(), }; this.orders.set(savedOrder.id, savedOrder); // Return a copy (simulates detached entity) return { ...savedOrder }; } findById(id: string): Order | null { const order = this.orders.get(id); return order ? { ...order } : null; } findByCustomerId(customerId: string): Order[] { return Array.from(this.orders.values()) .filter(o => o.customerId === customerId) .map(o => ({ ...o })); // Return copies } findByStatus(status: OrderStatus): Order[] { return Array.from(this.orders.values()) .filter(o => o.status === status) .map(o => ({ ...o })); } countByStatusSince(status: OrderStatus, since: Date): number { return Array.from(this.orders.values()) .filter(o => o.status === status && o.createdAt >= since) .length; } // Test helpers clear(): void { this.orders.clear(); this.idSequence = 1; } seedWith(orders: Order[]): void { for (const order of orders) { this.orders.set(order.id, { ...order }); const idNum = parseInt(order.id.replace('order-', '')); if (idNum >= this.idSequence) { this.idSequence = idNum + 1; } } } // For debugging tests dump(): Order[] { return Array.from(this.orders.values()); }} // TESTING THE FAKE ITSELFdescribe('InMemoryOrderRepository', () => { let repo: InMemoryOrderRepository; beforeEach(() => { repo = new InMemoryOrderRepository(); }); it('should generate unique IDs for new orders', () => { const order1 = repo.save(createOrder({ customerId: 'c1' })); const order2 = repo.save(createOrder({ customerId: 'c1' })); expect(order1.id).toBeDefined(); expect(order2.id).toBeDefined(); expect(order1.id).not.toBe(order2.id); }); it('should enforce customerId constraint', () => { const order = createOrder({ customerId: '' }); expect(() => repo.save(order)) .toThrow(ConstraintViolationError); }); it('should detect optimistic lock conflicts', () => { const order = repo.save(createOrder({ customerId: 'c1' })); // Simulate concurrent modification const copy1 = { ...order }; const copy2 = { ...order }; repo.save({ ...copy1, status: 'SHIPPED' }); // Succeeds, increments version // Stale version should fail expect(() => repo.save({ ...copy2, status: 'CANCELLED' })) .toThrow(OptimisticLockException); }); it('should return copies not references', () => { const order = repo.save(createOrder({ customerId: 'c1', status: 'PENDING' })); // Modify the returned object order.status = 'SHIPPED'; // Repository should not be affected const retrieved = repo.findById(order.id); expect(retrieved?.status).toBe('PENDING'); });});Several patterns have emerged for implementing fakes across different types of dependencies. Understanding these patterns provides templates for building your own fakes.
Pattern 1: In-Memory Cache Fake
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
// Fake cache with expiration behavior interface Cache<T> { get(key: string): T | null; set(key: string, value: T, ttlSeconds?: number): void; delete(key: string): void; clear(): void;} class InMemoryCache<T> implements Cache<T> { private store: Map<string, { value: T; expiresAt: number | null }> = new Map(); private now: () => number; constructor(timeProvider?: () => number) { // Allow injecting time for testing expiration this.now = timeProvider ?? (() => Date.now()); } get(key: string): T | null { const entry = this.store.get(key); if (!entry) { return null; } // Check expiration if (entry.expiresAt !== null && this.now() > entry.expiresAt) { this.store.delete(key); return null; } return entry.value; } set(key: string, value: T, ttlSeconds?: number): void { const expiresAt = ttlSeconds ? this.now() + (ttlSeconds * 1000) : null; this.store.set(key, { value, expiresAt }); } delete(key: string): void { this.store.delete(key); } clear(): void { this.store.clear(); } // Test helpers size(): number { return this.store.size; }} // Usage with controllable timedescribe('CachedProductService', () => { it('should return cached value within TTL', () => { let currentTime = 1000000; const cache = new InMemoryCache<Product>(() => currentTime); const service = new CachedProductService(cache, productApi); // First call - cache miss, hits API const product1 = service.getProduct('p-1'); // Advance time but within TTL currentTime += 30_000; // 30 seconds later // Second call - cache hit const product2 = service.getProduct('p-1'); expect(productApi.get).toHaveBeenCalledTimes(1); // Only one API call }); it('should refetch after TTL expires', () => { let currentTime = 1000000; const cache = new InMemoryCache<Product>(() => currentTime); const service = new CachedProductService(cache, productApi, { ttl: 60 }); service.getProduct('p-1'); // Advance time past TTL currentTime += 120_000; // 2 minutes later service.getProduct('p-1'); expect(productApi.get).toHaveBeenCalledTimes(2); // Cache expired });});Pattern 2: Fake Event Bus
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Fake event bus with synchronous handler execution interface EventBus { publish<T extends DomainEvent>(event: T): void; subscribe<T extends DomainEvent>( eventType: new (...args: any[]) => T, handler: EventHandler<T> ): void;} class InMemoryEventBus implements EventBus { private handlers: Map<string, EventHandler<any>[]> = new Map(); private publishedEvents: DomainEvent[] = []; publish<T extends DomainEvent>(event: T): void { this.publishedEvents.push(event); // Execute handlers synchronously (unlike production async) const eventType = event.constructor.name; const eventHandlers = this.handlers.get(eventType) || []; for (const handler of eventHandlers) { handler.handle(event); } } subscribe<T extends DomainEvent>( eventType: new (...args: any[]) => T, handler: EventHandler<T> ): void { const typeName = eventType.name; const existing = this.handlers.get(typeName) || []; this.handlers.set(typeName, [...existing, handler]); } // Test helpers getPublishedEvents(): DomainEvent[] { return [...this.publishedEvents]; } getPublishedEventsOfType<T extends DomainEvent>( eventType: new (...args: any[]) => T ): T[] { return this.publishedEvents.filter( e => e instanceof eventType ) as T[]; } clear(): void { this.publishedEvents = []; this.handlers.clear(); }} // Usagedescribe('OrderService with fake event bus', () => { it('should publish and handle order events', () => { const eventBus = new InMemoryEventBus(); const notificationService = new NotificationService(); // Set up handler eventBus.subscribe(OrderCompletedEvent, { handle: (event) => notificationService.sendConfirmation(event.orderId) }); const orderService = new OrderService(eventBus); // Complete order - triggers event publication orderService.completeOrder({ id: 'order-1', customerId: 'c-1' }); // Verify event was published const events = eventBus.getPublishedEventsOfType(OrderCompletedEvent); expect(events).toHaveLength(1); expect(events[0].orderId).toBe('order-1'); });});Pattern 3: Fake Time Provider
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Fake clock for testing time-dependent logic interface Clock { now(): Date; todayStart(): Date; addDays(date: Date, days: number): Date;} class FakeClock implements Clock { private currentTime: Date; constructor(initialTime: Date = new Date()) { this.currentTime = new Date(initialTime); } now(): Date { return new Date(this.currentTime); } todayStart(): Date { const date = new Date(this.currentTime); date.setHours(0, 0, 0, 0); return date; } addDays(date: Date, days: number): Date { const result = new Date(date); result.setDate(result.getDate() + days); return result; } // Control methods for tests advance(milliseconds: number): void { this.currentTime = new Date(this.currentTime.getTime() + milliseconds); } advanceDays(days: number): void { this.advance(days * 24 * 60 * 60 * 1000); } advanceHours(hours: number): void { this.advance(hours * 60 * 60 * 1000); } setTo(time: Date): void { this.currentTime = new Date(time); }} // Usagedescribe('SubscriptionService with fake clock', () => { it('should expire subscriptions after end date', () => { const clock = new FakeClock(new Date('2024-01-01')); const repository = new InMemorySubscriptionRepository(); const service = new SubscriptionService(repository, clock); // Create subscription valid for 30 days const sub = service.createSubscription('user-1', 30); expect(service.isActive(sub.id)).toBe(true); // Advance 15 days - still active clock.advanceDays(15); expect(service.isActive(sub.id)).toBe(true); // Advance another 20 days - now expired clock.advanceDays(20); expect(service.isActive(sub.id)).toBe(false); }); it('should calculate renewal date correctly', () => { const clock = new FakeClock(new Date('2024-02-28')); // Leap year edge case const service = new SubscriptionService(new InMemorySubscriptionRepository(), clock); const sub = service.createSubscription('user-1', 30); // Should handle February -> March transition expect(sub.endDate).toEqual(new Date('2024-03-29')); });});Every testing strategy involves trade-offs. Fakes provide speed and simplicity but sacrifice production parity. Understanding these trade-offs helps you make informed decisions about when to use fakes versus real dependencies.
Use fakes extensively for unit and component tests (the broad base of the pyramid). Complement with fewer integration tests against real infrastructure (the middle) and even fewer end-to-end tests (the tip). This gives you fast feedback for most scenarios while still catching production-specific issues.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Example: Balancing fakes and real dependencies // 1. UNIT TESTS - Use fakes for speed and isolationdescribe('OrderService - business logic', () => { // Hundreds of unit tests with fakes // Run in milliseconds it('should calculate correct totals', () => { const repo = new InMemoryOrderRepository(); const service = new OrderService(repo); // ... test business logic ... });}); // 2. COMPONENT TESTS - Use fakes for integration between componentsdescribe('Order workflow integration', () => { // Dozens of tests with multiple fake collaborators it('should complete full order lifecycle', () => { const orderRepo = new InMemoryOrderRepository(); const inventoryRepo = new InMemoryInventoryRepository(); const eventBus = new InMemoryEventBus(); // Wire up real business logic, fake infrastructure const service = new OrderOrchestrator( new OrderService(orderRepo), new InventoryService(inventoryRepo), eventBus ); // Test full workflow service.processOrder(order); });}); // 3. INTEGRATION TESTS - Use real dependencies for critical pathsdescribe('OrderRepository - PostgreSQL', () => { // Fewer tests, slower, but verify production behavior beforeAll(async () => { await database.connect(); await database.migrate(); }); afterEach(async () => { await database.truncateTables(); }); it('should persist and retrieve orders', async () => { const repo = new PostgresOrderRepository(database); const saved = await repo.save(order); const retrieved = await repo.findById(saved.id); expect(retrieved).toEqual(saved); }); it('should handle concurrent modifications correctly', async () => { // Test actual database locking behavior });});Fakes are the most complex test doubles to implement correctly. Their power comes with responsibility—a buggy fake produces misleading test results.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// ❌ PITFALL 1: Incomplete behaviorclass IncompleteFakeRepository implements UserRepository { private users: Map<string, User> = new Map(); findById(id: string): User | null { return this.users.get(id) ?? null; } save(user: User): User { this.users.set(user.id, user); return user; } // ❌ Not implemented - will throw or return undefined findByEmail(email: string): User | null { throw new Error('Not implemented'); // Tests never exercise this path } // ❌ Missing entirely - class doesn't fully implement interface // delete(id: string): void { ... }} // ✅ BETTER: Implement all methods or fail explicitlyclass CompleteFakeRepository implements UserRepository { findByEmail(email: string): User | null { // Actually implement the query for (const user of this.users.values()) { if (user.email.toLowerCase() === email.toLowerCase()) { return { ...user }; } } return null; }} // ❌ PITFALL 2: Missing constraintsclass NaiveFakeRepository implements UserRepository { save(user: User): User { // ❌ No uniqueness check - production DB would throw! this.users.set(user.id, user); return user; }} // Production code might rely on DB catching duplicate emails:// try {// repo.save(userWithDuplicateEmail);// } catch (e) {// if (e instanceof DuplicateKeyError) {// throw new EmailAlreadyExistsError();// }// }// With naive fake: no error thrown, bug undetected // ❌ PITFALL 3: Reference leakingclass LeakyFakeRepository implements UserRepository { save(user: User): User { this.users.set(user.id, user); // Stores reference directly return user; // Returns same reference } findById(id: string): User | null { return this.users.get(id); // Returns internal reference! }} // This test passes incorrectly:it('dangerous: mutation affects repository', () => { const repo = new LeakyFakeRepository(); const user = repo.save({ id: '1', name: 'Alice', email: 'a@b.com' }); user.name = 'Modified'; // Mutates internal storage! const retrieved = repo.findById('1'); expect(retrieved.name).toBe('Modified'); // ❌ PASSES but would fail with real DB}); // ✅ BETTER: Return copiesclass SafeFakeRepository implements UserRepository { save(user: User): User { const copy = { ...user }; this.users.set(copy.id, copy); return { ...copy }; // Return a different copy } findById(id: string): User | null { const user = this.users.get(id); return user ? { ...user } : null; // Always return copies }}We've comprehensively covered fakes—test doubles with working implementations that provide realistic behavior without production complexity. Let's consolidate the insights:
What's next:
We've now covered all three major test doubles: stubs for controlling inputs, mocks for verifying interactions, and fakes for realistic behavior. The final question is: how do you choose the right one?
In the next page, we'll develop a systematic framework for selecting the appropriate test double based on testing goals, dependency characteristics, and design considerations.
You now have a comprehensive understanding of fakes—their purpose, design, patterns, and pitfalls. You can confidently implement in-memory repositories, cache fakes, event bus fakes, and time providers that enable fast, reliable testing of complex stateful logic.