Mocking in Tests: Like Hot Sauce - A Little Goes a Long Way

Mocking in unit tests is like hot sauce - a little bit enhances the flavor, but too much ruins the meal. Yet many developers drown their tests in mocks, creating brittle, unreadable test suites that break with every refactor. Let's explore when to mock, when not to mock, and how to write maintainable tests that actually test what matters.

The Hot Sauce Analogy

When you're cooking a great meal, you don't dump hot sauce on everything. A few drops on the right spots enhance the flavors you've carefully built. Use too much, and you can't taste anything else. The same principle applies to mocking in tests.

Mocks should isolate your code from external dependencies - databases, APIs, file systems. They shouldn't replace the very business logic you're trying to test. When your test setup has more mock configurations than actual test logic, something's wrong.

What Mocking Is (And Isn't)

Mocking is: Creating fake implementations of dependencies to isolate the code under test from external systems and side effects. It helps make tests fast, deterministic, and focused.

Mocking isn't: A way to avoid testing your actual business logic. It's not a substitute for proper dependency injection or good architecture. And it's definitely not something you should do to every single dependency.

When to Mock

  • External systems: Databases, HTTP APIs, file systems, third-party services
  • Side effects: Logging, email sending, event publishing, notifications
  • Non-deterministic operations: Random number generation, current timestamps
  • Slow or expensive operations: Complex calculations, image processing

When NOT to Mock

  • Business logic: The core functionality you're trying to test
  • Pure functions: Calculations, validations, transformations
  • Value objects: Simple data structures and DTOs
  • Internal collaborators: Objects that are part of the same bounded context

The Problems with Over-Mocking

1. Brittle Tests

When you mock everything, your tests become coupled to implementation details rather than behavior. Change how a method is called internally, and tests break even though the external behavior is identical.

2. Unclear Intent

Tests should clearly communicate what the code does. When most of your test is mock setup, it's hard to understand what behavior is actually being verified.

3. False Confidence

Over-mocked tests can pass while the real system fails. You're testing your mocks, not your actual code.

4. Maintenance Nightmare

Every refactor requires updating dozens of mock expectations. Tests that should help you refactor safely become obstacles to change.

Over-Mocking Example: The Horror Show

Here's an example of a test that's gone completely overboard with mocking. Notice how the test setup is longer than the actual test, and how it's testing implementation details rather than behavior:

// BAD: Over-mocked test that's brittle and unclear
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OrderService } from '../services/OrderService';
import { PaymentGateway } from '../services/PaymentGateway';
import { EmailService } from '../services/EmailService';
import { InventoryService } from '../services/InventoryService';
import { AuditLogger } from '../services/AuditLogger';

describe('OrderService - Over-Mocked (BAD)', () => {
  let orderService: OrderService;
  let mockPaymentGateway: any;
  let mockEmailService: any;
  let mockInventoryService: any;
  let mockAuditLogger: any;

  beforeEach(() => {
    vi.clearAllMocks();
    
    // Mocking EVERYTHING - even simple data operations
    mockPaymentGateway = {
      processPayment: vi.fn(),
      validateCard: vi.fn(),
      calculateFees: vi.fn(),
      formatAmount: vi.fn(), // This shouldn't be mocked!
    };
    
    mockEmailService = {
      sendEmail: vi.fn(),
      formatTemplate: vi.fn(), // This shouldn't be mocked!
      validateEmail: vi.fn(),  // This shouldn't be mocked!
    };
    
    mockInventoryService = {
      checkStock: vi.fn(),
      reserveItems: vi.fn(),
      calculatePrice: vi.fn(), // This shouldn't be mocked!
      applyDiscount: vi.fn(),  // This shouldn't be mocked!
    };
    
    mockAuditLogger = {
      log: vi.fn(),
      formatMessage: vi.fn(), // This shouldn't be mocked!
    };
    
    orderService = new OrderService(
      mockPaymentGateway,
      mockEmailService,
      mockInventoryService,
      mockAuditLogger
    );
  });

  it('should process order successfully', async () => {
    // Arrange - SO MANY MOCK SETUPS!
    const orderData = { id: '123', items: [{ id: 'item1', quantity: 2 }] };
    
    mockInventoryService.checkStock.mockResolvedValue(true);
    mockInventoryService.reserveItems.mockResolvedValue({ success: true });
    mockInventoryService.calculatePrice.mockReturnValue(100);
    mockInventoryService.applyDiscount.mockReturnValue(90);
    
    mockPaymentGateway.processPayment.mockResolvedValue({ success: true });
    mockPaymentGateway.validateCard.mockReturnValue(true);
    mockPaymentGateway.calculateFees.mockReturnValue(2.50);
    mockPaymentGateway.formatAmount.mockReturnValue('$92.50');
    
    mockEmailService.sendEmail.mockResolvedValue(true);
    mockEmailService.formatTemplate.mockReturnValue('<html>...');
    mockEmailService.validateEmail.mockReturnValue(true);
    
    mockAuditLogger.log.mockReturnValue(undefined);
    mockAuditLogger.formatMessage.mockReturnValue('Order processed: 123');

    // Act
    const result = await orderService.processOrder(orderData);

    // Assert - Testing implementation details, not behavior!
    expect(mockInventoryService.checkStock).toHaveBeenCalledWith(['item1']);
    expect(mockInventoryService.calculatePrice).toHaveBeenCalledWith(orderData.items);
    expect(mockInventoryService.applyDiscount).toHaveBeenCalledWith(100, orderData);
    expect(mockPaymentGateway.formatAmount).toHaveBeenCalledWith(92.50);
    expect(mockEmailService.formatTemplate).toHaveBeenCalledWith('order_confirmation', expect.any(Object));
    expect(mockAuditLogger.formatMessage).toHaveBeenCalledWith('ORDER_PROCESSED', expect.any(Object));
    
    expect(result.success).toBe(true);
  });
});

This test is a maintenance nightmare. It's brittle, unclear, and provides false confidence. The mock setup is so complex that it's hard to understand what the code actually does.

Minimal Mocking: The Right Way

Here's the same test rewritten with minimal mocking. Notice how we only mock external dependencies and side effects, while using real implementations for business logic:

// GOOD: Minimal mocking focused on external dependencies and side effects
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OrderService } from '../services/OrderService';
import { IPaymentGateway } from '../interfaces/IPaymentGateway';
import { IEmailService } from '../interfaces/IEmailService';
import { IInventoryService } from '../interfaces/IInventoryService';
import { IAuditLogger } from '../interfaces/IAuditLogger';

