Loading content...
Mocking is a powerful tool that, like any powerful tool, can be misused. Tests that mock everything become disconnected from reality. Tests that mock too little become slow and non-deterministic. Tests that mock incorrectly provide false confidence.
The art of effective mocking lies in finding the right balance—mocking what should be mocked, keeping real what should stay real, and structuring tests so they remain valuable as the codebase evolves. This page distills hard-won wisdom from decades of collective testing experience into actionable best practices.
By the end of this page, you will understand what should and shouldn't be mocked, how to write mock configurations that remain maintainable, when to prefer stubs over mocks, how to avoid tests that are coupled to implementation details, and patterns for keeping assertion logic clear and valuable.
The decision of what to mock is perhaps the most important mocking decision. Mock too much, and your tests become meaningless theater. Mock too little, and you lose the benefits of unit testing.
The general principle:
Mock the things that make tests slow, non-deterministic, or have unacceptable side effects. Don't mock the thing you're actually testing or its simple value objects.
| Mock (Typically) | Keep Real (Typically) | Reason |
|---|---|---|
| External services (APIs, databases) | The class under test | Avoid side effects vs test actual behavior |
| Time/clock | Pure business logic | Control time vs test real calculations |
| Random number generators | Value objects (Money, Date ranges) | Determinism vs they're part of domain logic |
| File system access | Simple data transformations | Avoid I/O vs they're what we're testing |
| Network calls | In-memory collections/algorithms | Avoid latency vs they're fast and reliable |
| Third-party libraries with side effects | First-party utilities and helpers | Isolation vs they're our code |
| Email/SMS/notification services | Validators/calculators | Avoid sending vs they're deterministic |
The boundaries heuristic:
A useful mental model is to mock at architectural boundaries. External systems (databases, APIs, filesystems) are always mocked. Internal collaborators within the same module or bounded context are often kept real.
Consider a PricingCalculator that uses TaxCalculator and DiscountCalculator. All three are in-memory, deterministic, fast, and within the same module. There's no compelling reason to mock TaxCalculator and DiscountCalculator—use the real implementations. But if PricingCalculator also calls InventoryService which hits a database, mock InventoryService.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// ❌ Over-mocking: Mocking internal collaborators unnecessarilydescribe('PricingCalculator', () => { it('should calculate final price', () => { // Mocking TaxCalculator and DiscountCalculator is unnecessary // They're fast, deterministic, and part of the same bounded context const mockTax = mock<TaxCalculator>(); const mockDiscount = mock<DiscountCalculator>(); when(mockTax.calculate(100)).thenReturn(10); when(mockDiscount.calculate(100)).thenReturn(15); const calc = new PricingCalculator( instance(mockTax), instance(mockDiscount) ); // This test tells us nothing about actual pricing logic! expect(calc.calculateFinalPrice(100)).toBe(95); });}); // ✅ Appropriate mocking: Mock external boundaries, use real internalsdescribe('PricingCalculator', () => { it('should calculate final price with real tax and discount', () => { // Use real implementations for in-memory, deterministic code const taxCalculator = new TaxCalculator(0.1); // 10% tax const discountCalculator = new DiscountCalculator(); // Mock only the external dependency const mockInventory = mock<InventoryService>(); when(mockInventory.getItemPrice('ITEM-1')) .thenResolve(100); const calc = new PricingCalculator( taxCalculator, discountCalculator, instance(mockInventory) ); // Now we're actually testing the pricing calculation const result = await calc.calculateFinalPrice('ITEM-1', 'PROMO-10'); // 100 + 10% tax - 10 promo = 100 expect(result).toBe(100); });});A complementary heuristic: 'Don't mock types you don't own.' Mocking third-party APIs directly couples tests to their structure. Instead, wrap third-party APIs in your own adapters, and mock your adapters. This provides both testability and abstraction from vendor specifics.
Mocking frameworks make behavior verification easy—verifying that specific methods were called with specific arguments. But ease of use doesn't make it the right approach. State verification (checking the resulting state after an action) is often more robust and meaningful.
Why behavior verification can be problematic:
repository.save(user) was called, the test breaks if you refactor to use batch saves, even if the end result is identical.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// ❌ Behavior verification: Tests implementation, not outcomesdescribe('OrderService', () => { it('should confirm order', () => { const mockStatusUpdater = mock<OrderStatusUpdater>(); const mockNotifier = mock<OrderNotifier>(); const service = new OrderService( instance(mockStatusUpdater), instance(mockNotifier) ); service.confirmOrder(order); // These assertions are about HOW, not WHAT // If we refactor internals, these break even if behavior is correct verify(mockStatusUpdater.setStatus(order.id, OrderStatus.CONFIRMED)).once(); verify(mockNotifier.notifyCustomer(order.id, 'confirmed')).once(); });}); // ✅ State verification: Tests outcomes, not implementationdescribe('OrderService', () => { it('should confirm order', () => { const repository = new InMemoryOrderRepository(); const notifications = new CollectingNotifier(); const service = new OrderService(repository, notifications); repository.save(order); service.confirmOrder(order.id); // These assertions are about WHAT happened, not HOW const updatedOrder = repository.findById(order.id); expect(updatedOrder.status).toBe(OrderStatus.CONFIRMED); expect(notifications.sentTo).toContain(order.customerId); });}); // Supporting test utilities:class InMemoryOrderRepository implements OrderRepository { private orders = new Map<string, Order>(); save(order: Order): void { this.orders.set(order.id, { ...order }); } findById(id: string): Order | undefined { return this.orders.get(id); }} class CollectingNotifier implements OrderNotifier { sentTo: string[] = []; notifyCustomer(customerId: string, message: string): void { this.sentTo.push(customerId); }}When behavior verification IS appropriate:
Behavior verification makes sense when the interaction itself is the requirement:
The key distinction: verify interactions when they ARE the behavior, not when they're merely implementation details of the behavior.
A good rule of thumb: verify only observable behavior—things that an outside observer could notice and that appear in requirements. Internal method calls between objects are usually not observable behavior; final state and external communications are.
Complex mock setup is a code smell. If a test requires extensive mock configuration—many stubbed methods, complex argument matchers, precise return value sequences—something is wrong. Either the code under test has too many responsibilities, or the test is over-specified.
Signs of excessive mock setup:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// ❌ Excessive mock setup - indicates design problemsdescribe('ReportGenerator', () => { it('should generate report', async () => { // RED FLAG: Way too many mocks const mockUserService = mock<UserService>(); const mockPermissionService = mock<PermissionService>(); const mockReportRepository = mock<ReportRepository>(); const mockTemplateEngine = mock<TemplateEngine>(); const mockNotificationService = mock<NotificationService>(); const mockAuditLogger = mock<AuditLogger>(); const mockCacheService = mock<CacheService>(); // RED FLAG: Extensive stubbing when(mockUserService.getCurrentUser()).thenReturn(testUser); when(mockPermissionService.canGenerateReports(testUser)).thenReturn(true); when(mockReportRepository.getTemplate('monthly')).thenReturn(template); when(mockReportRepository.getData(startDate, endDate)).thenReturn(data); when(mockTemplateEngine.render(template, data)).thenReturn(rendered); when(mockCacheService.get('report-monthly')).thenReturn(null); when(mockAuditLogger.log(any())).thenReturn(Promise.resolve()); const generator = new ReportGenerator(...allTheseMocks); const report = await generator.generate('monthly', startDate, endDate); expect(report).toBeDefined(); });}); // ✅ Minimal setup - clean designdescribe('ReportGenerator', () => { it('should generate report from data and template', async () => { // Only mock what's truly external const mockRepository = mock<ReportRepository>(); when(mockRepository.getTemplate('monthly')).thenReturn(template); when(mockRepository.getData(startDate, endDate)).thenReturn(data); // Use real implementations for internal logic const generator = new ReportGenerator( instance(mockRepository), new RealTemplateEngine() // Fast, deterministic, no side effects ); const report = await generator.generate('monthly', startDate, endDate); expect(report.content).toContain('Monthly Report'); expect(report.data).toEqual(data); });}); // The design was refactored:// - Permission checking moved to a decorator/middleware// - Notifications moved to event handlers// - Audit logging moved to an aspect// - Caching moved to repository decorator// Result: ReportGenerator does one thing and is easy to testStrategies for minimizing mock setup:
If you spend more time setting up mocks than writing assertions, stop and refactor. Either the production code needs to be simplified, or you're testing at the wrong level of abstraction.
When tests do require mocks or complex test data, extract the setup into reusable builders and factories. This reduces duplication, improves readability, and makes tests more resilient to changes in construction requirements.
The Builder Pattern for test data:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Test data builder for domain objectsclass OrderBuilder { private order: Partial<Order> = { id: 'order-1', status: OrderStatus.PENDING, items: [], customerId: 'customer-1', createdAt: new Date('2024-01-01'), }; withId(id: string): OrderBuilder { this.order.id = id; return this; } withStatus(status: OrderStatus): OrderBuilder { this.order.status = status; return this; } withItems(...items: OrderItem[]): OrderBuilder { this.order.items = items; return this; } confirmed(): OrderBuilder { return this.withStatus(OrderStatus.CONFIRMED); } withTotal(amount: number): OrderBuilder { this.order.items = [{ productId: 'product-1', quantity: 1, unitPrice: amount }]; return this; } build(): Order { return new Order(this.order as Order); }} // Usage in tests - clean the readabledescribe('OrderService', () => { it('should ship confirmed orders', () => { const order = new OrderBuilder() .confirmed() .withTotal(100) .build(); service.shipOrder(order); expect(order.status).toBe(OrderStatus.SHIPPED); }); it('should not ship pending orders', () => { const order = new OrderBuilder().build(); // Pending by default expect(() => service.shipOrder(order)) .toThrow('Cannot ship pending order'); });});Mock factories for common mocking patterns:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Factory for creating configured mocksclass MockPaymentGatewayFactory { static successful(): IPaymentGateway { const mock = createMock<IPaymentGateway>(); mock.charge.mockResolvedValue({ success: true, transactionId: 'tx-' + Date.now() }); mock.refund.mockResolvedValue({ success: true }); return mock; } static failing(error: string = 'Gateway error'): IPaymentGateway { const mock = createMock<IPaymentGateway>(); mock.charge.mockRejectedValue(new PaymentError(error)); return mock; } static intermittent(): IPaymentGateway { const mock = createMock<IPaymentGateway>(); mock.charge .mockRejectedValueOnce(new TemporaryError()) .mockResolvedValue({ success: true, transactionId: 'tx-retry' }); return mock; } static fraudDetected(): IPaymentGateway { const mock = createMock<IPaymentGateway>(); mock.charge.mockResolvedValue({ success: false, reason: 'suspected_fraud' }); return mock; }} // Usage - tests become self-documentingdescribe('PaymentService', () => { it('should complete payment with successful gateway', async () => { const gateway = MockPaymentGatewayFactory.successful(); const service = new PaymentService(gateway); const result = await service.processPayment(100); expect(result.status).toBe('completed'); }); it('should handle fraud detection', async () => { const gateway = MockPaymentGatewayFactory.fraudDetected(); const service = new PaymentService(gateway); const result = await service.processPayment(100); expect(result.status).toBe('flagged'); expect(result.requiresReview).toBe(true); }); it('should retry on temporary failures', async () => { const gateway = MockPaymentGatewayFactory.intermittent(); const service = new PaymentService(gateway); const result = await service.processPayment(100); expect(result.status).toBe('completed'); expect(gateway.charge).toHaveBeenCalledTimes(2); });});Notice how factory method names like 'successful()', 'fraudDetected()', and 'intermittent()' make tests self-documenting. Readers understand the scenario without examining mock implementation details.
Value objects—immutable objects defined by their attributes rather than identity—should never be mocked. Creating real instances is trivial, and mocking them adds complexity without benefit.
Why not mock value objects:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// ❌ Pointless: Mocking a value objectdescribe('Invoice', () => { it('should calculate total with mocked Money', () => { const mockMoney = mock<Money>(); when(mockMoney.getAmount()).thenReturn(100); when(mockMoney.getCurrency()).thenReturn('USD'); when(mockMoney.add(any())).thenReturn(mockMoney); // Wrong! Mocks return themselves const invoice = new Invoice([ new LineItem('Product', instance(mockMoney)), ]); // This test tells us nothing useful // We're just verifying our mock setup });}); // ✅ Simple: Use real value objectsdescribe('Invoice', () => { it('should calculate total', () => { const invoice = new Invoice([ new LineItem('Product A', Money.of(100, 'USD')), new LineItem('Product B', Money.of(50, 'USD')), ]); // Now we're testing real behavior expect(invoice.total).toEqual(Money.of(150, 'USD')); }); it('should reject mixed currencies', () => { expect(() => new Invoice([ new LineItem('Product A', Money.of(100, 'USD')), new LineItem('Product B', Money.of(50, 'EUR')), ])).toThrow('Cannot mix currencies'); });}); // Value object implementation - simple, no dependenciesclass Money { private constructor( private readonly amount: number, private readonly currency: string ) {} static of(amount: number, currency: string): Money { if (amount < 0) throw new Error('Amount cannot be negative'); return new Money(amount, currency); } add(other: Money): Money { if (this.currency !== other.currency) { throw new Error('Cannot add different currencies'); } return Money.of(this.amount + other.amount, this.currency); } equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; }}The same applies to simple DTOs and records:
Data Transfer Objects, configuration objects, and simple records should be created normally. Mocking them adds overhead with no isolation benefit.
// Don't mock configurations
const config: AppConfig = {
databaseUrl: 'test-db',
maxRetries: 3,
timeout: 1000,
};
// Don't mock simple DTOs
const request: PaymentRequest = {
orderId: 'order-1',
amount: 100,
currency: 'USD',
};
Ask: Does this object have external dependencies or side effects? If no, use the real object. If yes, is it slow or non-deterministic? If no, probably use the real object. If yes, mock it.
When behavior verification is appropriate, restrict it to interactions that matter for the test's purpose. Over-verification leads to brittle tests that break on irrelevant changes.
The minimal verification principle:
Each test should verify the minimum set of interactions necessary to confirm the behavior under test. Additional verifications add fragility without adding confidence.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// ❌ Over-verification: Too many assertions about interactionsdescribe('OrderService', () => { it('should process order', () => { const mockInventory = mock<InventoryService>(); const mockPayment = mock<PaymentService>(); const mockNotification = mock<NotificationService>(); const mockAudit = mock<AuditService>(); const service = new OrderService(...mocks); service.processOrder(order); // TOO MANY VERIFICATIONS - test breaks if any implementation detail changes verify(mockInventory.checkAvailability(order.items)).once(); verify(mockInventory.reserve(order.items)).once(); verify(mockPayment.authorize(order.total)).once(); verify(mockPayment.capture(order.total)).once(); verify(mockInventory.confirm(order.items)).once(); verify(mockNotification.sendConfirmation(order.customerId)).once(); verify(mockNotification.sendReceipt(order.customerId)).once(); verify(mockAudit.log('order_processed', order.id)).once(); verify(mockAudit.log('payment_captured', any())).once(); // What is this test actually about? Hard to tell. });}); // ✅ Focused verification: One behavior per testdescribe('OrderService', () => { it('should reserve inventory before charging payment', async () => { const mockInventory = mock<InventoryService>(); const mockPayment = mock<PaymentService>(); // Capture call order const callOrder: string[] = []; when(mockInventory.reserve(any())).thenCall(() => { callOrder.push('reserve'); return Promise.resolve(); }); when(mockPayment.charge(any())).thenCall(() => { callOrder.push('charge'); return Promise.resolve({ success: true }); }); const service = new OrderService(instance(mockInventory), instance(mockPayment)); await service.processOrder(order); // Single focused assertion about ordering expect(callOrder).toEqual(['reserve', 'charge']); }); it('should notify customer on successful order', async () => { const mockNotification = mock<NotificationService>(); // Other mocks with default happy-path behavior const service = new OrderService(...mocks); await service.processOrder(order); // Test is specifically about notification verify(mockNotification.sendConfirmation(order.customerId)).once(); }); it('should not charge payment if inventory unavailable', async () => { const mockInventory = mock<InventoryService>(); const mockPayment = mock<PaymentService>(); when(mockInventory.reserve(any())) .thenReject(new InsufficientInventoryError()); const service = new OrderService(instance(mockInventory), instance(mockPayment)); await expect(service.processOrder(order)) .rejects.toThrow(InsufficientInventoryError); // Only verify what this specific test cares about verify(mockPayment.charge(anything())).never(); });});Verification guidelines:
Avoid verifying the exact order of method calls unless order is actually meaningful (e.g., 'reserve inventory before charging payment'). Order verification is extremely fragile and rarely reflects actual requirements.
Test names are documentation. When a test fails in CI, the name tells you what broke without reading the code. Good test names describe the behavior, not the implementation.
Test naming patterns:
| Pattern | Example | When to Use |
|---|---|---|
| should [behavior] when [condition] | 'should reject order when inventory insufficient' | Most common; describes expected behavior |
| [action] [expected result] | 'processOrder returns failure for invalid payment' | More concise; action-result focus |
| given [context] when [action] then [result] | 'given expired coupon when applied then throws' | BDD style; explicit context |
| [method] throws [exception] for [condition] | 'calculateTotal throws for negative quantities' | Testing error cases specifically |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// ❌ Poor test names - describe implementation, not behaviordescribe('PaymentService', () => { it('test1', () => { /* ... */ }); it('calls gateway.charge', () => { /* ... */ }); it('mock returns success', () => { /* ... */ }); it('processPayment works', () => { /* ... */ }); it('handles error', () => { /* ... */ }); // What error? How handled?}); // ✅ Good test names - describe behavior and contextdescribe('PaymentService', () => { describe('processPayment', () => { it('should complete payment when gateway approves charge', async () => { /* ... */ }); it('should mark payment as failed when gateway declines', async () => { /* ... */ }); it('should retry up to 3 times on temporary network failure', async () => { /* ... */ }); it('should flag transaction for review on suspected fraud', async () => { /* ... */ }); describe('when payment amount exceeds limit', () => { it('should require additional authorization', async () => { /* ... */ }); it('should notify compliance team', async () => { /* ... */ }); }); }); describe('refundPayment', () => { it('should refund full amount for completed payments', async () => { /* ... */ }); it('should reject refund for already refunded payments', async () => { /* ... */ }); });});The nested describe pattern:
Use nested describe blocks to group related tests and establish context. This creates readable hierarchies:
PaymentService
processPayment
✓ should complete payment when gateway approves
✓ should retry on temporary failure
when amount exceeds limit
✓ should require additional authorization
refundPayment
✓ should refund full amount for completed payments
Read your test name as if it just failed in CI. Does it tell you what broke? 'test_payment' doesn't. 'should_reject_payment_when_card_expired' does. Write names for the person debugging at 2am.
Effective mocking is about judgment—knowing when to mock, how much to mock, and what to verify. Let's consolidate the key practices:
What's next:
Even with best practices, mocking can go wrong. The next page explores the pitfalls of over-mocking—when tests become so divorced from reality that they provide false confidence, when mock configurations become maintenance nightmares, and how to recognize and recover from these situations.
You now understand the best practices that make mocking effective and sustainable. These guidelines help you write tests that provide real confidence without becoming maintenance burdens. Next, we'll examine what happens when mocking goes wrong and how to avoid those traps.