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()
, andnever()
for comprehensive behavior verification - Interface-first design: Custom interfaces that extend base functionality are cleaner than complex intersections
Related documentation:
- PHPUnit 11 Test Doubles Documentation - Official guide to mocking and intersection types
- PHPUnit 12 Release Notes - Latest PHPUnit features and deprecations
- PHP 8.1 Intersection Types - Comprehensive guide to PHP intersection types
- PHPStan: Union vs Intersection Types - Advanced typing patterns for PHP
- PHP Union Types - Official PHP documentation for union type declarations
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()
inbeforeEach
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 typingvi.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:
- Vitest Mocking Guide - Official documentation for mocking in Vitest
- Vitest Vi API Reference - Complete vi.mocked() and testing utilities
- TypeScript 4.9 Release Notes - Official satisfies keyword documentation
- Frontend Masters: Satisfies in TypeScript - Practical guide to the satisfies operator
- Total TypeScript: Satisfies Operator - Advanced patterns and best practices
- TypeScript Union Types - Official documentation for union type declarations
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
- Is it an external system? (Database, API, file system) → Mock it
- Does it have side effects? (Logging, email, events) → Mock it
- Is it non-deterministic? (Random, time-based) → Mock it
- Is it slow or expensive? → Consider mocking
- 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
- Vitest Mocking Guide - Official documentation with TypeScript examples
- PHPUnit Test Doubles - Comprehensive guide to mocking in PHP
- Mocks Aren't Stubs - Martin Fowler's classic explanation of test doubles
- Mockery - Expressive mocking framework for PHP
- Bypass Finals - Tool for mocking final classes in PHP