describe('OrderService - Minimal Mocking (GOOD)', () => {
  let orderService: OrderService;

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should process order successfully', async () => {
    // Mock only external dependencies and side effects
    const mockPaymentGateway = {
      processPayment: vi.fn().mockResolvedValue({ success: true, transactionId: 'tx123' }),
      validateCard: vi.fn().mockReturnValue(true),
      calculateFees: vi.fn().mockReturnValue(2.50),
      // formatAmount: NOT MOCKED - it's a pure function
    } as Partial<IPaymentGateway> as IPaymentGateway;

    const mockEmailService = {
      sendEmail: vi.fn().mockResolvedValue(true), // External service - mock it
      // formatTemplate: NOT MOCKED - it's internal logic we want to test
      // validateEmail: NOT MOCKED - it's a pure function
    } as Partial<IEmailService> as IEmailService;

    const mockInventoryService = {
      checkStock: vi.fn().mockResolvedValue(true),     // External system - mock it
      reserveItems: vi.fn().mockResolvedValue({ success: true }), // Side effect - mock it
      // calculatePrice: NOT MOCKED - business logic we want to test
      // applyDiscount: NOT MOCKED - business logic we want to test
    } as Partial<IInventoryService> as IInventoryService;

    const mockAuditLogger = {
      log: vi.fn(), // External logging - mock the side effect only
      // formatMessage: NOT MOCKED - formatting logic we want to test
    } as Partial<IAuditLogger> as IAuditLogger;

    orderService = new OrderService(
      mockPaymentGateway,
      mockEmailService,
      mockInventoryService,
      mockAuditLogger
    );

    // Arrange
    const orderData = {
      id: '123',
      customerEmail: 'customer@example.com',
      items: [{ id: 'item1', quantity: 2, price: 50 }]
    };

    // Act
    const result = await orderService.processOrder(orderData);

    // Assert - Focus on outcomes, not implementation details
    expect(result.success).toBe(true);
    expect(result.orderId).toBe('123');
    expect(result.totalAmount).toBeGreaterThan(0); // Tests real calculation logic
    
    // Verify only critical side effects
    expect(mockPaymentGateway.processPayment).toHaveBeenCalledOnce();
    expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
      'customer@example.com',
      expect.stringContaining('Order Confirmation')
    );
    expect(mockInventoryService.reserveItems).toHaveBeenCalledOnce();
    expect(mockAuditLogger.log).toHaveBeenCalledWith(
      expect.stringContaining('Order processed: 123')
    );
  });

  it('should handle payment failure gracefully', async () => {
    // Only mock the failing external dependency
    const mockPaymentGateway = {
      processPayment: vi.fn().mockRejectedValue(new Error('Payment failed')),
      validateCard: vi.fn().mockReturnValue(true),
      calculateFees: vi.fn().mockReturnValue(2.50),
    } as Partial<IPaymentGateway> as IPaymentGateway;

    // Use real implementations for everything else
    const realEmailService = new EmailService();
    const realInventoryService = new InventoryService();
    const realAuditLogger = new AuditLogger();

    orderService = new OrderService(
      mockPaymentGateway,
      realEmailService,
      realInventoryService,
      realAuditLogger
    );

    const orderData = {
      id: '456',
      customerEmail: 'customer@example.com',
      items: [{ id: 'item1', quantity: 1, price: 25 }]
    };

    // Act & Assert
    await expect(orderService.processOrder(orderData)).rejects.toThrow('Payment failed');
    
    // Verify payment was attempted
    expect(mockPaymentGateway.processPayment).toHaveBeenCalledOnce();
  });
});

This version is clearer, more maintainable, and actually tests the business logic. The mocks serve their purpose - isolating external dependencies - without obscuring the intent.

PHPUnit: The Same Principles Apply

The over-mocking problem isn't unique to JavaScript. Here's how it manifests in PHP with PHPUnit, and how to fix it:

The Wrong Way: Everything Mocked

<?php
// BAD: Over-mocked PHPUnit test that's brittle and unclear
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

class OrderServiceOverMockedTest extends TestCase
{
    private OrderService $orderService;
    private MockObject $paymentGateway;
    private MockObject $emailService;
    private MockObject $inventoryService;
    private MockObject $auditLogger;
    private MockObject $priceCalculator; // Even mocking simple calculations!
    private MockObject $discountEngine;  // Mocking business logic!
    private MockObject $taxCalculator;   // Mocking pure functions!

    protected function setUp(): void
    {
        parent::setUp();

        // Mocking EVERYTHING - even simple utilities and business logic
        $this->paymentGateway = $this->createMock(PaymentGateway::class);
        $this->emailService = $this->createMock(EmailService::class);
        $this->inventoryService = $this->createMock(InventoryService::class);
        $this->auditLogger = $this->createMock(AuditLogger::class);
        $this->priceCalculator = $this->createMock(PriceCalculator::class);
        $this->discountEngine = $this->createMock(DiscountEngine::class);
        $this->taxCalculator = $this->createMock(TaxCalculator::class);

        $this->orderService = new OrderService(
            $this->paymentGateway,
            $this->emailService,
            $this->inventoryService,
            $this->auditLogger,
            $this->priceCalculator,
            $this->discountEngine,
            $this->taxCalculator
        );
    }

