模拟和存根
概述
模拟和存根是隔离代码单元进行测试的基本技术,通过替换依赖项为受控的测试替身。这使得单元测试快速、可靠、专注,不依赖于外部系统,如数据库、API或文件系统。
何时使用
- 从外部依赖项中隔离单元测试
- 测试依赖于慢速操作的代码(数据库、网络)
- 模拟错误条件和边缘情况
- 验证对象之间的交互
- 测试具有非确定性行为的代码(时间、随机性)
- 避免在测试中进行昂贵的操作
- 测试错误处理而不触发真实故障
测试替身类型
- 存根:返回预定义值,不验证行为
- 模拟:验证交互(方法调用、参数)
- 间谍:包装真实对象,允许部分模拟
- 假:工作实现,但简化(内存数据库)
- 虚拟:传递但从未使用(填充参数列表)
指令
1. Jest 模拟(JavaScript/TypeScript)
基本模拟
// services/UserService.ts
import { UserRepository } from './UserRepository';
import { EmailService } from './EmailService';
export class UserService {
constructor(
private userRepository: UserRepository,
private emailService: EmailService
) {}
async createUser(userData: CreateUserDto) {
const user = await this.userRepository.create(userData);
await this.emailService.sendWelcomeEmail(user.email, user.name);
return user;
}
async getUserStats(userId: string) {
const user = await this.userRepository.findById(userId);
if (!user) throw new Error('User not found');
const orderCount = await this.userRepository.getOrderCount(userId);
return { ...user, orderCount };
}
}
// __tests__/UserService.test.ts
import { UserService } from '../UserService';
import { UserRepository } from '../UserRepository';
import { EmailService } from '../EmailService';
// 模拟依赖项
jest.mock('../UserRepository');
jest.mock('../EmailService');
describe('UserService', () => {
let userService: UserService;
let mockUserRepository: jest.Mocked<UserRepository>;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(() => {
// 每次测试前清除所有模拟
jest.clearAllMocks();
// 创建模拟实例
mockUserRepository = new UserRepository() as jest.Mocked<UserRepository>;
mockEmailService = new EmailService() as jest.Mocked<EmailService>;
userService = new UserService(mockUserRepository, mockEmailService);
});
describe('createUser', () => {
it('should create user and send welcome email', async () => {
// 安排
const userData = {
email: 'test@example.com',
name: 'Test User',
password: 'password123'
};
const createdUser = {
id: '123',
...userData,
createdAt: new Date()
};
mockUserRepository.create.mockResolvedValue(createdUser);
mockEmailService.sendWelcomeEmail.mockResolvedValue(undefined);
// 行动
const result = await userService.createUser(userData);
// 断言
expect(result).toEqual(createdUser);
expect(mockUserRepository.create).toHaveBeenCalledWith(userData);
expect(mockUserRepository.create).toHaveBeenCalledTimes(1);
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
userData.email,
userData.name
);
});
it('should not send email if user creation fails', async () => {
// 安排
mockUserRepository.create.mockRejectedValue(
new Error('Database error')
);
// 行动 & 断言
await expect(
userService.createUser({ email: 'test@example.com' })
).rejects.toThrow('Database error');
expect(mockEmailService.sendWelcomeEmail).not.toHaveBeenCalled();
});
});
describe('getUserStats', () => {
it('should return user with order count', async () => {
// 安排
const userId = '123';
const user = { id: userId, name: 'Test User' };
mockUserRepository.findById.mockResolvedValue(user);
mockUserRepository.getOrderCount.mockResolvedValue(5);
// 行动
const result = await userService.getUserStats(userId);
// 断言
expect(result).toEqual({ ...user, orderCount: 5 });
expect(mockUserRepository.findById).toHaveBeenCalledWith(userId);
expect(mockUserRepository.getOrderCount).toHaveBeenCalledWith(userId);
});
it('should throw error if user not found', async () => {
// 安排
mockUserRepository.findById.mockResolvedValue(null);
// 行动 & 断言
await expect(userService.getUserStats('999')).rejects.toThrow(
'User not found'
);
expect(mockUserRepository.getOrderCount).not.toHaveBeenCalled();
});
});
});
间谍功能
// services/PaymentService.js
const stripe = require('stripe');
class PaymentService {
async processPayment(amount, currency, customerId) {
const charge = await stripe.charges.create({
amount: amount * 100,
currency,
customer: customerId,
});
this.logPayment(charge.id, amount);
return charge;
}
logPayment(chargeId, amount) {
console.log(`Payment processed: ${chargeId} for $${amount}`);
}
}
// __tests__/PaymentService.test.js
describe('PaymentService', () => {
let paymentService;
let stripeMock;
beforeEach(() => {
// 模拟Stripe模块
stripeMock = {
charges: {
create: jest.fn(),
},
};
jest.mock('stripe', () => jest.fn(() => stripeMock));
paymentService = new PaymentService();
});
it('should process payment and log', async () => {
// 安排
const mockCharge = { id: 'ch_123', amount: 5000 };
stripeMock.charges.create.mockResolvedValue(mockCharge);
// 间谍内部方法
const logSpy = jest.spyOn(paymentService, 'logPayment');
// 行动
await paymentService.processPayment(50, 'usd', 'cus_123');
// 断言
expect(stripeMock.charges.create).toHaveBeenCalledWith({
amount: 5000,
currency: 'usd',
customer: 'cus_123',
});
expect(logSpy).toHaveBeenCalledWith('ch_123', 50);
logSpy.mockRestore();
});
});
2. Python模拟unittest.mock
# services/order_service.py
from typing import Optional
from repositories.order_repository import OrderRepository
from services.payment_service import PaymentService
from services.notification_service import NotificationService
class OrderService:
def __init__(
self,
order_repository: OrderRepository,
payment_service: PaymentService,
notification_service: NotificationService
):
self.order_repository = order_repository
self.payment_service = payment_service
self.notification_service = notification_service
def create_order(self, user_id: str, items: list) -> Order:
"""Create and process a new order."""
order = self.order_repository.create({
'user_id': user_id,
'items': items,
'status': 'pending'
})
try:
payment = self.payment_service.process_payment(
order.id,
order.total
)
order.status = 'paid'
order.payment_id = payment.id
self.order_repository.update(order)
self.notification_service.send_order_confirmation(
order.user_id,
order.id
)
except PaymentError as e:
order.status = 'failed'
self.order_repository.update(order)
raise
return order
# tests/test_order_service.py
import pytest
from unittest.mock import Mock, MagicMock, patch, call
from services.order_service import OrderService
from exceptions import PaymentError
class TestOrderService:
@pytest.fixture
def mock_dependencies(self):
"""Create mock dependencies."""
return {
'order_repository': Mock(),
'payment_service': Mock(),
'notification_service': Mock()
}
@pytest.fixture
def order_service(self, mock_dependencies):
"""Create OrderService with mocked dependencies."""
return OrderService(**mock_dependencies)
def test_create_order_success(self, order_service, mock_dependencies):
"""Test successful order creation and payment."""
# 安排
user_id = 'user-123'
items = [{'product_id': 'p1', 'quantity': 2}]
mock_order = Mock(
id='order-123',
total=99.99,
status='pending',
user_id=user_id
)
mock_payment = Mock(id='payment-123')
mock_dependencies['order_repository'].create.return_value = mock_order
mock_dependencies['payment_service'].process_payment.return_value = mock_payment
# 行动
result = order_service.create_order(user_id, items)
# 断言
assert result.status == 'paid'
assert result.payment_id == 'payment-123'
mock_dependencies['order_repository'].create.assert_called_once_with({
'user_id': user_id,
'items': items,
'status': 'pending'
})
mock_dependencies['payment_service'].process_payment.assert_called_once_with(
'order-123',
99.99
)
mock_dependencies['notification_service'].send_order_confirmation.assert_called_once_with(
user_id,
'order-123'
)
assert mock_dependencies['order_repository'].update.call_count == 1
def test_create_order_payment_failure(self, order_service, mock_dependencies):
"""Test order creation when payment fails."""
# 安排
mock_order = Mock(id='order-123', total=99.99, status='pending')
mock_dependencies['order_repository'].create.return_value = mock_order
mock_dependencies['payment_service'].process_payment.side_effect = PaymentError('Card declined')
# 行动 & 断言
with pytest.raises(PaymentError):
order_service.create_order('user-123', [])
# 验证订单状态更新为失败
assert mock_order.status == 'failed'
mock_dependencies['order_repository'].update.assert_called()
# 通知不应发送
mock_dependencies['notification_service'].send_order_confirmation.assert_not_called()
@patch('services.order_service.datetime')
def test_order_timestamp(self, mock_datetime, order_service, mock_dependencies):
"""Test order creation with mocked time."""
# 安排
fixed_time = datetime(2024, 1, 1, 12, 0, 0)
mock_datetime.now.return_value = fixed_time
mock_order = Mock(id='order-123', created_at=fixed_time)
mock_dependencies['order_repository'].create.return_value = mock_order
# 行动
result = order_service.create_order('user-123', [])
# 断言
assert result.created_at == fixed_time
3. Mockito for Java
// service/UserService.java
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
private final AuditLogger auditLogger;
public UserService(
UserRepository userRepository,
EmailService emailService,
AuditLogger auditLogger
) {
this.userRepository = userRepository;
this.emailService = emailService;
this.auditLogger = auditLogger;
}
public User createUser(UserDto userDto) {
User user = userRepository.save(mapToUser(userDto));
emailService.sendWelcomeEmail(user.getEmail());
auditLogger.log("User created: " + user.getId());
return user;
}
public Optional<User> getUserWithOrders(Long userId) {
return userRepository.findByIdWithOrders(userId);
}
}
// test/UserServiceTest.java
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@Mock
private AuditLogger auditLogger;
@InjectMocks
private UserService userService;
@Test
void createUser_shouldSaveAndSendEmail() {
// 安排
UserDto userDto = new UserDto("test@example.com", "Test User");
User savedUser = new User(1L, "test@example.com", "Test User");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
doNothing().when(emailService).sendWelcomeEmail(anyString());
// 行动
User result = userService.createUser(userDto);
// 断言
assertNotNull(result);
assertEquals(1L, result.getId());
verify(userRepository, times(1)).save(any(User.class));
verify(emailService, times(1)).sendWelcomeEmail("test@example.com");
verify(auditLogger, times(1)).log(contains("User created"));
}
@Test
void createUser_shouldThrowExceptionWhenEmailFails() {
// 安排
UserDto userDto = new UserDto("test@example.com", "Test User");
User savedUser = new User(1L, "test@example.com", "Test User");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
doThrow(new EmailException("SMTP error"))
.when(emailService)
.sendWelcomeEmail(anyString());
// 行动 & 断言
assertThrows(EmailException.class, () -> {
userService.createUser(userDto);
});
verify(userRepository).save(any(User.class));
verify(emailService).sendWelcomeEmail("test@example.com");
}
@Test
void getUserWithOrders_shouldReturnUserWhenExists() {
// 安排
User user = new User(1L, "test@example.com", "Test User");
when(userRepository.findByIdWithOrders(1L))
.thenReturn(Optional.of(user));
// 行动
Optional<User> result = userService.getUserWithOrders(1L);
// 断言
assertTrue(result.isPresent());
assertEquals(user, result.get());
verify(userRepository).findByIdWithOrders(1L);
}
@Test
void getUserWithOrders_shouldReturnEmptyWhenNotExists() {
// 安排
when(userRepository.findByIdWithOrders(999L))
.thenReturn(Optional.empty());
// 行动
Optional<User> result = userService.getUserWithOrders(999L);
// 断言
assertFalse(result.isPresent());
}
@Captor
private ArgumentCaptor<User> userCaptor;
@Test
void createUser_shouldSaveUserWithCorrectData() {
// 安排
UserDto userDto = new UserDto("test@example.com", "Test User");
when(userRepository.save(any(User.class)))
.thenReturn(new User(1L, "test@example.com", "Test User"));
// 行动
userService.createUser(userDto);
// 断言 - 捕获并验证保存的用户
verify(userRepository).save(userCaptor.capture());
User capturedUser = userCaptor.getValue();
assertEquals("test@example.com", capturedUser.getEmail());
assertEquals("Test User", capturedUser.getName());
}
}
4. 高级模拟模式
// 模拟计时器
describe('Scheduled Tasks', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should execute task after delay', () => {
const callback = jest.fn();
const scheduler = new TaskScheduler();
scheduler.scheduleTask(callback, 5000);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(5000);
expect(callback).toHaveBeenCalledTimes(1);
});
});
// 部分模拟
describe('UserService with partial mocking', () => {
it('should use real method for validation, mock for DB', async () => {
const userService = new UserService();
// 间谍真实对象
const saveSpy = jest
.spyOn(userService.repository, 'save')
.mockResolvedValue({ id: '123' });
// 真实验证方法被使用
await expect(
userService.createUser({ email: 'invalid' })
).rejects.toThrow('Invalid email');
expect(saveSpy).not.toHaveBeenCalled();
// 有效数据使用模拟保存
await userService.createUser({ email: 'valid@example.com' });
expect(saveSpy).toHaveBeenCalled();
});
});
最佳实践
✅ DO
- 模拟外部依赖项(数据库、API、文件系统)
- 使用依赖注入以便于模拟
- 验证与模拟的重要交互
- 测试之间重置模拟
- 在边界(存储库、服务)处模拟
- 如有需要,使用间谍进行部分模拟
- 创建可重用的模拟工厂
- 测试成功和失败两种场景
❌ DON’T
- 模拟一切(不要模拟你自己的东西)
- 过度指定模拟交互
- 在集成测试中使用模拟
- 模拟简单的实用程序函数
- 创建复杂的模拟层次结构
- 忘记验证模拟调用
- 在测试之间共享模拟
- 仅仅为了使测试通过而模拟
工具和库
- JavaScript/TypeScript:Jest, Sinon.js, ts-mockito
- Python:unittest.mock, pytest-mock, responses
- Java:Mockito, EasyMock, PowerMock, JMockit
- C#:Moq, NSubstitute, FakeItEasy
示例
另见:integration-testing, test-data-generation, test-automation-framework 以获得完整的测试模式。