Loading content...
You've learned about stubs, mocks, and fakes. Each has its strengths. Each has its place. But when you're staring at a test you need to write, how do you decide which one to use?
The wrong choice leads to problems:
Choosing correctly is a skill. This page gives you the frameworks to make that choice confidently.
By the end of this page, you will have a systematic decision framework for selecting test doubles. You'll understand how the nature of the dependency, the goal of the test, and the tradeoffs you're willing to accept all influence the choice.
The choice between stub, mock, and fake comes down to three key questions:
What are you testing? — The behavior of your code given certain inputs? The interactions your code makes? A complex stateful workflow?
What type of dependency is it? — A query that returns data? A command that performs side effects? A stateful collaborator?
What are your constraints? — Speed requirements? Test readability? Maintenance burden?
Let's build a decision tree based on these questions.
DECISION TREE: Choosing a Test Double ┌─────────────────────────────────────────────────────────────┐│ What aspect of the system are you testing? │└─────────────────────────────────────────────────────────────┘ │ ┌─────────────────────┼─────────────────────┐ ▼ ▼ ▼┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│ How my code │ │ How my code │ │ How my code behaves ││ RESPONDS to │ │ CALLS its │ │ across MULTIPLE ││ dependency │ │ dependencies│ │ interactions │└─────────────┘ └─────────────┘ └─────────────────────┘ │ │ │ ▼ ▼ ▼ ┌──────┐ ┌──────────┐ ┌──────────┐ │ STUB │ │ MOCK │ │ FAKE │ └──────┘ └──────────┘ └──────────┘ Additional considerations: Is the dependency a QUERY (returns data, no side effects)? → Prefer STUB (control the returned data) Is the dependency a COMMAND (performs action, void/minimal return)? → Prefer MOCK (verify the action was performed) Does testing require STATE PERSISTENCE between calls? → Prefer FAKE (maintains realistic state) Is the scenario simple and one-off? → Prefer INLINE STUB (quick, readable, no class needed) Will multiple tests need similar setups? → Prefer REUSABLE STUB or FAKE (DRY, maintainable)Command-Query Separation provides a useful heuristic: Stub queries, mock commands. Queries return data—you control what data with stubs. Commands perform actions—you verify the actions with mocks. This simple rule covers 80% of cases.
Stubs are your default choice for query dependencies—methods that return data without side effects. When you need to control what your system under test receives, reach for a stub.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// SCENARIO: DiscountCalculator uses PricingService to get base prices// Testing: How DiscountCalculator responds to different price scenarios interface PricingService { getBasePrice(productId: string): number; getDiscountRate(userId: string): number;} describe('DiscountCalculator', () => { // Use STUB: We need to control what prices are returned // We don't care HOW PricingService is called it('should apply percentage discount correctly', () => { // STUB: Control the inputs const pricing: PricingService = { getBasePrice: () => 100, // Fixed return for this test getDiscountRate: () => 0.15, // 15% discount }; const calculator = new DiscountCalculator(pricing); // TEST: Verify the calculation logic expect(calculator.calculate('product-1', 'user-1')).toBe(85); }); it('should handle zero discount', () => { const pricing: PricingService = { getBasePrice: () => 100, getDiscountRate: () => 0, // No discount }; const calculator = new DiscountCalculator(pricing); expect(calculator.calculate('product-1', 'user-1')).toBe(100); }); it('should handle products not found', () => { const pricing: PricingService = { getBasePrice: () => { throw new ProductNotFoundError(); }, getDiscountRate: () => 0, }; const calculator = new DiscountCalculator(pricing); expect(() => calculator.calculate('unknown', 'user-1')) .toThrow(ProductNotFoundError); });}); // Why NOT mock here?// - We don't care that getBasePrice was called with 'product-1'// - We don't care about call count// - Mocking would over-specify the test// - If we refactored to batch-fetch prices, mock-based test breaks unnecessarilyMocks are your choice when the interaction itself is the behavior being tested. When correctness depends on calling the right method with the right arguments, you need a mock.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// SCENARIO: OrderService sends notifications when orders complete// Testing: That the correct notifications are sent interface NotificationService { sendOrderConfirmation(email: string, orderId: string): void; sendShippingUpdate(email: string, trackingNumber: string): void;} describe('OrderService', () => { // Use MOCK: The notification IS the behavior we're testing // We must verify it was called correctly it('should send confirmation email when order completes', () => { // MOCK: Will verify interactions const notifications = { sendOrderConfirmation: vi.fn(), sendShippingUpdate: vi.fn(), }; const service = new OrderService(notifications); service.completeOrder({ id: 'order-123', customerEmail: 'alice@example.com', }); // VERIFY: Check the mock received correct calls expect(notifications.sendOrderConfirmation).toHaveBeenCalledWith( 'alice@example.com', 'order-123' ); expect(notifications.sendOrderConfirmation).toHaveBeenCalledTimes(1); }); it('should not send email for cancelled orders', () => { const notifications = { sendOrderConfirmation: vi.fn(), sendShippingUpdate: vi.fn(), }; const service = new OrderService(notifications); service.cancelOrder({ id: 'order-456', ... }); // VERIFY: Notification should NOT be sent expect(notifications.sendOrderConfirmation).not.toHaveBeenCalled(); }); it('should send shipping update with tracking number', () => { const notifications = { sendOrderConfirmation: vi.fn(), sendShippingUpdate: vi.fn(), }; const service = new OrderService(notifications); service.shipOrder({ orderId: 'order-789', customerEmail: 'bob@example.com', trackingNumber: 'TRACK-ABC-123', }); // VERIFY: Correct arguments passed expect(notifications.sendShippingUpdate).toHaveBeenCalledWith( 'bob@example.com', 'TRACK-ABC-123' ); });}); // Why NOT stub here?// - Stubs can't verify calls were made// - We need to assert on the INTERACTION, not return values// - The notification service might return void// - The behavior IS making the call correctlyFakes are your choice for complex, stateful scenarios where stubs would be too cumbersome and mocks would over-specify. When you need realistic behavior across multiple interactions, reach for a fake.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// SCENARIO: ShoppingCartService with complex multi-step workflows// Testing: Full cart lifecycle with realistic behavior interface CartRepository { findByUserId(userId: string): Cart | null; save(cart: Cart): Cart; delete(cartId: string): void;} describe('ShoppingCartService', () => { // Use FAKE: Complex stateful workflow across multiple calls // Stubs would require configuring each response manually // Mocks would over-specify internal implementation it('should handle complete shopping workflow', () => { // FAKE: Maintains state across interactions const cartRepo = new InMemoryCartRepository(); const productRepo = new InMemoryProductRepository(); // Seed products productRepo.save({ id: 'p1', name: 'Widget', price: 10 }); productRepo.save({ id: 'p2', name: 'Gadget', price: 25 }); const service = new ShoppingCartService(cartRepo, productRepo); // Complex workflow service.addItem('user-1', 'p1', 3); // Add 3 widgets service.addItem('user-1', 'p2', 1); // Add 1 gadget service.removeItem('user-1', 'p1', 1);// Remove 1 widget service.addItem('user-1', 'p1', 2); // Add 2 more widgets // Verify final state const cart = service.getCart('user-1'); expect(cart.getQuantity('p1')).toBe(4); // 3 - 1 + 2 expect(cart.getQuantity('p2')).toBe(1); expect(cart.getTotal()).toBe(65); // (4 × $10) + (1 × $25) }); it('should apply discount codes correctly', () => { const cartRepo = new InMemoryCartRepository(); const productRepo = new InMemoryProductRepository(); const discountRepo = new InMemoryDiscountRepository(); productRepo.save({ id: 'p1', price: 100 }); discountRepo.save({ code: 'SAVE20', percent: 20, minOrder: 50 }); const service = new ShoppingCartService(cartRepo, productRepo, discountRepo); service.addItem('user-1', 'p1', 2); service.applyDiscount('user-1', 'SAVE20'); expect(service.getCart('user-1').getTotal()).toBe(160); // $200 - 20% });}); // Why FAKE is better than STUB here:// With stubs, you'd need:// - Configure findByUserId to return null (first call), then cart (subsequent)// - Track what was "saved" to configure later reads// - Manually maintain consistency// This is exactly what a fake does automatically // Why FAKE is better than MOCK here:// - We don't care WHICH repository methods are called internally// - Implementation might cache, batch, or optimize// - We care about the END RESULT, not the internal mechanicsReal-world tests often require multiple dependencies, each potentially suited to a different test double type. Combining them thoughtfully creates focused, effective tests.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
// SCENARIO: OrderProcessor with multiple dependencies// Each dependency suits a different test double type interface OrderRepository { /* ... */ } // Stateful → FAKEinterface PricingService { /* ... */ } // Query → STUBinterface NotificationService { /* ... */ } // Command → MOCKinterface PaymentGateway { /* ... */ } // External → STUB describe('OrderProcessor - complete order flow', () => { let orderRepo: InMemoryOrderRepository; // FAKE: stateful let pricingService: PricingService; // STUB: control inputs let notificationService: MockNotificationService; // MOCK: verify actions let paymentGateway: StubPaymentGateway; // STUB: control responses let processor: OrderProcessor; beforeEach(() => { // Each test double chosen for its purpose orderRepo = new InMemoryOrderRepository(); pricingService = { getPrice: () => 100, getDiscount: () => 0.1, }; notificationService = new MockNotificationService(); paymentGateway = new StubPaymentGateway().willSucceed(); processor = new OrderProcessor( orderRepo, pricingService, notificationService, paymentGateway ); }); it('should process order and send notification on success', async () => { const order = createOrder({ customerId: 'c-1', items: [{ productId: 'p-1', quantity: 2 }], }); // ACT await processor.processOrder(order); // ASSERT using appropriate double methods // Use FAKE to verify state persisted const savedOrder = orderRepo.findByCustomerId('c-1')[0]; expect(savedOrder.status).toBe('COMPLETED'); expect(savedOrder.total).toBe(180); // 2 × $100 × 0.9 // Use MOCK to verify notification sent notificationService.verifyConfirmationSent( order.customerEmail, expect.stringContaining('COMPLETED') ); }); it('should not send notification when payment fails', async () => { // Use STUB to simulate payment failure paymentGateway = new StubPaymentGateway().willFail('Card declined'); processor = new OrderProcessor( orderRepo, pricingService, notificationService, paymentGateway ); const order = createOrder({ customerId: 'c-1' }); await expect(processor.processOrder(order)) .rejects.toThrow(PaymentFailedError); // Use FAKE to verify order status const orders = orderRepo.findByCustomerId('c-1'); expect(orders[0].status).toBe('PAYMENT_FAILED'); // Use MOCK to verify no notification notificationService.verifyNoConfirmationSent(); });}); // ANALYSIS: Why each choice?// // OrderRepository → FAKE// - Multiple operations (save, find)// - State must persist between calls// - Need realistic query behavior// // PricingService → STUB// - Just returns data// - Don't care how it's called// - Control the test inputs// // NotificationService → MOCK// - Side effect is the behavior// - Need to verify call happened// - Need to verify arguments// // PaymentGateway → STUB// - External system// - Control success/failure// - Don't verify exact API calls (implementation detail)Don't feel constrained to use only one type. A single test might use a fake repository (for stateful persistence), a stub configuration service (for controlled inputs), and a mock event publisher (for verified side effects). Choose each based on its purpose in the test.
This matrix summarizes when to use each test double based on common scenarios and dependency types:
| Scenario | Best Choice | Why |
|---|---|---|
| Repository findById returns User | Stub | Query returning data, control inputs |
| Repository save(user) | Fake or Mock | Fake if testing persistence; Mock if verifying save was called |
| EmailService.send(email) | Mock | Command/side effect, verify it was called correctly |
| Logger.log(message) | Mock (usually) | Verify logging; or ignore with Dummy |
| PaymentGateway.charge(amount) | Stub | Control success/failure response |
| Cache.get(key) / set(key) | Fake | Stateful, need realistic caching behavior |
| TimeProvider.now() | Stub or Fake | Control time for deterministic tests |
| EventBus.publish(event) | Mock or Fake | Mock to verify; Fake if handlers should execute |
| ConfigService.getValue(key) | Stub | Query, return test-specific configuration |
| Full CRUD workflow | Fake | Multiple operations, state persistence needed |
| Characteristic | Stub | Mock | Fake |
|---|---|---|---|
| Primary purpose | Control inputs | Verify outputs | Realistic behavior |
| Maintains state | Usually no | Records calls only | Yes |
| Contains logic | Minimal | Tracking only | Yes, working impl |
| Setup complexity | Low | Medium | Higher |
| Maintenance cost | Low | Low-Medium | Higher |
| Brittleness risk | Low | High if over-used | Medium if outdated |
| Test readability | High | High | High for complex scenarios |
| Best for | Query deps | Command deps | Stateful deps |
Choosing the wrong test double is common. Here are frequent mistakes and how to correct them:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
// ❌ MISTAKE 1: Mocking when you should stub// Over-verifying query calls creates brittle tests it('WRONG: mocks a query', () => { const repo = { findById: vi.fn().mockReturnValue({ id: '1', name: 'Alice' }), }; const service = new UserService(repo); const user = service.getProfile('1'); // ❌ Unnecessary verification - testing implementation detail expect(repo.findById).toHaveBeenCalledWith('1'); expect(repo.findById).toHaveBeenCalledTimes(1);}); // ✅ CORRECTION: Stub the query, assert on resultit('RIGHT: stubs the query', () => { const repo = { findById: () => ({ id: '1', name: 'Alice' }), }; const service = new UserService(repo); const user = service.getProfile('1'); // ✅ Test the behavior, not the mechanism expect(user.name).toBe('Alice');}); // ❌ MISTAKE 2: Stubbing when you should mock// Commands need verification, not just non-failure it('WRONG: stubs a command', () => { const emailService = { sendEmail: () => {}, // Stub: does nothing }; const service = new RegistrationService(emailService); service.register({ email: 'new@user.com' }); // ❌ No assertion on email being sent! // Test passes even if sendEmail was never called}); // ✅ CORRECTION: Mock the command, verify it was calledit('RIGHT: mocks the command', () => { const emailService = { sendEmail: vi.fn(), }; const service = new RegistrationService(emailService); service.register({ email: 'new@user.com' }); // ✅ Verify the side effect occurred expect(emailService.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ to: 'new@user.com' }) );}); // ❌ MISTAKE 3: Stubs for stateful scenarios// Leads to complex, fragile test setup it('WRONG: stubs for stateful workflow', () => { let cartState: Item[] = []; const repo = { findByUserId: vi.fn().mockImplementation(() => ({ items: [...cartState] })), save: vi.fn().mockImplementation((cart) => { cartState = cart.items; }), }; const service = new CartService(repo); // ❌ Manually tracking state in stubs - error prone! service.addItem('user-1', 'product-1', 2); service.addItem('user-1', 'product-2', 1); // State management here is complex and easy to get wrong}); // ✅ CORRECTION: Use a fake for stateful behaviorit('RIGHT: fake for stateful workflow', () => { const repo = new InMemoryCartRepository(); const service = new CartService(repo); // ✅ Clean, simple - fake handles state automatically service.addItem('user-1', 'product-1', 2); service.addItem('user-1', 'product-2', 1); const cart = service.getCart('user-1'); expect(cart.items).toHaveLength(2);}); // ❌ MISTAKE 4: Fakes for simple scenarios// Over-engineering when stub would suffice it('WRONG: fake for single return value', () => { const userRepo = new InMemoryUserRepository(); userRepo.save({ id: '1', name: 'Alice', role: 'ADMIN' }); const authService = new AuthService(userRepo); // ❌ Created entire fake just to return one user expect(authService.isAdmin('1')).toBe(true);}); // ✅ CORRECTION: Simple stub is enoughit('RIGHT: stub for single value', () => { const userRepo = { findById: () => ({ id: '1', name: 'Alice', role: 'ADMIN' }), }; const authService = new AuthService(userRepo); // ✅ Less setup, equally clear expect(authService.isAdmin('1')).toBe(true);});We've developed a comprehensive framework for choosing between stubs, mocks, and fakes. Here are the key decision principles:
The simple heuristic:
"What am I testing? If I'm testing how my code USES data, stub the source. If I'm testing that my code DOES something, mock the target. If I'm testing WORKFLOWS, fake the collaborators."
Module complete:
You now have mastery over the three essential test doubles. You understand:
This knowledge transforms your ability to write isolated, fast, reliable unit tests that give you confidence without brittleness.
You've mastered test doubles—the essential tools for isolating code under test. You can now confidently choose between stubs, mocks, and fakes based on systematic criteria. Combined with your understanding of each double's implementation and pitfalls, you're equipped to write tests that are fast, focused, and maintainable.