    public function testProcessOrderSuccessfully(): void
    {
        // Arrange - SO MANY MOCK CONFIGURATIONS!
        $orderData = [
            'id' => '123',
            'customer_email' => 'test@example.com',
            'items' => [['id' => 'item1', 'quantity' => 2]]
        ];

        // Mocking every single method call - testing implementation, not behavior
        $this->inventoryService
            ->expects($this->once())
            ->method('checkStock')
            ->with(['item1'])
            ->willReturn(true);

        $this->inventoryService
            ->expects($this->once())
            ->method('reserveItems')
            ->with($orderData['items'])
            ->willReturn(['success' => true]);

        // These shouldn't be mocked - they're business logic!
        $this->priceCalculator
            ->expects($this->once())
            ->method('calculateSubtotal')
            ->with($orderData['items'])
            ->willReturn(100.00);

        $this->discountEngine
            ->expects($this->once())
            ->method('applyDiscounts')
            ->with(100.00, $orderData)
            ->willReturn(90.00);

        $this->taxCalculator
            ->expects($this->once())
            ->method('calculateTax')
            ->with(90.00)
            ->willReturn(9.00);

        // Even mocking formatting functions!
        $this->priceCalculator
            ->expects($this->once())
            ->method('formatCurrency')
            ->with(99.00)
            ->willReturn('$99.00');

        $this->paymentGateway
            ->expects($this->once())
            ->method('processPayment')
            ->with($this->callback(function ($payment) {
                return $payment['amount'] === 99.00;
            }))
            ->willReturn(['success' => true, 'transaction_id' => 'tx123']);

        // Mocking template rendering instead of testing it!
        $this->emailService
            ->expects($this->once())
            ->method('renderTemplate')
            ->with('order_confirmation', $this->anything())
            ->willReturn('<html>Confirmation email content</html>');

        $this->emailService
            ->expects($this->once())
            ->method('send')
            ->with('test@example.com', 'Order Confirmation', '<html>Confirmation email content</html>')
            ->willReturn(true);

        // Even mocking log message formatting!
        $this->auditLogger
            ->expects($this->once())
            ->method('formatLogMessage')
            ->with('ORDER_PROCESSED', $this->anything())
            ->willReturn('Order 123 processed successfully');

        $this->auditLogger
            ->expects($this->once())
            ->method('log')
            ->with('info', 'Order 123 processed successfully');

        // Act
        $result = $this->orderService->processOrder($orderData);

        // Assert - Only testing the final result, but the test is incredibly brittle
        $this->assertTrue($result['success']);
        $this->assertEquals('123', $result['order_id']);
    }

    public function testProcessOrderWithInvalidDiscount(): void
    {
        // This test breaks when we change internal discount calculation logic
        // even though the external behavior is the same!
        
        $orderData = ['id' => '456', 'items' => [['id' => 'item1', 'quantity' => 1]]];

        $this->inventoryService->method('checkStock')->willReturn(true);
        $this->inventoryService->method('reserveItems')->willReturn(['success' => true]);
        $this->priceCalculator->method('calculateSubtotal')->willReturn(50.00);
        
        // This test is coupled to internal implementation details
        $this->discountEngine
            ->expects($this->once())
            ->method('applyDiscounts')
            ->willThrowException(new InvalidDiscountException('Invalid discount code'));

        $this->expectException(InvalidDiscountException::class);
        $this->orderService->processOrder($orderData);
    }
}

The Right Way: Minimal Mocking

<?php
// GOOD: Proper setUp() with intersection typed properties
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

interface PaymentServiceInterface
{
    public function processPayment(float $amount): array;
}

class OrderServiceTest extends TestCase
{
    // KEY POINT: Intersection types with MockObject
    private PaymentServiceInterface&MockObject $paymentService;
    private OrderService $orderService;

    protected function setUp(): void
    {
        parent::setUp();
        
        // Create mock with proper intersection typing
        $this->paymentService = $this->createMock(PaymentServiceInterface::class);
        $this->orderService = new OrderService($this->paymentService);
    }

    public function testProcessPayment(): void
    {
        // Now you get full type safety for both interface methods AND mock methods
        $this->paymentService->method('processPayment')  // MockObject method
            ->willReturn(['success' => true]);            // MockObject method
        
        $result = $this->orderService->processOrder(100.00);
        
        $this->assertTrue($result['success']);
    }
}

In PHP 8.4 and modern development, you'll often encounter final classes that can't be mocked by default. Use the dg/bypass-finals library when you genuinely need to mock final classes, but question whether you really need to.

PHP 8.4 Intersection Types for Mock Objects

PHP 8.4's intersection types provide powerful mock typing capabilities. However, creating custom interfaces that extend base functionality is often cleaner than complex intersection types:

<?php
// PHP 8.4 Intersection Types for Mock Objects - GOOD Examples
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

// Interface-first design with proper type declarations
interface PaymentProcessorInterface 
{
    public function processPayment(array $paymentData): array;
    public function refundPayment(string $transactionId): bool;
}

interface LoggableInterface 
{
    public function log(string $level, string $message, array $context = []): void;
}

interface ValidatableInterface 
{
    public function validate(array $data): array;
}

// BETTER: Custom interfaces that extend base functionality
interface LoggingPaymentProcessorInterface extends PaymentProcessorInterface, LoggableInterface
{
    // Combines payment processing with logging capability
}

interface LoggingValidatorInterface extends ValidatableInterface, LoggableInterface
{
    // Combines validation with logging capability
}

// Service using cleaner custom interfaces (RECOMMENDED)
final class PaymentService 
{
    public function __construct(
        private LoggingPaymentProcessorInterface $paymentGateway,
        private LoggingValidatorInterface $validator
    ) {}

    // Same implementation as before...
}

// Alternative: Using intersection types (when you can't modify interfaces)
final class PaymentServiceWithIntersectionTypes 
{
    public function __construct(
        private PaymentProcessorInterface&LoggableInterface $paymentGateway,
        private ValidatableInterface&LoggableInterface $validator
    ) {}

    public function processSecurePayment(array $paymentData): array
    {
        // Validation with logging
        $validationErrors = $this->validator->validate($paymentData);
        if (!empty($validationErrors)) {
            $this->validator->log('error', 'Payment validation failed', $validationErrors);
            throw new ValidationException('Invalid payment data');
        }

        // Payment processing with logging
        $this->paymentGateway->log('info', 'Processing payment', ['amount' => $paymentData['amount']]);
        $result = $this->paymentGateway->processPayment($paymentData);
        
        if ($result['success']) {
            $this->paymentGateway->log('info', 'Payment processed successfully', $result);
        } else {
            $this->paymentGateway->log('error', 'Payment processing failed', $result);
        }

        return $result;
    }
}

// Test demonstrating CLEAN approach with custom interfaces (RECOMMENDED)
class PaymentServiceTest extends TestCase
{
    private LoggingPaymentProcessorInterface&MockObject $paymentGateway;
    private LoggingValidatorInterface&MockObject $validator;
    private PaymentService $paymentService;

    protected function setUp(): void
    {
        parent::setUp();
        
        // Much cleaner - single interface mocks
        $this->paymentGateway = $this->createMock(LoggingPaymentProcessorInterface::class);
        $this->validator = $this->createMock(LoggingValidatorInterface::class);

        // Create service with clean interface dependencies
        $this->paymentService = new PaymentService($this->paymentGateway, $this->validator);
    }

    public function testProcessSecurePaymentSuccess(): void
    {
        // Configure the mock behaviors
        $this->validator->method('validate')->willReturn([]); // No validation errors
        $this->validator->expects($this->once())
            ->method('log')
            ->with('error', 'Payment validation failed', $this->anything());

        $expectedResult = [
            'success' => true,
            'transaction_id' => 'txn_123',
            'amount' => 100.00
        ];

        $this->paymentGateway->method('processPayment')->willReturn($expectedResult);
        
        // Expect proper logging calls
        $this->paymentGateway->expects($this->exactly(2))
            ->method('log')
            ->withConsecutive(
                ['info', 'Processing payment', ['amount' => 100.00]],
                ['info', 'Payment processed successfully', $expectedResult]
            );

        // Test the actual business logic
        $result = $this->paymentService->processSecurePayment([
            'amount' => 100.00,
            'card_number' => '4111111111111111',
            'expiry' => '12/25'
        ]);

        // Assertions
        $this->assertTrue($result['success']);
        $this->assertEquals('txn_123', $result['transaction_id']);
        $this->assertEquals(100.00, $result['amount']);
    }

    public function testProcessSecurePaymentValidationFailure(): void
    {
        // Configure validation to fail
        $validationErrors = [
            'card_number' => 'Invalid card number',
            'expiry' => 'Card expired'
        ];
        
        $this->validator->method('validate')->willReturn($validationErrors);
        
        // Expect error logging for validation failure
        $this->validator->expects($this->once())
            ->method('log')
            ->with('error', 'Payment validation failed', $validationErrors);

        // Payment gateway should never be called when validation fails
        $this->paymentGateway->expects($this->never())->method('processPayment');
        $this->paymentGateway->expects($this->never())->method('log');

        // Expect the exception
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('Invalid payment data');

        $this->paymentService->processSecurePayment([
            'amount' => 100.00,
            'card_number' => 'invalid',
            'expiry' => '01/20'
        ]);
    }

    public function testProcessSecurePaymentGatewayFailure(): void
    {
        // Setup successful validation
        $this->validator->method('validate')->willReturn([]);
        
        // Setup payment gateway to fail
        $failureResult = [
            'success' => false,
            'error' => 'Insufficient funds',
            'code' => 'INSUFFICIENT_FUNDS'
        ];
        
        $this->paymentGateway->method('processPayment')->willReturn($failureResult);
        
        // Expect proper logging sequence
        $this->paymentGateway->expects($this->exactly(2))
            ->method('log')
            ->withConsecutive(
                ['info', 'Processing payment', ['amount' => 50.00]],
                ['error', 'Payment processing failed', $failureResult]
            );

        // Test payment processing
        $result = $this->paymentService->processSecurePayment([
            'amount' => 50.00,
            'card_number' => '4111111111111111',
            'expiry' => '12/25'
        ]);

        // Verify failure is properly handled
        $this->assertFalse($result['success']);
        $this->assertEquals('Insufficient funds', $result['error']);
        $this->assertEquals('INSUFFICIENT_FUNDS', $result['code']);
    }
}

// Alternative test showing intersection types (when you can't modify interfaces)
class PaymentServiceWithIntersectionTypesTest extends TestCase
{
    private PaymentProcessorInterface&LoggableInterface&MockObject $paymentGateway;
    private ValidatableInterface&LoggableInterface&MockObject $validator;
    private PaymentServiceWithIntersectionTypes $paymentService;

    protected function setUp(): void
    {
        parent::setUp();
        
        // Intersection type mocs - more complex but sometimes necessary
        $this->paymentGateway = $this->createStubForIntersectionOfInterfaces([
            PaymentProcessorInterface::class,
            LoggableInterface::class
        ]);

        $this->validator = $this->createStubForIntersectionOfInterfaces([
            ValidatableInterface::class,
            LoggableInterface::class
        ]);

        $this->paymentService = new PaymentServiceWithIntersectionTypes(
            $this->paymentGateway, 
            $this->validator
        );
    }

    public function testIntersectionTypesWork(): void
    {
        // Same test logic as before, demonstrating intersection types work
        // but are more complex than custom interfaces
        
        $this->validator->method('validate')->willReturn([]);
        $this->paymentGateway->method('processPayment')->willReturn([
            'success' => true,
            'transaction_id' => 'txn_456'
        ]);

        $result = $this->paymentService->processSecurePayment([
            'amount' => 75.00,
            'card_number' => '4111111111111111',
            'expiry' => '12/25'
        ]);

        $this->assertTrue($result['success']);
        $this->assertEquals('txn_456', $result['transaction_id']);
    }
}

// Alternative approach: Single interface with intersection-like behavior
interface SecurePaymentGatewayInterface extends PaymentProcessorInterface, LoggableInterface 
{
    // This interface inherits from both PaymentProcessorInterface and LoggableInterface
    // Provides similar benefits to intersection types but with cleaner syntax
}

final class AlternativePaymentService
{
    public function __construct(
        private SecurePaymentGatewayInterface $gateway,
        private ValidatableInterface $validator  // Separate concerns
    ) {}

    // Implementation similar to above but with simpler type declarations
}

class AlternativePaymentServiceTest extends TestCase
{
    public function testWithInheritanceBasedInterface(): void
    {
        // Single mock for inherited interface - cleaner than intersection
        $gateway = $this->createMock(SecurePaymentGatewayInterface::class);
        $validator = $this->createMock(ValidatableInterface::class);

        $gateway->method('processPayment')->willReturn(['success' => true]);
        $gateway->method('log')->willReturn(null);
        $validator->method('validate')->willReturn([]);

        $service = new AlternativePaymentService($gateway, $validator);

        // Test implementation...
        $this->assertTrue(true); // Placeholder for actual test
    }
}

Key benefits of proper mock typing:

  • Type safety: Full IDE support and static analysis for both interface methods and PHPUnit mock methods
  • Clean setup: Centralized mock creation in setUp() with typed class properties
  • Better testing: Use expects(), withConsecutive(), and never() for comprehensive behavior verification
  • Interface-first design: Custom interfaces that extend base functionality are cleaner than complex intersections

Related documentation:

Vitest Setup and Best Practices

With Vitest, proper mock cleanup and setup patterns help maintain test reliability:

// Vitest setup and configuration for clean mocking
import { vi, beforeEach, afterAll } from 'vitest';

// Global test setup - clean up mocks between tests
beforeEach(() => {
  // Clear all mocks before each test
  vi.clearAllMocks();
  
  // Reset module mocks
  vi.resetModules();
});

afterAll(() => {
  // Restore real timers after all tests
  vi.useRealTimers();
  
  // Restore all mocks
  vi.restoreAllMocks();
});

// Helper function for creating typed mocks
export function createMockService<T>(implementation: Partial<T> = {}): T {
  return implementation as T;
}

// Example of when to use vi.spyOn vs vi.mock
export class TestHelpers {
  // Use vi.spyOn for temporary overrides in specific tests
  static spyOnMethod<T extends object, K extends keyof T>(
    object: T,
    method: K
  ) {
    return vi.spyOn(object, method);
  }

  // Use vi.mock for complete module replacement
  static mockModule(modulePath: string, factory?: () => any) {
    return vi.mock(modulePath, factory);
  }

  // Helper for mocking only external dependencies
  static mockExternalDependency<T>(
    dependency: T,
    overrides: Partial<T> = {}
  ): T {
    const baseMock = vi.fn() as any;
    return Object.assign(baseMock, overrides) as T;
  }
}

Key Vitest principles:

  • Use vi.clearAllMocks() in beforeEach to prevent test pollution
  • Use vi.mock() for complete module replacement
  • Use vi.spyOn() for temporary method overrides
  • Leverage TypeScript types with vi.mocked() for better IDE support

TypeScript Intersection Types for Mocks

TypeScript's intersection types are particularly powerful for mock objects, combining mock functionality with interface typing for full type safety:

// TypeScript Interface-First Design with Intersection Types for Mocks
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';

// GOOD: TypeScript uses I prefix for interfaces
interface IUserRepository {
  findById(id: number): Promise<User | null>;
  save(user: User): Promise<void>;
  findByEmail(email: string): Promise<User | null>;
}

interface IEmailService {
  send(to: string, subject: string, body: string): Promise<boolean>;
  sendTemplate(to: string, template: string, data: Record<string, any>): Promise<boolean>;
}

interface IPasswordHasher {
  hash(password: string): Promise<string>;
  verify(password: string, hash: string): Promise<boolean>;
}

// TypeScript DTO/Value Object - should NEVER be mocked
interface User {
  readonly id: number | null;
  readonly email: string;
  readonly passwordHash: string;
  readonly createdAt: Date;
}

// Business service that depends on interfaces
class UserRegistrationService {
  constructor(
    private userRepository: IUserRepository,
    private emailService: IEmailService,
    private passwordHasher: IPasswordHasher
  ) {}

  async registerUser(email: string, password: string): Promise<User> {
    // Check if user already exists
    const existing = await this.userRepository.findByEmail(email);
    if (existing) {
      throw new Error(`User with email ${email} already exists`);
    }

    // Create new user with hashed password
    const hashedPassword = await this.passwordHasher.hash(password);
    const user: User = {
      id: null, // Will be assigned by repository
      email,
      passwordHash: hashedPassword,
      createdAt: new Date()
    };

    // Save user
    await this.userRepository.save(user);

    // Send welcome email
    await this.emailService.sendTemplate(email, 'welcome', {
      email,
      welcomeMessage: 'Welcome to our platform!'
    });

    return user;
  }
}

describe('UserRegistrationService - TypeScript Intersection Types', () => {
  let service: UserRegistrationService;

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should register user successfully using intersection types', async () => {
    // GOOD: Using intersection types to combine Mock<T> with interface
    // This gives you full type safety AND mock functionality
    const mockUserRepository: Mock<any> & IUserRepository = {
      findById: vi.fn(),
      save: vi.fn().mockResolvedValue(undefined),
      findByEmail: vi.fn().mockResolvedValue(null), // User doesn't exist
    };

    const mockEmailService: Mock<any> & IEmailService = {
      send: vi.fn(),
      sendTemplate: vi.fn().mockResolvedValue(true),
    };

    const mockPasswordHasher: Mock<any> & IPasswordHasher = {
      hash: vi.fn().mockResolvedValue('hashed_password_123'),
      verify: vi.fn(),
    };

    // Create service with properly typed mocks
    service = new UserRegistrationService(
      mockUserRepository,
      mockEmailService,
      mockPasswordHasher
    );

    // Test the registration logic
    const user = await service.registerUser('test@example.com', 'password123');

    // Assertions
    expect(user.email).toBe('test@example.com');
    expect(user.passwordHash).toBe('hashed_password_123');
    expect(user.createdAt).toBeInstanceOf(Date);
    
    // Verify interactions with full type safety
    expect(mockUserRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
    expect(mockUserRepository.save).toHaveBeenCalledWith(
      expect.objectContaining({
        email: 'test@example.com',
        passwordHash: 'hashed_password_123'
      })
    );
    expect(mockPasswordHasher.hash).toHaveBeenCalledWith('password123');
    expect(mockEmailService.sendTemplate).toHaveBeenCalledWith(
      'test@example.com',
      'welcome',
      expect.objectContaining({
        email: 'test@example.com'
      })
    );
  });

  it('should handle existing user error', async () => {
    const existingUser: User = {
      id: 1,
      email: 'test@example.com',
      passwordHash: 'existing_hash',
      createdAt: new Date()
    };

    // Mock returns existing user
    const mockUserRepository: Mock<any> & IUserRepository = {
      findById: vi.fn(),
      save: vi.fn(),
      findByEmail: vi.fn().mockResolvedValue(existingUser),
    };

    const mockEmailService: Mock<any> & IEmailService = {
      send: vi.fn(),
      sendTemplate: vi.fn(),
    };

    const mockPasswordHasher: Mock<any> & IPasswordHasher = {
      hash: vi.fn(),
      verify: vi.fn(),
    };

    service = new UserRegistrationService(
      mockUserRepository,
      mockEmailService,
      mockPasswordHasher
    );

    // Should throw error for existing user
    await expect(
      service.registerUser('test@example.com', 'password123')
    ).rejects.toThrow('User with email test@example.com already exists');

    // Should not attempt to save or send email
    expect(mockUserRepository.save).not.toHaveBeenCalled();
    expect(mockEmailService.sendTemplate).not.toHaveBeenCalled();
  });
});

// MODERN APPROACH: Using vi.Mocked<T> utility (RECOMMENDED)
describe('UserRegistrationService - Modern vi.Mocked<T>', () => {
  it('should work with vi.Mocked<T> utility', async () => {
    // BEST: Modern Vitest approach with vi.Mocked<T>
    const userRepository = {} as vi.Mocked<IUserRepository>;
    userRepository.findById = vi.fn();
    userRepository.save = vi.fn();
    userRepository.findByEmail = vi.fn().mockResolvedValue(null);

    const emailService = {} as vi.Mocked<IEmailService>;
    emailService.send = vi.fn();
    emailService.sendTemplate = vi.fn().mockResolvedValue(true);

    const passwordHasher = {} as vi.Mocked<IPasswordHasher>;
    passwordHasher.hash = vi.fn().mockResolvedValue('hashed_password_456');
    passwordHasher.verify = vi.fn();

    const service = new UserRegistrationService(
      userRepository,
      emailService,
      passwordHasher
    );

    const user = await service.registerUser('test2@example.com', 'secret123');

    expect(user.email).toBe('test2@example.com');
    expect(user.passwordHash).toBe('hashed_password_456');
    
    // Type-safe mock assertions
    expect(userRepository.findByEmail).toHaveBeenCalledWith('test2@example.com');
    expect(passwordHasher.hash).toHaveBeenCalledWith('secret123');
  });
});

// ALTERNATIVE: satisfies approach for inline mock creation
describe('UserRegistrationService - satisfies approach', () => {
  it('should work with satisfies keyword', async () => {
    // GOOD: satisfies ensures type compliance without losing inference
    const userRepository = {
      findById: vi.fn(),
      save: vi.fn(),
      findByEmail: vi.fn().mockResolvedValue(null),
    } satisfies IUserRepository;

    const emailService = {
      send: vi.fn(),
      sendTemplate: vi.fn().mockResolvedValue(true),
    } satisfies IEmailService;

    const passwordHasher = {
      hash: vi.fn().mockResolvedValue('hashed_password_789'),
      verify: vi.fn(),
    } satisfies IPasswordHasher;

    const service = new UserRegistrationService(
      userRepository,
      emailService,
      passwordHasher
    );

    const user = await service.registerUser('test3@example.com', 'secret789');

    expect(user.email).toBe('test3@example.com');
    expect(user.passwordHash).toBe('hashed_password_789');
    
    // Type-safe mock assertions
    expect(userRepository.findByEmail).toHaveBeenCalledWith('test3@example.com');
    expect(passwordHasher.hash).toHaveBeenCalledWith('secret789');
  });
});

// ANTI-PATTERN: Don't use concrete classes in TypeScript either
class BadUserService {
  constructor(
    private userRepo: ConcreteUserRepository,    // BAD: Concrete class
    private emailSvc: ConcreteEmailService,      // BAD: Concrete class
    private hasher: ConcretePasswordHasher       // BAD: Concrete class
  ) {}
  
  // This makes testing difficult and couples you to specific implementations
}

// Example concrete classes (for demonstration of anti-pattern)
class ConcreteUserRepository implements IUserRepository {
  async findById(id: number): Promise<User | null> {
    // Real database logic
    throw new Error('Not implemented');
  }
  
  async save(user: User): Promise<void> {
    // Real save logic
    throw new Error('Not implemented');
  }
  
  async findByEmail(email: string): Promise<User | null> {
    // Real query logic
    throw new Error('Not implemented');
  }
}

class ConcreteEmailService implements IEmailService {
  async send(to: string, subject: string, body: string): Promise<boolean> {
    // Real SMTP logic
    return false;
  }
  
  async sendTemplate(to: string, template: string, data: Record<string, any>): Promise<boolean> {
    // Real template rendering
    return false;
  }
}

class ConcretePasswordHasher implements IPasswordHasher {
  async hash(password: string): Promise<string> {
    // Real bcrypt logic
    return '';
  }
  
  async verify(password: string, hash: string): Promise<boolean> {
    // Real verification
    return false;
  }
}

TypeScript intersection type approaches:

  • Mock<any> & IInterface: Combines Vitest mock functionality with interface typing
  • vi.Mocked<IInterface>: Modern Vitest utility type with generics (recommended)
  • satisfies IInterface: TypeScript 4.9+ keyword for type validation without changing inference
  • Interface naming: TypeScript uses I prefix convention (Microsoft style)

Related documentation:

Better Alternatives to Mocking

Sometimes the best mock is no mock at all. Here are architectural patterns that reduce the need for mocking:

// Alternatives to mocking: Better architectural patterns

// 1. DEPENDENCY INJECTION - Makes testing easier without mocks
interface PaymentProcessor {
  process(amount: number): Promise<PaymentResult>;
}

interface NotificationService {
  send(message: string): Promise<void>;
}

class OrderService {
  constructor(
    private paymentProcessor: PaymentProcessor,
    private notificationService: NotificationService
  ) {}

  async processOrder(order: Order): Promise<OrderResult> {
    // Business logic here - easy to test with real implementations
    const total = this.calculateTotal(order);
    const payment = await this.paymentProcessor.process(total);
    
    if (payment.success) {
      await this.notificationService.send(`Order ${order.id} confirmed`);
    }
    
    return { success: payment.success, orderId: order.id };
  }

  // Pure function - no mocking needed!
  private calculateTotal(order: Order): number {
    return order.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  }
}

// 2. TEST DOUBLES - Simple fake implementations
class FakePaymentProcessor implements PaymentProcessor {
  private shouldSucceed: boolean = true;

  async process(amount: number): Promise<PaymentResult> {
    // Deterministic behavior for testing
    if (amount <= 0) {
      return { success: false, error: 'Invalid amount' };
    }
    
    if (!this.shouldSucceed) {
      return { success: false, error: 'Payment declined' };
    }

    return { success: true, transactionId: `fake-tx-${Date.now()}` };
  }

  // Test helper methods
  setFailureMode(): void {
    this.shouldSucceed = false;
  }

  setSuccessMode(): void {
    this.shouldSucceed = true;
  }
}

class FakeNotificationService implements NotificationService {
  public sentMessages: string[] = [];

  async send(message: string): Promise<void> {
    // Capture behavior for verification without mocking
    this.sentMessages.push(message);
  }

  // Test helper methods
  getLastMessage(): string | undefined {
    return this.sentMessages[this.sentMessages.length - 1];
  }

  clear(): void {
    this.sentMessages = [];
  }
}

// 3. BUILDER PATTERN - Easy test data creation
class OrderBuilder {
  private order: Order = {
    id: '123',
    items: [],
    customerId: 'customer1'
  };

  withId(id: string): OrderBuilder {
    this.order.id = id;
    return this;
  }

  withItem(id: string, price: number, quantity: number = 1): OrderBuilder {
    this.order.items.push({ id, price, quantity });
    return this;
  }

  withCustomer(customerId: string): OrderBuilder {
    this.order.customerId = customerId;
    return this;
  }

  build(): Order {
    return { ...this.order };
  }
}

// 4. TESTING WITH REAL IMPLEMENTATIONS - No mocks needed!
describe('OrderService with Real Dependencies', () => {
  let orderService: OrderService;
  let fakePaymentProcessor: FakePaymentProcessor;
  let fakeNotificationService: FakeNotificationService;

  beforeEach(() => {
    fakePaymentProcessor = new FakePaymentProcessor();
    fakeNotificationService = new FakeNotificationService();
    orderService = new OrderService(fakePaymentProcessor, fakeNotificationService);
  });

  it('processes successful orders without mocks', async () => {
    // Arrange - use builder pattern for clean test data
    const order = new OrderBuilder()
      .withId('order-123')
      .withItem('item1', 50.00, 2)
      .withItem('item2', 25.00, 1)
      .withCustomer('customer-456')
      .build();

    // Act
    const result = await orderService.processOrder(order);

    // Assert - verify outcomes, not implementation details
    expect(result.success).toBe(true);
    expect(result.orderId).toBe('order-123');
    
    // Verify side effects through test double
    expect(fakeNotificationService.getLastMessage()).toBe('Order order-123 confirmed');
  });

  it('handles payment failures gracefully', async () => {
    // Arrange
    fakePaymentProcessor.setFailureMode();
    const order = new OrderBuilder()
      .withId('order-456')
      .withItem('item1', 100.00)
      .build();

    // Act
    const result = await orderService.processOrder(order);

    // Assert
    expect(result.success).toBe(false);
    expect(fakeNotificationService.sentMessages).toHaveLength(0); // No confirmation sent
  });
});

// 5. FUNCTIONAL APPROACH - Pure functions need no mocking
export const OrderCalculator = {
  calculateSubtotal(items: OrderItem[]): number {
    return items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  },

  applyDiscount(subtotal: number, discountPercent: number): number {
    return subtotal * (1 - discountPercent / 100);
  },

  calculateTax(amount: number, taxRate: number): number {
    return amount * (taxRate / 100);
  },

  calculateTotal(items: OrderItem[], discountPercent: number = 0, taxRate: number = 10): number {
    const subtotal = this.calculateSubtotal(items);
    const discounted = this.applyDiscount(subtotal, discountPercent);
    const tax = this.calculateTax(discounted, taxRate);
    return discounted + tax;
  }
};

// Testing pure functions - no mocks needed!
describe('OrderCalculator', () => {
  it('calculates totals correctly', () => {
    const items = [
      { id: 'item1', price: 100, quantity: 2 },
      { id: 'item2', price: 50, quantity: 1 }
    ];

    const total = OrderCalculator.calculateTotal(items, 10, 8.5); // 10% discount, 8.5% tax

    expect(total).toBeCloseTo(244.25, 2); // (200 + 50) * 0.9 * 1.085
  });
});

Dependency Injection

Proper dependency injection makes your code testable without complex mocking. Inject interfaces, not concrete implementations.

Test Doubles

Simple fake implementations often work better than mocks. They're easier to understand and maintain, and they can evolve with your system.

Pure Functions

The more of your logic you can express as pure functions, the easier testing becomes. Pure functions need no mocks - just call them and verify the output.

The Mocking Decision Tree

Use this decision tree to determine whether something should be mocked:

// Mocking Guidelines: When to mock and when not to mock

// ✅ GOOD: Mock external dependencies and side effects
export class GoodMockingExamples {
  // Mock HTTP clients - external network calls
  static mockHttpClient() {
    const httpClient = {
      get: vi.fn().mockResolvedValue({ data: { userId: 123 } }),
      post: vi.fn().mockResolvedValue({ status: 201 }),
    };
    return httpClient;
  }

  // Mock database connections - external systems
  static mockDatabase() {
    const database = {
      query: vi.fn().mockResolvedValue([{ id: 1, name: 'Test User' }]),
      transaction: vi.fn().mockImplementation(async (callback) => {
        return callback(); // Simple transaction mock
      }),
    };
    return database;
  }

  // Mock file system operations - side effects
  static mockFileSystem() {
    const fs = {
      readFile: vi.fn().mockResolvedValue('file content'),
      writeFile: vi.fn().mockResolvedValue(undefined),
      exists: vi.fn().mockReturnValue(true),
    };
    return fs;
  }

  // Mock third-party services - external APIs
  static mockPaymentGateway() {
    const paymentGateway = {
      charge: vi.fn().mockResolvedValue({
        success: true,
        transactionId: 'tx_123',
        amount: 1000
      }),
    };
    return paymentGateway;
  }
}

// ❌ BAD: Don't mock these things
export class BadMockingExamples {
  // DON'T mock pure functions - test them directly!
  static dontMockPureFunctions() {
    // BAD - mocking a pure calculation
    const calculator = {
      add: vi.fn().mockReturnValue(5), // Why mock this?
      multiply: vi.fn().mockReturnValue(10),
    };
    
    // GOOD - use the real implementation
    const realCalculator = new Calculator();
    expect(realCalculator.add(2, 3)).toBe(5);
  }

  // DON'T mock value objects - they're data, not behavior
  static dontMockValueObjects() {
    // BAD - mocking a simple data structure
    const mockUser = {
      getName: vi.fn().mockReturnValue('John'),
      getEmail: vi.fn().mockReturnValue('john@example.com'),
    };

    // GOOD - create real value objects
    const user = new User('John', 'john@example.com');
    expect(user.getName()).toBe('John');
  }

  // DON'T mock internal business logic - test it!
  static dontMockBusinessLogic() {
    // BAD - mocking the very logic you want to test
    const orderService = {
      calculateDiscount: vi.fn().mockReturnValue(10),
      applyTax: vi.fn().mockReturnValue(108),
      validateOrder: vi.fn().mockReturnValue(true),
    };

    // GOOD - test the real business logic
    const realOrderService = new OrderService();
    const order = { items: [{ price: 100, quantity: 1 }], discountCode: 'SAVE10' };
    const result = realOrderService.calculateTotal(order);
    expect(result.discount).toBe(10);
  }

  // DON'T mock everything in a collaborator - be selective
  static dontOverMockCollaborators() {
    // BAD - mocking every method, even pure ones
    const userService = {
      findById: vi.fn(),
      validateEmail: vi.fn(), // Pure function - don't mock!
      hashPassword: vi.fn(),  // Pure function - don't mock!
      saveUser: vi.fn(),      // Database call - OK to mock
      sendWelcomeEmail: vi.fn(), // External service - OK to mock
    };

    // GOOD - mock only external dependencies
    const userRepository = { save: vi.fn(), findById: vi.fn() };
    const emailService = { send: vi.fn() };
    const realUserService = new UserService(userRepository, emailService);
  }
}

// Mocking Decision Tree
export const MockingDecisionTree = {
  shouldMock(dependency: any): boolean {
    // External system (database, API, file system)?
    if (this.isExternalSystem(dependency)) return true;
    
    // Has side effects (logging, messaging, notifications)?
    if (this.hasSideEffects(dependency)) return true;
    
    // Slow or expensive to create?
    if (this.isSlowOrExpensive(dependency)) return true;
    
    // Non-deterministic (random, time-based)?
    if (this.isNonDeterministic(dependency)) return true;
    
    // Otherwise, use the real implementation
    return false;
  },

  isExternalSystem(dependency: any): boolean {
    const externalIndicators = [
      'client', 'gateway', 'api', 'repository', 
      'database', 'cache', 'queue', 'storage'
    ];
    const name = dependency.constructor?.name?.toLowerCase() || '';
    return externalIndicators.some(indicator => name.includes(indicator));
  },

  hasSideEffects(dependency: any): boolean {
    const sideEffectIndicators = [
      'logger', 'mailer', 'notifier', 'publisher',
      'tracker', 'monitor', 'reporter'
    ];
    const name = dependency.constructor?.name?.toLowerCase() || '';
    return sideEffectIndicators.some(indicator => name.includes(indicator));
  },

  isSlowOrExpensive(dependency: any): boolean {
    // Check for expensive operations
    const expensiveIndicators = [
      'processor', 'generator', 'builder', 'compiler'
    ];
    const name = dependency.constructor?.name?.toLowerCase() || '';
    return expensiveIndicators.some(indicator => name.includes(indicator));
  },

  isNonDeterministic(dependency: any): boolean {
    const nonDeterministicIndicators = [
      'random', 'uuid', 'timestamp', 'clock', 'timer'
    ];
    const name = dependency.constructor?.name?.toLowerCase() || '';
    return nonDeterministicIndicators.some(indicator => name.includes(indicator));
  }
};

// Example usage in tests
export class SmartMockingTest {
  static createTestSubject() {
    // Only mock what needs mocking
    const httpClient = vi.fn(); // External - mock it
    const database = vi.fn();   // External - mock it
    const logger = vi.fn();     // Side effect - mock it
    
    // Use real implementations for business logic
    const calculator = new PriceCalculator();
    const validator = new OrderValidator();
    const formatter = new CurrencyFormatter();

    return new OrderService(
      httpClient,
      database,
      logger,
      calculator,  // Real implementation
      validator,   // Real implementation  
      formatter    // Real implementation
    );
  }
}

// Hot Sauce Principle: A little goes a long way
export const HotSaucePrinciple = {
  // Like hot sauce, mocks should enhance the test, not overpower it
  // Too much mocking makes tests inedible (unmaintainable)
  
  tooLittleMocking: 'Tests are slow, flaky, or coupled to external systems',
  justRightMocking: 'Tests are fast, reliable, and focus on behavior',
  tooMuchMocking: 'Tests are brittle, unclear, and test implementation details',
  
  advice: [
    'Mock external dependencies and side effects',
    'Use real implementations for business logic',
    'Prefer test doubles over complex mocks',
    'Focus on testing outcomes, not implementation',
    'If your test is mostly mocks, reconsider your architecture'
  ]
};

Questions to Ask Yourself

  1. Is it an external system? (Database, API, file system) → Mock it
  2. Does it have side effects? (Logging, email, events) → Mock it
  3. Is it non-deterministic? (Random, time-based) → Mock it
  4. Is it slow or expensive? → Consider mocking
  5. Is it business logic I want to test? → Don't mock it

Mocking Anti-Patterns to Avoid

The "Mock Everything" Pattern

Creating mocks for every dependency, including value objects and pure functions. This leads to tests that break constantly and provide no real value.

The "Implementation Coupling" Pattern

Using expect().toHaveBeenCalledWith() for every mock interaction. This couples your tests to implementation details instead of behavior.

The "Mock Return Mock" Pattern

Mocks that return other mocks, creating complex nested mock hierarchies that are impossible to maintain.

The "Shared Mock State" Pattern

Reusing mock objects across tests without proper cleanup, leading to test interdependence and flaky tests.

Testing in Production: Real-World Guidelines

The 80/20 Rule

In a well-architected system, about 80% of your business logic should be testable without mocks. The remaining 20% involves external integrations that genuinely need mocking.

Mock at the Boundaries

Mock at the edges of your system - where your code talks to external services. Keep the internal domain logic mock-free.

Integration Tests for Glue Code

Use integration tests to verify that your mocked components actually work together. Unit tests with mocks verify individual components; integration tests verify the whole system.

Modern Testing Tools and Frameworks

TypeScript with Vitest (2025)

Vitest provides excellent TypeScript support and fast test execution. Unlike Jest, it doesn't auto-mock modules, forcing you to be intentional about what you mock.

PHP with PHPUnit 11+

Modern PHPUnit versions work well with PHP 8.4's type system and provide better mock object APIs. Consider using Mockery for more expressive mock syntax.

Signs Your Tests Need Less Mock

Watch for these warning signs that indicate over-mocking:

  • Mock setup is longer than the actual test - You're probably mocking too much
  • Tests break when you refactor internal implementation - Tests are coupled to implementation
  • You can't understand what the code does by reading the test - Too many mocks obscure intent
  • Adding a new parameter breaks 20 tests - Over-mocked tests are brittle
  • Mocks return other mocks - Your object graph is too complex
  • You spend more time fixing tests than writing features - Technical debt from bad mocking

Conclusion: The Hot Sauce Test

Before you add a mock to your test, ask yourself: "Is this mock like a drop of hot sauce that enhances the test, or am I drowning my test in mocks until I can't taste the actual logic anymore?"

Key Takeaways

  • Mock external dependencies and side effects - databases, APIs, logging, email
  • Don't mock business logic - test the real implementations
  • Use dependency injection - makes testing easier without complex mocks
  • Prefer test doubles over complex mocks - simpler and more maintainable
  • Focus on behavior, not implementation - test what the code does, not how
  • If your test is mostly mocks, reconsider your architecture - the problem might be design, not testing

Remember: good tests should help you refactor with confidence. If your tests break every time you change internal implementation details, you're not testing behavior - you're testing implementation. Use mocks like hot sauce: sparingly, purposefully, and only where they truly add value.

Further Reading