Mock是现代软件测试不可或缺的工具,但也是最容易误用的工具之一。一个好的Mock策略能让测试快速、稳定、可读;一个糟糕的Mock策略会让测试变成维护噩梦,甚至失去测试的意义。
这篇文章深度分析不同类型的Mock策略,它们的适用场景、优缺点、常见陷阱,以及如何在真实项目中制定合理的Mock策略。无论你用的是Java的Mockito、Python的unittest.mock、JavaScript的Jest,还是其他任何语言的测试框架,本文的讨论都适用。
为什么Mock策略如此重要?
在讨论"怎么Mock"之前,必须先理解为什么Mock策略值得花这么多篇幅讨论。
测试的困境
现代软件开发中,自动化测试已经是标配。但很多团队的测试实践陷入了困境:
- 测试太慢:一个测试套件跑完需要几十分钟甚至几个小时,开发者不愿意在本地跑测试,CI流水线也成为瓶颈
- 测试太脆弱:生产代码稍微一改,几百个测试挂掉,而且很多时候挂掉的测试跟改动的逻辑无关
- 测试可信度低:覆盖率90%以上,但线上bug还是不断,测试就像"皇帝的新衣"
- 测试维护成本高:重构生产代码的同时要改一大堆测试,开发者开始抵触写测试
这些问题的根源往往不是"该不该写测试",而是"怎么写测试"——具体来说,就是如何合理地使用Mock。
一个真实案例:Mock过度导致的测试失效
2025年,某电商团队重构了支付模块。重构前,支付模块的单元测试覆盖率95%,所有测试都是用Mock写的——Mock了数据库、Mock了支付网关、Mock了消息队列。
重构后,代码更简洁了,逻辑更清晰了,但令人震惊的是:所有单元测试仍然通过。
问题在于:重构前的测试,Mock的是旧接口的方法签名和行为。重构后,虽然方法签名没变,但内部调用关系变了。旧的测试Mock了所有依赖,所以即使真实逻辑已经错了,测试仍然通过。
这个案例揭示了一个残酷的事实:过度Mock的测试,测的不是业务逻辑,而是Mock配置。
Mock策略的核心矛盾
Mock策略面临一个核心矛盾:
- 一方面,我们需要隔离外部依赖,让测试快速、稳定
- 另一方面,隔离得越多,测试离真实行为越远,可信度越低
这个矛盾没有标准答案,需要根据项目阶段、团队成熟度、系统特点来权衡。后面的内容会给你做出权衡所需的全部知识。
Mock的本质:为什么要Mock?
什么是Mock?
Mock(模拟)是一种测试替身(Test Double),用来替代真实对象,让测试能够控制依赖的行为、验证依赖的交互。
但Mock不只是Mock。Gerard Meszaros在《xUnit Test Patterns》中定义了五种Test Double,理解这个分类是制定Mock策略的基础。
五种Test Double详解
1. Dummy(哑对象)
Dummy是最简单的Test Double:它什么都不做,只是为了让代码能编译通过或满足参数要求。
使用场景:某个方法签名需要一个参数,但这个参数在你的测试中不重要。
// 生产代码
public class OrderProcessor {
public void process(Order order, AuditLogger logger) {
// logger 只在某些分支中才会用到
if (order.getAmount() > 10000) {
logger.log("Large order: " + order.getId());
}
order.setStatus("PROCESSED");
}
}
// 测试代码
@Test
public void testProcess_smallAmount() {
Order order = new Order(100); // 小额订单,不会触发audit log
AuditLogger dummyLogger = null; // Dummy:不需要真的logger
processor.process(order, dummyLogger);
assertEquals("PROCESSED", order.getStatus());
}
更现实的例子:在Python中,你可能需要传递一个配置对象,但测试不关心配置内容:
# 生产代码需要 config
def process_data(data, config):
threshold = config.get('threshold', 100)
return [x for x in data if x > threshold]
# 测试:用 Dummy config
def test_process_data():
config = {} # Dummy:空的就行,会用默认值100
result = process_data([50, 150, 200], config)
assert result == [150, 200]
Dummy的价值:让测试聚焦于真正重要的东西,忽略不重要的东西。但Dummy用得少,因为大多数情况下你可以传None或默认值。
2. Fake(假对象)
Fake是有真实实现的对象,但这个实现走的是捷径,不适合生产环境,但适合测试。
最典型的例子:InMemoryRepository,它在内存中实现了一个Repository接口,不连接真实数据库,但提供了完整的CRUD功能。
# 生产代码用真实的数据库Repository
class DatabaseUserRepository:
def __init__(self, db_connection):
self.db = db_connection
def find_by_id(self, user_id):
# 真实的SQL查询
cursor = self.db.cursor()
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
return cursor.fetchone()
def save(self, user):
# 真实的INSERT
cursor = self.db.cursor()
cursor.execute(
"INSERT INTO users (email, password_hash) VALUES (%s, %s)",
(user.email, user.password_hash)
)
self.db.commit()
# 测试用Fake Repository
class InMemoryUserRepository:
def __init__(self):
self.users = {} # {user_id: User}
self.email_index = {} # {email: user_id} 用于唯一性检查
def find_by_id(self, user_id):
return self.users.get(user_id)
def find_by_email(self, email):
user_id = self.email_index.get(email)
if user_id:
return self.users.get(user_id)
return None
def save(self, user):
# 模拟唯一性约束
if user.email in self.email_index:
raise DuplicateEmailException(user.email)
if not user.id:
user.id = str(uuid.uuid4())
self.users[user.id] = user
self.email_index[user.email] = user.id
return user
def delete(self, user_id):
user = self.users.get(user_id)
if user:
del self.users[user_id]
del self.email_index[user.email]
# 测试
def test_user_registration():
repo = InMemoryUserRepository() # Fake
email_service = FakeEmailService() # 另一个Fake
service = UserService(repo, email_service)
user = service.register("alice@example.com", "password123")
assert repo.find_by_id(user.id) == user
assert email_service.email_sent_to == "alice@example.com"
Fake的优点:
- 行为接近真实实现,测试的可信度比Mock高
- 不需要为每个测试设置Mock行为,使用更简单
- 能模拟真实实现中的约束(如唯一性、事务)
Fake的缺点:
- 需要自己实现,维护成本比直接用Mock框架高
- Fake和真实实现可能行为不一致,导致"测试通过但生产失败"
什么时候用Fake:
- 当你需要模拟的依赖有复杂的行为(如数据库的唯一性约束、事务语义)
- 当同一个Fake可以在多个测试中复用时(值得投入时间实现)
- 当你用的语言没有好用的Mock框架时(如Go语言的Mock不太方便,用Fake更常见)
3. Stub(桩对象)
Stub提供固定的返回值,用来替代真实对象的返回值。
核心特征:Stub只关注"返回什么",不关注"是否被调用"。
// 使用Mockito创建Stub
@Test
public void testLogin_userNotFound() {
UserRepository stubRepo = Mockito.mock(UserRepository.class);
// Stub:让findById返回null(用户不存在)
Mockito.when(stubRepo.findById("nonexistent"))
.thenReturn(null);
AuthService authService = new AuthService(stubRepo);
LoginResult result = authService.login("nonexistent", "password");
assertFalse(result.isSuccess());
assertEquals("User not found", result.getError());
}
更复杂的Stub例子:模拟不同的场景
import unittest.mock as mock
def test_order_total_with_stub():
# Stub:模拟订单有不同的商品
order_repo = mock.Mock()
# 场景1:空订单
order_repo.find_by_id.return_value = Order(
id="1",
items=[] # 没有商品
)
service = OrderService(order_repo)
assert service.calculate_total("1") == 0
# 场景2:有商品的订单
order_repo.find_by_id.return_value = Order(
id="2",
items=[
OrderItem(price=100, quantity=2),
OrderItem(price=50, quantity=1)
]
)
assert service.calculate_total("2") == 250 # 100*2 + 50*1
Stub的进阶用法:根据参数返回不同的值
// 根据输入参数返回不同的Stub值
Mockito.when(userRepository.findById(Mockito.anyString()))
.thenAnswer(invocation -> {
String userId = invocation.getArgument(0);
if ("vip-user".equals(userId)) {
User user = new User(userId, "vip@example.com");
user.setMembershipLevel("VIP");
return user;
} else if ("normal-user".equals(userId)) {
return new User(userId, "normal@example.com");
} else {
return null;
}
});
Stub的最佳实践:
- 只Stub你需要的,不要Stub所有方法(用
Mockito.RETURNS_SMART_NULLS或类似配置) - 用描述性的方法名或变量名,让Stub的意图清晰
- 对于复杂的Stub逻辑,考虑用Fake替代
4. Mock(模拟对象)
Mock和Stub经常被混用,但严格来说,Mock不仅提供返回值,还验证交互行为:某个方法是否被正确调用、被调用了几次、参数是否正确。
核心特征:Mock关注"是否被调用"和"怎么被调用",用于验证副作用。
@Test
public void testRegister_sendsWelcomeEmail() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmailService = Mockito.mock(EmailService.class);
// Stub:让save返回什么
Mockito.when(mockRepo.save(Mockito.any(User.class)))
.thenAnswer(inv -> inv.getArgument(0));
UserService service = new UserService(mockRepo, mockEmailService);
service.register("alice@example.com", "password123");
// Mock:验证emailService.sendWelcomeEmail被调用了一次,且参数正确
Mockito.verify(mockEmailService, Mockito.times(1))
.sendWelcomeEmail(Mockito.eq("alice@example.com"));
}
Mock的三种验证方式:
- 验证调用次数:
Mockito.verify(mockService, Mockito.times(1)).someMethod(); // 精确1次
Mockito.verify(mockService, Mockito.atLeast(2)).someMethod(); // 至少2次
Mockito.verify(mockService, Mockito.never()).someMethod(); // 从未调用
Mockito.verify(mockService, Mockito.atMost(3)).someMethod(); // 最多3次
- 验证调用参数:
// 精确匹配
Mockito.verify(emailService).sendEmail(Mockito.eq("alice@example.com"), Mockito.eq("Welcome!"));
// 模糊匹配
Mockito.verify(emailService).sendEmail(
Mockito.eq("alice@example.com"),
Mockito.matches("Welcome.*") // 正则匹配
);
// 自定义匹配器
Mockito.verify(paymentGateway).charge(Mockito.argThat(chargeRequest ->
chargeRequest.getAmount() > 0 &&
"USD".equals(chargeRequest.getCurrency())
));
- 验证调用顺序:
// 验证方法按特定顺序被调用
InOrder inOrder = Mockito.inOrder(mockService);
inOrder.verify(mockService).step1();
inOrder.verify(mockService).step2();
inOrder.verify(mockService).step3();
Mock的陷阱:过度验证
// 过度验证的例子
Mockito.verify(mockService).doA();
Mockito.verify(mockService).doB();
Mockito.verify(mockService).doC();
Mockito.verify(mockService).doD();
// ... 验证了太多实现细节
// 更好的做法:只验证关键的副作用
Mockito.verify(mockEmailService).sendWelcomeEmail(anyString());
5. Spy(间谍对象)
Spy是部分Mock:它包装一个真实对象,真实方法默认会被调用,但你可以选择替换某些方法的行为,同时还能验证交互。
from unittest.mock import patch, MagicMock, call
class RealEmailService:
def send_welcome_email(self, address):
# 真实实现:连接SMTP服务器发送邮件
smtp = smtplib.SMTP("smtp.example.com")
smtp.sendmail(f"welcome@{FROM_DOMAIN}", address, WELCOME_EMAIL_CONTENT)
smtp.quit()
def send_notification(self, address, message):
# 另一个真实方法
smtp = smtplib.SMTP("smtp.example.com")
smtp.sendmail(f"notify@{FROM_DOMAIN}", address, message)
smtp.quit()
def test_register_sends_email():
service = RealEmailService()
# Spy:包装真实对象,但记录调用
with patch.object(service, 'send_welcome_email', wraps=service.send_welcome_email) as spy:
# 让真实方法抛出异常(模拟发送失败)
spy.side_effect = Exception("SMTP connection failed")
result = register_user("alice@example.com", email_service=service)
# 验证方法被调用了
spy.assert_called_once_with("alice@example.com")
# 验证业务逻辑正确处理了邮件发送失败
assert result.status == "registered_but_email_failed"
Spy的危险之处:
- 行为不一致:Spy让真实方法和Mock行为混在一起,测试的可预测性降低
- 部分Mock的维护成本:当真实方法签名变化时,Spy的测试会挂,但你可能不知道为什么
- 误导性的测试:看起来像在测真实行为,但实际上部分行为被替换了
什么时候用Spy:
- 当你需要测试遗留代码,且无法轻易重构时(临时方案)
- 当真实对象的创建成本很高,但你只想替换部分行为时
- 如果能用Mock或Stub,就不要用Spy
Mock策略的核心决策:测什么?
有了五种Test Double,接下来的问题是:什么时候该Mock?Mock什么?
这个问题没有标准答案,但有一个核心原则:你的测试在验证什么?
决策框架:测试的三种验证目标
目标一:验证状态(State Verification)
测试关注点:方法的返回值、对象的状态变化。
应该用:Stub(或真实依赖)
例子:
@Test
public void testCalculateLevel_vip() {
// 给定:用户的订单总金额
OrderRepository stubRepo = Mockito.mock(OrderRepository.class);
Mockito.when(stubRepo.findByUserId("user1"))
.thenReturn(Arrays.asList(
new Order(4000),
new Order(3500),
new Order(3000)
));
MembershipService service = new MembershipService(stubRepo);
// 当:计算会员等级
MembershipLevel level = service.calculateLevel("user1");
// 则:验证返回值
assertEquals(MembershipLevel.VIP, level); // 状态验证
}
这个测试中,我们关心的是"给定这些订单,计算结果是否正确"。orderRepository只是数据源,我们用Stub提供数据,不验证它是否被调用。
目标二:验证行为(Behavior Verification)
测试关注点:某个外部依赖是否被正确调用、是否被调用了正确的次数、参数是否正确。
应该用:Mock
例子:
@Test
public void testCancelOrder_publishesEvent() {
OrderRepository mockRepo = Mockito.mock(OrderRepository.class);
EventPublisher mockPublisher = Mockito.mock(EventPublisher.class);
OrderService service = new OrderService(mockRepo, mockPublisher);
// 当:取消订单
service.cancelOrder("order123", "User requested");
// 则:验证事件被发布(行为验证)
Mockito.verify(mockPublisher).publish(Mockito.argThat(event ->
event instanceof OrderCancelledEvent &&
event.getOrderId().equals("order123") &&
event.getReason().equals("User requested")
));
}
这个测试中,我们关心的是"cancelOrder是否发布了正确的事件"。这是行为验证。
目标三:验证集成(Integration Verification)
测试关注点:多个组件是否能正确协同工作。
应该用:真实依赖或Testcontainers(不应该用Mock)
例子:
@Test
public void testUserRegistration_integration() {
// 用真实的数据库(Testcontainers)
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
postgres.start();
UserRepository realRepo = new DatabaseUserRepository(postgres.getJdbcUrl());
EmailService mockEmail = Mockito.mock(EmailService.class); // 第三方仍然Mock
UserService service = new UserService(realRepo, mockEmail);
// 当:注册用户
User user = service.register("alice@example.com", "password123");
// 则:验证数据库状态
User savedUser = realRepo.findById(user.getId());
assertNotNull(savedUser);
assertEquals("alice@example.com", savedUser.getEmail());
// 验证邮件被发送
Mockito.verify(mockEmail).sendWelcomeEmail("alice@example.com");
}
决策树:该用哪种Test Double?
测试的目标是什么?
├── 验证返回值 / 对象状态
│ ├── 依赖的行为简单 → 用真实实现或Fake
│ └── 依赖的行为复杂 / 有外部依赖 → 用Stub
│
├── 验证副作用(邮件、事件、消息)
│ ├── 副作用是第三方服务 → 用Mock
│ └── 副作用是内部组件 → 用Mock或Fake
│
└── 验证集成
├── 用真实依赖(Testcontainers、真实数据库)
└── 第三方服务仍然Mock(或用sandbox环境)
Mock策略的粒度:该Mock到哪一层?
一个常见的争议是:应该Mock所有外部依赖,还是只Mock一部分?
策略对比
策略一:Mock所有外部依赖(纯单元测试)
理念:单元测试就应该只测试一个类,所有其他类都用Mock替代。
@Test
public void testProcessPayment_pureUnitTest() {
// 所有依赖都Mock
UserRepository mockUserRepo = Mockito.mock(UserRepository.class);
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);
NotificationService mockNotifier = Mockito.mock(NotificationService.class);
AuditLogger mockAuditLogger = Mockito.mock(AuditLogger.class);
CacheService mockCache = Mockito.mock(CacheService.class);
MetricsCollector mockMetrics = Mockito.mock(MetricsCollector.class);
// 设置Stub
Mockito.when(mockUserRepo.findById("user1"))
.thenReturn(new User("user1", "alice@example.com"));
Mockito.when(mockGateway.charge(Mockito.any(ChargeRequest.class)))
.thenReturn(new ChargeResult("txn_123", "success"));
PaymentService service = new PaymentService(
mockUserRepo, mockGateway, mockNotifier,
mockAuditLogger, mockCache, mockMetrics
);
PaymentResult result = service.processPayment("user1", 1000, "USD");
assertTrue(result.isSuccess());
// 验证所有交互
Mockito.verify(mockGateway).charge(Mockito.any(ChargeRequest.class));
Mockito.verify(mockNotifier).sendPaymentSuccessNotification("alice@example.com");
Mockito.verify(mockAuditLogger).log(Mockito.eq("payment_success"), Mockito.any());
Mockito.verify(mockCache).invalidate(Mockito.eq("user1"));
Mockito.verify(mockMetrics).increment(Mockito.eq("payment.success"));
}
优点:
- 测试完全隔离,速度极快(毫秒级)
- 不依赖外部服务,稳定性高
- 能精确控制每个依赖的行为
- 适合TDD(先写测试,再写实现)
缺点:
- 测试代码冗长,setup成本高
- Mock太多,测试变成"验证Mock是否按预期被调用",而不是"验证业务逻辑是否正确"
- 当真实实现变化时,Mock的测试可能仍然通过,给你虚假的安全感
- 重构时,大量测试需要跟着改
适用场景:
- 复杂的业务逻辑(有很多if-else、有很多边界条件)
- 遗留代码(难以写集成测试)
- 对测试速度要求极高的场景(如你在做严格的TDD)
策略二:只Mock不可控的外部依赖(集成单元测试)
理念:只Mock那些不可控的依赖(第三方API、外部服务),而对于项目内部的依赖,用真实实现或Fake。
@Test
public void testProcessPayment_integrationUnitTest() {
// 只Mock不可控的外部依赖
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);
Mockito.when(mockGateway.charge(Mockito.any()))
.thenReturn(new ChargeResult("txn_123", "success"));
// 内部依赖用真实实现
UserRepository realUserRepo = new InMemoryUserRepository();
NotificationService realNotifier = new FakeNotificationService();
AuditLogger realAuditLogger = new FakeAuditLogger();
CacheService realCache = new InMemoryCache();
MetricsCollector realMetrics = new FakeMetricsCollector();
// 准备测试数据
realUserRepo.save(new User("user1", "alice@example.com"));
PaymentService service = new PaymentService(
realUserRepo, mockGateway, realNotifier,
realAuditLogger, realCache, realMetrics
);
PaymentResult result = service.processPayment("user1", 1000, "USD");
assertTrue(result.isSuccess());
// 还能验证副作用:通知是否被发送、缓存是否被更新
assertTrue(realNotifier.hasSentNotification("alice@example.com"));
assertEquals("alice@example.com", realCache.get("user1:last_payment"));
assertEquals(1, realMetrics.getCount("payment.success"));
}
优点:
- 测试更接近真实行为,可信度更高
- 测试代码更简洁(不需要Mock所有东西)
- 内部实现变化时,测试能捕获到
- 重构时,测试不需要大改
缺点:
- 需要准备Fake实现(如
FakeNotificationService、InMemoryUserRepository) - 测试速度比纯单元测试慢(但仍然比集成测试快很多)
- Fake实现可能和真实实现行为不一致
适用场景:
- 大多数业务应用的测试(推荐默认用这个策略)
- 当团队有足够时间维护Fake实现时
- 当项目内部的模块边界清晰、易于用Fake替代时
策略三:用真实的集成测试(端到端测试)
理念:对于关键业务逻辑,最可信的测试是集成测试或端到端测试,用真实的数据库、真实的服务(或Testcontainers)。
@Test
public void testProcessPayment_integrationTest() {
// 用Testcontainers启动真实数据库
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
postgres.start();
// 真实Repository
UserRepository realRepo = new DatabaseUserRepository(postgres.getJdbcUrl());
// 第三方API仍然Mock(或用sandbox环境)
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);
Mockito.when(mockGateway.charge(any()))
.thenReturn(new ChargeResult("txn_123", "success"));
// 真实的其他依赖
NotificationService realNotifier = new EmailNotificationService(/* 用sandbox SMTP */);
AuditLogger realAuditLogger = new DatabaseAuditLogger(postgres.getJdbcUrl());
PaymentService service = new PaymentService(
realRepo, mockGateway, realNotifier,
realAuditLogger, new RedisCache(/* 用Testcontainers启动Redis */),
new PrometheusMetrics()
);
// 完整测试
User user = new User("user1", "alice@example.com");
realRepo.save(user);
PaymentResult result = service.processPayment("user1", 1000, "USD");
assertTrue(result.isSuccess());
// 还能验证数据库状态
User updatedUser = realRepo.findById("user1");
assertEquals(1000, updatedUser.getBalance());
// 验证审计日志
List<AuditLog> logs = realAuditLogger.findByUserId("user1");
assertTrue(logs.stream().anyMatch(log -> "payment_success".equals(log.getEventType())));
}
优点:
- 最可信,能捕获到真实环境下的bug
- 不需要写Fake实现
- 能发现集成问题(如SQL语法错误、序列化问题)
缺点:
- 速度慢(每个测试可能需要几秒)
- 环境要求高(需要Docker、需要第三方服务的sandbox环境)
- 不稳定(网络问题、端口冲突、环境脏数据)
- 调试困难(失败时难以定位是哪里出问题)
适用场景:
- 关键业务流程(如支付、注册)
- 集成点(如数据库迁移、消息队列配置)
- 作为单元测试的补充(不是替代)
推荐策略:分层测试 + 合理Mock
现实项目中,应该组合使用以上策略,根据测试的目标选择合适的方法:
| 测试类型 | Mock策略 | 速度 | 可信度 | 维护成本 |
|---|---|---|---|---|
| 单元测试 | 策略二(只Mock不可控的外部依赖) | 快 | 中 | 中 |
| 集成测试 | 策略三(真实数据库 + Mock第三方) | 中 | 高 | 高 |
| 端到端测试 | 真实环境 | 慢 | 最高 | 最高 |
经验法则:
- Mock的边界应该是"进程边界":进程内的依赖(其他类、其他模块)用真实实现或Fake,进程外的依赖(数据库、第三方API、消息队列)用Mock或Testcontainers
- 测试金字塔:单元测试应该最多(占80%),集成测试次之(占15%),端到端测试最少(占5%)
- 关键路径全覆盖:对于核心业务流程(如支付),应该在所有三层都有测试
不同语言的Mock实践
不同编程语言的Mock生态不同,实践方式也有差异。
Java:Mockito的最佳实践
Mockito是Java最流行的Mock框架,以下是一些最佳实践:
1. 用@Mock和@InjectMocks简化测试
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
OrderRepository orderRepository;
@Mock
PaymentGateway paymentGateway;
@Mock
NotificationService notificationService;
@InjectMocks
OrderService orderService; // Mockito自动注入上面三个Mock
@Test
void testProcessOrder() {
// 只需要设置必要的Stub
when(orderRepository.findById("order1"))
.thenReturn(Optional.of(new Order("order1", 1000)));
when(paymentGateway.charge(any()))
.thenReturn(new ChargeResult("success"));
// 测试
orderService.process("order1");
// 验证
verify(notificationService).sendOrderConfirmation("order1");
}
}
2. 用ArgumentCaptor捕获参数
当需要验证Mock方法被调用时的参数细节时,用ArgumentCaptor:
@Test
void testProcessOrder_capturesEmailContent() {
ArgumentCaptor<Email> emailCaptor = ArgumentCaptor.forClass(Email.class);
orderService.process("order1");
verify(notificationService).sendEmail(emailCaptor.capture());
Email sentEmail = emailCaptor.getValue();
assertEquals("alice@example.com", sentEmail.getTo());
assertTrue(sentEmail.getBody().contains("Your order #order1"));
}
3. 用@Spy谨慎地使用部分Mock
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Spy
@InjectMocks
UserService userService;
@Mock
UserRepository userRepository;
@Test
void testRegister_withSpy() {
// 让真实方法被调用,但替换其中一个方法
doReturn("hashed-password").when(userService).hashPassword(anyString());
User user = userService.register("alice@example.com", "password123");
assertEquals("hashed-password", user.getPasswordHash());
verify(userRepository).save(any(User.class));
}
}
Python:unittest.mock的精髓
Python的unittest.mock模块功能强大,以下是一些关键用法:
1. patch的使用方式
from unittest.mock import patch, MagicMock, PropertyMock
# 方式1:作为装饰器
@patch('myapp.services.UserRepository')
@patch('myapp.services.EmailService')
def test_register(self, MockEmailService, MockUserRepository):
mock_repo = MockUserRepository.return_value
mock_email = MockEmailService.return_value
service = UserService()
result = service.register("alice@example.com", "password123")
mock_repo.save.assert_called_once()
mock_email.send_welcome_email.assert_called_once_with("alice@example.com")
# 方式2:作为上下文管理器
def test_register(self):
with patch('myapp.services.UserRepository') as MockRepo, \
patch('myapp.services.EmailService') as MockEmail:
mock_repo = MockRepo.return_value
# ...
2. 模拟属性和异步方法
# 模拟属性
with patch('myapp.models.User.is_vip', new_callable=PropertyMock) as mock_is_vip:
mock_is_vip.return_value = True
user = User()
assertTrue(user.is_vip)
# 模拟异步方法
@pytest.mark.asyncio
async def test_async_service():
with patch('myapp.services.fetch_data') as mock_fetch:
mock_fetch.return_value = {"data": "mocked"}
result = await my_async_function()
assert result == {"data": "mocked"}
3. side_effect的强大用法
# side_effect可以是一个函数
mock_repo.save.side_effect = lambda user: user if user.email else ValueError("Email required")
# side_effect可以是一个异常
mock_repo.save.side_effect = DatabaseException("Connection failed")
# side_effect可以是一个迭代器(模拟多次调用返回不同值)
mock_repo.find_by_id.side_effect = [User("1"), User("2"), None]
JavaScript/TypeScript:Jest的Mock
Jest是前端测试的主流框架,内置了Mock功能。
1. 自动Mock
// __mocks__/userRepository.js
export default {
findById: jest.fn(),
save: jest.fn()
};
// 测试文件
import UserRepository from '../userRepository';
jest.mock('../userRepository'); // 自动使用__mocks__下的Mock
test('test register', () => {
UserRepository.findById.mockReturnValue({ id: '1', email: 'alice@example.com' });
// 测试...
expect(UserRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ email: 'alice@example.com' })
);
});
2. Mock模块的部分方法
// 只Mock特定方法,其他方法用真实实现
import * as utils from '../utils';
jest.spyOn(utils, 'generateId').mockReturnValue('fixed-id');
// utils.generateId() 现在返回 'fixed-id'
// 但 utils其他方法仍然是真实实现
3. Mock定时器
// 测试定时任务
jest.useFakeTimers();
test('scheduled task', () => {
const callback = jest.fn();
setInterval(callback, 1000);
// 快进时间
jest.advanceTimersByTime(3000);
expect(callback).toHaveBeenCalledTimes(3);
});
常见陷阱和反模式(深度分析)
即使理解了Mock的基本概念和策略,实际使用中仍然容易掉进陷阱。以下是最常见的几个,每个都配有真实案例和解决方案。
陷阱一:过度指定(Over-specification)
问题:在测试中验证了太多不必要的细节,导致测试脆弱(brittle)——实现代码稍微一改,测试就挂了。
真实案例:
// 脆弱的测试:过度验证参数
@Test
public void testProcessOrder_overSpecified() {
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);
OrderService service = new OrderService(mockGateway);
service.processOrder(order);
// 过度指定:验证了timestamp精确到毫秒
Mockito.verify(mockGateway).charge(Mockito.argThat(req ->
req.getAmount() == 1000 &&
req.getCurrency().equals("USD") &&
req.getTimestamp().equals(Instant.now()) // 脆弱!时间差1ms就挂
));
}
为什么这是个问题:
req.getTimestamp().equals(Instant.now())这个验证太精确了- 如果实现代码中
timestamp是在方法开头获取的,而测试中的Instant.now()是在方法调用后获取的,中间哪怕差1毫秒,测试也会失败 - 但这个细节对业务逻辑不重要——我们关心的是"是否发送了支付请求",而不是"时间戳是否精确到毫秒"
改进方案:
@Test
public void testProcessOrder_better() {
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);
OrderService service = new OrderService(mockGateway);
service.processOrder(order);
// 只验证关键属性
Mockito.verify(mockGateway).charge(Mockito.argThat(req ->
req.getAmount() == 1000 &&
req.getCurrency().equals("USD")
// 不验证timestamp,只要它在合理范围内就行
));
}
更进一步的改进:如果一定要验证timestamp,用"范围"而不是"精确值":
Mockito.verify(mockGateway).charge(Mockito.argThat(req -> {
Instant timestamp = req.getTimestamp();
Instant now = Instant.now();
return Duration.between(timestamp, now).abs().toMillis() < 1000; // 1秒内都算对
}));
经验教训:
- 只验证对业务逻辑重要的东西
- 对于生成的值(如timestamp、UUID),要么不验证,要么验证"格式"而不是"精确值"
- 如果发现自己在测试中写了很复杂的
argThat逻辑,停下来想想:我到底在验证什么?
陷阱二:Mock Hell(Mock地狱)
问题:测试代码里,每个测试都要Mock五六七八个依赖,测试代码比生产代码还长,且难以理解。
真实案例:
// Mock Hell:测试代码中80%是Mock setup
@Test
public void testSomething_mockHell() {
Dependency1 mock1 = Mockito.mock(Dependency1.class);
Dependency2 mock2 = Mockito.mock(Dependency2.class);
Dependency3 mock3 = Mockito.mock(Dependency3.class);
Dependency4 mock4 = Mockito.mock(Dependency4.class);
Dependency5 mock5 = Mockito.mock(Dependency5.class);
Dependency6 mock6 = Mockito.mock(Dependency6.class);
Dependency7 mock7 = Mockito.mock(Dependency7.class);
Mockito.when(mock1.doA()).thenReturn(...);
Mockito.when(mock2.doB()).thenReturn(...);
Mockito.when(mock3.doC()).thenReturn(...);
Mockito.when(mock4.doD()).thenReturn(...);
Mockito.when(mock5.doE()).thenReturn(...);
// ... 20行Mock setup
Service service = new Service(mock1, mock2, mock3, mock4, mock5, mock6, mock7);
service.doSomething();
Mockito.verify(mock1).doA();
Mockito.verify(mock2).doB();
Mockito.verify(mock3).doC();
// ... 10行verify
}
为什么这是个问题:
- 可读性差:测试的"Given-When-Then"结构被大量的Mock setup淹没
- 维护成本高:如果
Service的构造函数加了一个新的依赖,所有测试都要改 - 信号问题:你不知道哪些Mock是重要的、哪些是不重要的
原因分析:
Mock Hell通常是以下问题的信号:
- 被测类的职责太多(违反了Single Responsibility Principle)
- 依赖注入做得太细(每个小功能都抽成一个接口)
- 测试在验证实现细节,而不是验证业务逻辑
改进方案1:重构生产代码
如果Service有7个依赖,它可能做了太多事情。考虑拆分:
// 重构前:一个类做所有事情
class OrderService {
void processOrder(Order order) {
// 验证订单
// 计算价格
// 扣库存
// 发送通知
// 记录日志
// ...
}
}
// 重构后:拆成多个类
class OrderProcessor {
void process(Order order) {
validator.validate(order);
pricingService.calculate(order);
inventoryService.deduct(order);
notifier.notify(order);
auditLogger.log(order);
}
}
改进方案2:用@Mock和@InjectMocks
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
OrderValidator validator;
@Mock
PricingService pricingService;
@Mock
InventoryService inventoryService;
@Mock
NotificationService notifier;
@Mock
AuditLogger auditLogger;
@InjectMocks
OrderService orderService; // Mockito自动注入上面所有Mock
@Test
void testProcessOrder() {
// 只需要设置必要的Stub
when(pricingService.calculate(any()))
.thenReturn(new Price(1000, "USD"));
orderService.process(order);
// 验证关键的副作用
verify(notifier).sendConfirmation(any());
verify(auditLogger).log(eq("order_processed"), any());
}
}
改进方案3:考虑用Fake替代部分Mock
如果某些依赖的行为复杂,用Fake可能比用Mock更简单:
// 用Fake替代多个Mock
class FakeInventoryService implements InventoryService {
private Map<String, Integer> stock = new HashMap<>();
public void setStock(String productId, int quantity) {
stock.put(productId, quantity);
}
@Override
public void deduct(Order order) {
for (OrderItem item : order.getItems()) {
String productId = item.getProductId();
int newStock = stock.getOrDefault(productId, 0) - item.getQuantity();
if (newStock < 0) {
throw new InsufficientStockException(productId);
}
stock.put(productId, newStock);
}
}
}
// 测试更简洁
@Test
void testProcessOrder_withFake() {
FakeInventoryService fakeInventory = new FakeInventoryService();
fakeInventory.setStock("product1", 100);
OrderService service = new OrderService(
new RealOrderValidator(),
new FakePricingService(),
fakeInventory, // Fake
new FakeNotificationService(),
new FakeAuditLogger()
);
service.process(order);
// 验证副作用:库存被扣了
assertEquals(90, fakeInventory.getStock("product1"));
}
陷阱三:测试Mock而不是测试逻辑
问题:测试写完了,覆盖率100%,但测试的其实是"Mock框架是否正常工作",而不是"业务逻辑是否正确"。
真实案例:
// 坏例子:测试的是Mock,不是逻辑
@Test
public void testBadExample() {
UserService mockService = Mockito.mock(UserService.class);
// 设置Mock返回固定的结果
Mockito.when(mockService.register(anyString(), anyString()))
.thenReturn(new User("mock-user"));
User result = mockService.register("alice@example.com", "password");
// 这个断言永远通过,因为你测试的是Mock的返回值
assertEquals("mock-user", result.getId());
}
为什么这是个问题:
- 这个测试没有任何意义
mockService.register()的返回值就是你设置的返回值- 这个测试只是验证了"Mockito的
when-thenReturn是否正常工作",而没有验证任何业务逻辑 - 即使
UserService.register()的实现有bug,这个测试也会通过(因为调用的是Mock,不是真实实现)
如何识别这个问题:
- 测试中创建的是
Mockito.mock(XxxService.class),而不是真实的new XxxService(...) - 测试中没有"Given"(准备测试数据)的部分,直接就是"When"(调用方法)
- 测试的断言只是验证了Mock的返回值,而没有验证任何状态变化或副作用
改进方案:
// 好例子:测试真实逻辑
@Test
public void testGoodExample() {
// Given:创建真实对象,注入Mock依赖
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmail = Mockito.mock(EmailService.class);
UserService realService = new UserService(mockRepo, mockEmail);
// Given:设置Stub
User savedUser = new User("alice@example.com");
when(mockRepo.save(any(User.class))).thenReturn(savedUser);
// When:调用真实方法
User result = realService.register("alice@example.com", "password");
// Then:验证真实逻辑
// 1. 验证返回值
assertEquals("alice@example.com", result.getEmail());
// 2. 验证副作用:用户是否被保存
verify(mockRepo).save(any(User.class));
// 3. 验证副作用:邮件是否被发送
verify(mockEmail).sendWelcomeEmail("alice@example.com");
}
核心原则:
- 测试真实对象,只Mock它的依赖
- 如果你发现自己在测试Mock对象,停下来,你可能搞错了
陷阱四:忽略Mock的副作用
问题:有些方法的副作用不是返回值,而是修改了外部状态(如发送邮件、写入文件、发布消息)。如果只用Stub而不用Mock,可能会漏掉这些副作用的验证。
真实案例:
// 可能漏掉副作用的测试
@Test
public void testRegister_incomplete() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmail = Mockito.mock(EmailService.class);
UserService service = new UserService(mockRepo, mockEmail);
User user = service.register("alice@example.com", "password");
// 只验证了返回值
assertNotNull(user);
// 但没验证邮件是否被发送!
// 如果register()实现忘了发邮件,这个测试发现不了
}
为什么这是个问题:
register()方法的职责可能包括"发送欢迎邮件"- 但如果测试只验证了返回值,没有验证邮件是否被发送
- 那么即使
register()实现中漏掉了emailService.sendWelcomeEmail()调用,测试也会通过 - 这是一个"假阳性"——测试通过,但实现有bug
改进方案:
@Test
public void testRegister_complete() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmail = Mockito.mock(EmailService.class);
UserService service = new UserService(mockRepo, mockEmail);
User user = service.register("alice@example.com", "password");
// 验证返回值
assertNotNull(user);
// 验证副作用:邮件是否被发送
verify(mockEmail).sendWelcomeEmail("alice@example.com");
// 更严格的验证:参数是否正确
verify(mockEmail).sendWelcomeEmail(eq("alice@example.com"));
}
更进一步的改进:验证邮件内容
@Test
public void testRegister_emailContent() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmail = Mockito.mock(EmailService.class);
// 用ArgumentCaptor捕获邮件参数
ArgumentCaptor<Email> emailCaptor = ArgumentCaptor.forClass(Email.class);
UserService service = new UserService(mockRepo, mockEmail);
User user = service.register("alice@example.com", "password");
// 验证邮件被发送,且内容正确
verify(mockEmail).sendWelcomeEmail(emailCaptor.capture());
Email sentEmail = emailCaptor.getValue();
assertEquals("alice@example.com", sentEmail.getTo());
assertTrue(sentEmail.getSubject().contains("Welcome"));
assertTrue(sentEmail.getBody().contains(user.getId())); // 邮件里应该包含用户ID
}
陷阱五:静态方法、私有方法的Mock
问题:有些团队为了测试而Mock静态方法或私有方法,这通常是设计问题的信号。
真实案例:
// 坏例子:为了测试而Mock静态方法
@Test
public void testBadStaticMock() {
try (MockedStatic<UtilityClass> mocked = Mockito.mockStatic(UtilityClass.class)) {
mocked.when(() -> UtilityClass.someStaticMethod(anyString()))
.thenReturn("mocked result");
// 测试代码
Service service = new Service();
String result = service.doSomething();
assertEquals("mocked result", result);
}
}
为什么这是个问题:
- Mock静态方法需要特殊的Mockito配置(
mockStatic),且会影响其他测试(因为静态方法是全局的) - 静态方法通常有副作用或依赖全局状态,Mock起来很复杂
- 更根本的问题是:如果静态方法难以测试,说明它不应该用静态方法实现,应该注入一个实例
改进方案:依赖注入
// 改进:注入实例,而不是调用静态方法
interface TimeProvider {
Instant now();
}
class SystemTimeProvider implements TimeProvider {
public Instant now() {
return Instant.now();
}
}
class UserService {
private final TimeProvider timeProvider;
public UserService(..., TimeProvider timeProvider) {
this.timeProvider = timeProvider;
}
public User register(String email, String password) {
User user = new User(email, password);
user.setCreatedAt(timeProvider.now()); // 不再调用静态方法
return user;
}
}
// 测试
@Test
public void testRegister_withFixedTime() {
TimeProvider mockTime = Mockito.mock(TimeProvider.class);
when(mockTime.now()).thenReturn(Instant.parse("2026-01-01T00:00:00Z"));
UserService service = new UserService(..., mockTime);
User user = service.register("alice@example.com", "password");
assertEquals(Instant.parse("2026-01-01T00:00:00Z"), user.getCreatedAt());
}
什么时候可以Mock静态方法:
- 遗留代码,无法轻易重构
- 第三方库的静态方法,且你无法修改
- 用
MockedStatic时要小心,确保不会影响其他测试(用try-with-resources)
陷阱六:Mock导致的测试耦合
问题:测试的Mock setup和生产代码的实现细节耦合太紧,导致重构生产代码时,大量测试挂掉。
真实案例:
更多陷阱
除了上述六大陷阱,以下也是实践中常见的问题:
陷阱七:Mock没有验证参数,导致假阳性
// 坏例子:没有验证参数
@Test
public void testBad_noParameterVerification() {
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);
OrderService service = new OrderService(mockGateway);
service.processOrder(order);
// 只验证了方法被调用,但没验证参数
verify(mockGateway).charge(any()); // 太宽松了!
}
问题:any()匹配任何参数,即使传入了错误的金额、错误的货币,测试也会通过。
改进:
// 好例子:精确验证参数
verify(mockGateway).charge(argThat(req ->
req.getAmount() == 1000 &&
"USD".equals(req.getCurrency()) &&
"order123".equals(req.getOrderId())
));
陷阱八:Mock的默认返回值导致测试漏掉NPE
Mockito等框架会为未Stub的方法返回默认值(如null、空集合、0)。这可能导致测试漏掉空指针异常。
// 坏例子:Mock的默认返回值导致问题
@Test
public void testBad_defaultReturnValue() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
// 没有设置findById的Stub,会返回null
UserService service = new UserService(mockRepo);
User user = service.getUser("user1");
// 如果getUser()实现中没有检查null,这里会是null
// 但测试没有捕获到这个问题
assertNotNull(user); // 这个断言会失败,但太晚了
}
改进方案1:用Mockito.RETURNS_DEEP_STUBS要小心
// 不推荐:RETURNS_DEEP_STUBS可能导致隐藏的bug
UserRepository mockRepo = Mockito.mock(UserRepository.class, Mockito.RETURNS_DEEP_STUBS);
// 现在mockRepo.anyMethod().anyMethod()都不会返回null,但可能隐藏bug
改进方案2:明确设置Stub,包括异常场景
@Test
public void testGood_explicitStub() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
// 明确设置:用户存在和不存在两种场景
when(mockRepo.findById("existing-user"))
.thenReturn(new User("existing-user"));
when(mockRepo.findById("nonexistent-user"))
.thenReturn(null); // 明确返回null
UserService service = new UserService(mockRepo);
// 测试用户存在
User user = service.getUser("existing-user");
assertNotNull(user);
// 测试用户不存在
assertThrows(UserNotFoundException.class, () -> {
service.getUser("nonexistent-user");
});
}
陷阱九:在循环中Mock,导致测试不可读
// 坏例子:在循环中设置Mock
@Test
public void testBad_loopMock() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
// 为100个用户设置Stub
for (int i = 0; i < 100; i++) {
User user = new User("user" + i);
when(mockRepo.findById("user" + i)).thenReturn(user);
}
// 测试...
}
问题:
- 测试setup非常冗长
- 如果只需要测试某一个用户,为什么要设置100个?
- 难以理解测试的意图
改进方案:用@ParameterizedTest或只设置需要的Mock
// 好例子:参数化测试
@ParameterizedTest
@ValueSource(strings = {"user1", "user2", "user3"})
void testGetUser_multipleUsers(String userId) {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
when(mockRepo.findById(userId)).thenReturn(new User(userId));
UserService service = new UserService(mockRepo);
User user = service.getUser(userId);
assertEquals(userId, user.getId());
}
陷阱十:Mock静态方法导致的测试隔离问题
Mockito的mockStatic会影响所有测试,因为静态方法是全局的。
// 坏例子:mockStatic影响其他测试
@Test
public void test1() {
try (MockedStatic<UtilityClass> mocked = Mockito.mockStatic(UtilityClass.class)) {
mocked.when(() -> UtilityClass.doSomething()).thenReturn("mocked");
// 测试1
}
}
@Test
public void test2() {
// 这个测试可能没有Mock UtilityClass.doSomething()
// 但如果test1先执行,且mockStatic的scope没控制好,test2可能也会受到影响
String result = UtilityClass.doSomething(); // 可能返回"mocked",也可能返回真实值
}
改进方案:
- 避免Mock静态方法(用依赖注入替代)
- 如果一定要Mock,确保用
try-with-resources且每个测试都正确设置 - 用
@BeforeEach和@AfterEach清理静态Mock的状态
// 好例子:正确管理静态Mock
class MyTest {
private MockedStatic<UtilityClass> mockedStatic;
@BeforeEach
void setUp() {
mockedStatic = Mockito.mockStatic(UtilityClass.class);
}
@AfterEach
void tearDown() {
mockedStatic.close(); // 每个测试后都关闭
}
@Test
void test1() {
mockedStatic.when(() -> UtilityClass.doSomething()).thenReturn("mocked1");
// 测试1
}
@Test
void test2() {
mockedStatic.when(() -> UtilityClass.doSomething()).thenReturn("mocked2");
// 测试2(独立的)
}
}
// 生产代码(旧版本)
class OrderService {
public void process(Order order) {
validator.validate(order); // 步骤1
pricingService.calculate(order); // 步骤2
inventoryService.deduct(order); // 步骤3
notifier.notify(order); // 步骤4
}
}
// 测试(旧版本)
@Test
public void testProcess() {
OrderService service = new OrderService(validator, pricingService, ...);
service.process(order);
// 验证了所有步骤都被调用
verify(validator).validate(order);
verify(pricingService).calculate(order);
verify(inventoryService).deduct(order);
verify(notifier).notify(order);
}
现在,重构生产代码:
// 生产代码(新版本):把步骤2-4放到一个新方法里
class OrderService {
public void process(Order order) {
validator.validate(order);
processInternal(order); // 重构:合并到一个方法
}
private void processInternal(Order order) {
pricingService.calculate(order);
inventoryService.deduct(order);
notifier.notify(order);
}
}
结果:测试挂了!因为测试验证了pricingService.calculate()等方法的调用,但重构后这些方法是在processInternal()里调用的,而processInternal()是私有的,Mock无法验证。
为什么这是个问题:
- 测试验证了实现细节("是否调用了某方法"),而不是验证行为("是否产生了预期的副作用")
- 重构实现(改变内部调用结构)不应该导致测试挂掉
改进方案:验证行为,而不是验证实现
@Test
public void testProcess() {
OrderService service = new OrderService(validator, pricingService, ...);
service.process(order);
// 不再验证"是否调用了某方法"
// 而是验证"是否产生了预期的副作用"
// 副作用1:通知被发送
verify(notifier).notify(order);
// 副作用2:库存被扣
verify(inventoryService).deduct(order);
// 副作用3:订单状态被正确更新
assertEquals("PROCESSED", order.getStatus());
}
核心原则:
- 测试应该验证"做了什么"(What),而不是"怎么做的"(How)
- 如果重构实现(改变内部流程)导致测试挂掉,说明测试太耦合了
实践建议:如何制定团队的Mock策略
理解理论和陷阱之后,如何在真实项目中落地?以下是经过多个项目验证的实践建议。
建议一:为项目制定明确的Mock规范
在项目的测试规范文档中,明确写出:
- 哪些层应该Mock,哪些层不应该Mock
- 什么时候用Stub,什么时候用Mock,什么时候用Fake
- 如何命名测试、如何组织测试代码
示例规范:
# 项目测试规范
## Mock规范
### 应该Mock的东西
- 第三方API(支付网关、短信服务、邮件服务)
- 外部系统(ERP、CRM、消息队列)
- 时间(用`Clock`或`TimeProvider`注入,不要直接用`Instant.now()`)
- 文件系统(用`@TempDir`或Fake)
### 不应该Mock的东西
- 同一项目内的其他类(用真实实现或Fake)
- 值对象(Value Object)
- 工具类(如果逻辑简单,直接用真实实现)
- 数据访问层(用Testcontainers或真实数据库)
### Mock框架约定
- Java项目用Mockito,Python项目用unittest.mock
- 每个测试最多Mock 5个依赖,超过说明被测类职责太多
- 只用`verify`验证副作用,不用`verify`验证查询方法
- 不对私有方法、静态方法做Mock(说明设计有问题)
### 测试命名规范
- 单元测试:`test[方法名]_[场景]_[预期结果]`
例:`testRegister_newUser_returnsUserWithId`
- 集成测试:`[场景]_[预期结果]`
例:`userRegistered_eventPublishedToMessageQueue`
建议二:用@TempDir或Fake替代文件系统和网络
文件和网络的Mock特别容易出错,建议用Fake或真实但隔离的实现。
// 坏例子:Mock文件系统
@Test
public void testBadFileMock() {
FileSystem mockFs = Mockito.mock(FileSystem.class);
// ... 设置一堆Mock,脆弱且难维护
}
// 好例子:用临时目录
@Test
public void testGoodFileHandling(@TempDir Path tempDir) {
Path outputFile = tempDir.resolve("output.txt");
FileService service = new FileService();
service.writeToFile(outputFile, "hello world");
assertEquals("hello world", Files.readString(outputFile));
}
// 好例子:用Fake HTTP客户端
@Test
public void testGoodHttpCall() {
FakeHttpClient fakeClient = new FakeHttpClient();
fakeClient.when(GET, "https://api.example.com/users/1")
.thenReturn(200, "{\"id\": 1, \"name\": \"Alice\"}");
UserService service = new UserService(fakeClient);
User user = service.getUser(1);
assertEquals("Alice", user.getName());
}
建议三:用Builder或Factory简化测试数据创建
测试数据创建是Mock策略的重要部分。如果测试数据创建很复杂,测试代码会变得冗长。
// 坏例子:每个测试都要写一堆setup
@Test
public void test1() {
User user = new User();
user.setId("1");
user.setEmail("alice@example.com");
user.setPassword("hashed");
user.setStatus("active");
user.setCreatedAt(Instant.now());
user.setMembershipLevel("VIP");
user.setAddress(new Address("China", "Beijing", "..."));
// ... 20行
}
// 好例子:用Builder
@Test
public void test1() {
User user = UserBuilder.aUser()
.withEmail("alice@example.com")
.withStatus("active")
.withMembershipLevel("VIP")
.build();
}
// 或者更简洁:用测试专用Factory
@Test
public void test1() {
User user = aUser("alice@example.com", "active", "VIP"); // 默认其他字段都填好
}
Factory方法的实现:
// 在测试包中定义Factory
class TestDataFactory {
static User aUser(String email, String status, String level) {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setEmail(email);
user.setPassword(hashPassword("password123"));
user.setStatus(status);
user.setMembershipLevel(level);
user.setCreatedAt(Instant.now());
user.setAddress(new Address("China", "Beijing", "Chaoyang District"));
return user;
}
static User anActiveUser() {
return aUser("test@example.com", "active", "NORMAL");
}
static User aVipUser() {
return aUser("vip@example.com", "active", "VIP");
}
}
建议四:集成测试和单元测试分开
用不同的测试基类或测试目录,区分"需要Mock的测试"和"不需要Mock的测试"。
src/test/java/
├── unit/ # 单元测试:用Mock
│ ├── UserServiceTest.java
│ ├── PaymentServiceTest.java
│ └── OrderProcessingTest.java
├── integration/ # 集成测试:用真实数据库或Testcontainers
│ ├── UserRepositoryTest.java
│ ├── OrderIntegrationTest.java
│ └── PaymentGatewayIntegrationTest.java
└── e2e/ # 端到端测试:用真实环境
└── RegistrationFlowTest.java
CI流水线的配置:
# .github/workflows/test.yml
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- run: ./mvnw test -Dtest=unit.* # 只跑单元测试,快速反馈
integration-test:
runs-on: ubuntu-latest
steps:
- run: ./mvnw verify -Dtest=integration.* # PR合并前跑
e2e-test:
runs-on: ubuntu-latest
if: github.event_name == 'deployment' # 发布前跑
steps:
- run: ./mvnw test -Dtest=e2e.*
建议五:定期重构测试代码
测试代码不是一次性的,它和production代码一样需要维护。以下信号说明测试代码需要重构:
- 一个测试方法有超过50行代码 → Extract Method,把setup逻辑抽到@BeforeEach或Factory
- 一个测试类有超过10个Mock → 说明被测类职责太多,考虑拆分
- 测试的名称看不出在测什么(
test1,test2)→ 重命名,用test[方法]_[场景]_[预期]格式 - 测试之间有很多重复代码 → 用
@BeforeEach或Factory抽取 - 改了一行production代码,结果20个测试挂了 → 过度指定,减少不必要的verify
重构的例子:
// 重构前:重复的setup
@Test
public void test1() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmail = Mockito.mock(EmailService.class);
// ... 10行setup
UserService service = new UserService(mockRepo, mockEmail);
// 测试逻辑
}
@Test
public void test2() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmail = Mockito.mock(EmailService.class);
// ... 和test1一样的10行setup
UserService service = new UserService(mockRepo, mockEmail);
// 测试逻辑
}
// 重构后:用@BeforeEach
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
UserRepository userRepository;
@Mock
EmailService emailService;
@InjectMocks
UserService userService;
@BeforeEach
void setUp() {
// 公共的setup逻辑(如果需要)
}
@Test
void test1() { ... }
@Test
void test2() { ... }
}
一个完整的例子:用户注册服务的测试策略
最后,用一个完整的例子展示如何为真实业务场景设计Mock策略。
业务场景
用户注册服务需要:
- 检查邮箱是否已注册(调用
UserRepository) - 对密码进行哈希(调用
PasswordEncoder) - 保存用户(调用
UserRepository) - 发送欢迎邮件(调用
EmailService) - 发布用户注册事件(调用
EventPublisher) - 初始化用户配额(调用
QuotaService)
测试策略设计
@ExtendWith(MockitoExtension.class)
class UserRegistrationServiceTest {
@Mock
UserRepository userRepository;
@Mock
PasswordEncoder passwordEncoder;
@Mock
EmailService emailService;
@Mock
EventPublisher eventPublisher;
@Mock
QuotaService quotaService;
@InjectMocks
UserRegistrationService service;
@Test
@DisplayName("注册成功:保存用户、发送邮件、发布事件、初始化配额")
void register_success() {
// Given:设置Stub
when(userRepository.findByEmail("alice@example.com"))
.thenReturn(null); // 邮箱未注册
when(passwordEncoder.encode("password123"))
.thenReturn("hashed-password");
when(userRepository.save(any(User.class)))
.thenAnswer(inv -> inv.getArgument(0)); // 返回传入的对象
// When
User result = service.register("alice@example.com", "password123");
// Then:验证返回值
assertNotNull(result);
assertEquals("alice@example.com", result.getEmail());
assertEquals("hashed-password", result.getPasswordHash());
// Then:验证副作用(用Mock验证交互)
verify(userRepository).save(argThat(user ->
user.getEmail().equals("alice@example.com") &&
user.getPasswordHash().equals("hashed-password")
));
verify(emailService).sendWelcomeEmail("alice@example.com");
verify(eventPublisher).publish(argThat(event ->
event instanceof UserRegisteredEvent &&
((UserRegisteredEvent) event).getEmail().equals("alice@example.com")
));
verify(quotaService).initializeForUser(anyString());
}
@Test
@DisplayName("注册失败:邮箱已存在")
void register_emailAlreadyExists() {
// Given
when(userRepository.findByEmail("alice@example.com"))
.thenReturn(new User("alice@example.com", "old-hash"));
// When & Then
assertThrows(DuplicateEmailException.class, () -> {
service.register("alice@example.com", "password123");
});
// 验证:没有保存用户、没有发邮件、没有发布事件、没有初始化配额
verify(userRepository, never()).save(any());
verify(emailService, never()).sendWelcomeEmail(anyString());
verify(eventPublisher, never()).publish(any());
verify(quotaService, never()).initializeForUser(anyString());
}
@Test
@DisplayName("注册成功但邮件发送失败:仍然返回成功,但记录日志")
void register_emailFails() {
// Given
when(userRepository.findByEmail(anyString())).thenReturn(null);
when(passwordEncoder.encode(anyString())).thenReturn("hashed");
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
doThrow(new EmailException("SMTP error"))
.when(emailService).sendWelcomeEmail(anyString());
// When
User result = service.register("alice@example.com", "password123");
// Then:用户仍然注册成功
assertNotNull(result);
verify(userRepository).save(any());
// 邮件发送失败不应该影响注册,但应该被记录(可以用@Spy或日志框架验证)
}
@Test
@DisplayName("注册时密码哈希失败:注册失败,不保存用户")
void register_passwordHashingFails() {
// Given
when(userRepository.findByEmail(anyString())).thenReturn(null);
when(passwordEncoder.encode(anyString()))
.thenThrow(new EncodingException("Hash failed"));
// When & Then
assertThrows(RegistrationFailedException.class, () -> {
service.register("alice@example.com", "password123");
});
verify(userRepository, never()).save(any());
verify(emailService, never()).sendWelcomeEmail(anyString());
}
@Test
@DisplayName("注册时事件发布失败:注册成功,但事件被重试")
void register_eventPublishFails() {
// Given
when(userRepository.findByEmail(anyString())).thenReturn(null);
when(passwordEncoder.encode(anyString())).thenReturn("hashed");
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
doThrow(new EventPublishException("MQ error"))
.when(eventPublisher).publish(any());
// When
User result = service.register("alice@example.com", "password123");
// Then:用户注册成功,但事件发布失败应该被重试或记录
assertNotNull(result);
verify(eventPublisher, times(3)).publish(any()); // 验证了重试
}
}
这个例子的亮点
- 测试命名清晰:用
@DisplayName说明测试场景 - Given-When-Then结构:测试代码易读
- 合理的使用Mock:只Mock了外部依赖
- 验证了副作用:用
verify确保邮件和事件被正确调用 - 验证了异常场景:不仅测了"成功",还测了"失败"
- 验证了"什么没发生":用
verify(..., never())确保失败场景下没有副作用 - 测试了边界条件:邮件失败、密码哈希失败、事件发布失败
配套的集成测试
单元测试之外,还应该有集成测试:
@SpringBootTest
@Testcontainers
class UserRegistrationIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");
@Autowired
UserRepository userRepository;
@Autowired
UserRegistrationService registrationService;
@MockBean // Spring Boot的Mock Bean
EmailService emailService;
@Test
void register_success_integration() {
// When
User result = registrationService.register("alice@example.com", "password123");
// Then:验证数据库状态
Optional<User> savedUser = userRepository.findByEmail("alice@example.com");
assertTrue(savedUser.isPresent());
assertEquals("alice@example.com", savedUser.get().getEmail());
// Then:验证邮件被调用
verify(emailService).sendWelcomeEmail("alice@example.com");
}
}
Mock策略在不同架构中的应用
不同的软件架构对Mock策略有不同的要求。以下分析三种主流架构下的Mock实践。
单体应用中的Mock策略
在单体应用中,模块之间的边界通常比较模糊,Mock的策略相对简单:
推荐策略:
- Service层:用Mock替代数据库访问(Repository)和外部API调用
- Repository层:用集成测试 + 真实数据库(Testcontainers)
- Controller层:用Mock替代Service,测试请求映射和参数校验
// 单体应用的分层测试策略
// 1. Controller层测试:Mock Service
@WebMvcTest(UserController.class)
class UserControllerTest {
@MockBean
UserService userService;
@Test
void testRegister() throws Exception {
when(userService.register(any())).thenReturn(new User("alice"));
mockMvc.perform(post("/users/register")
.contentType("application/json")
.content("{\"email\": \"alice@example.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
}
// 2. Service层测试:Mock Repository
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
UserRepository userRepository;
@InjectMocks
UserService userService;
@Test
void testRegister() {
when(userRepository.save(any())).thenReturn(new User("alice"));
User result = userService.register("alice@example.com", "password");
assertNotNull(result);
verify(userRepository).save(any());
}
}
// 3. Repository层测试:真实数据库
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");
@Autowired
UserRepository userRepository;
@Test
void testSave() {
User user = new User("alice@example.com", "hashed");
User saved = userRepository.save(user);
assertNotNull(saved.getId());
assertTrue(userRepository.findByEmail("alice@example.com").isPresent());
}
}
微服务架构中的Mock策略
微服务架构中,服务之间的通信是Mock的重点。
挑战:
- 服务间调用是网络调用,需要用Mock模拟
- 服务可能有多个实例,需要用Mock模拟负载均衡
- 服务可能宕机,需要用Mock模拟熔断和降级
推荐策略:
- 用Contract Testing替代部分集成测试:定义服务间的契约(如Pact),确保每个服务都遵守契约
- 用Service Mesh或API Gateway模拟下游服务:在测试环境中部署轻量级的Mock服务
- 用Resilience4j等框架测试熔断和降级:模拟下游服务失败的场景
// 微服务中的Mock:模拟下游服务失败
@Test
void testOrderService_whenPaymentServiceFails() {
// 用WireMock模拟支付服务返回500
stubFor(post(urlEqualTo("/api/payments"))
.willReturn(aResponse()
.withStatus(500)
.withBody("Internal Server Error")));
// 调用订单服务
OrderService service = new OrderService(
"http://localhost:8080", // WireMock的地址
restTemplate
);
// 验证:订单服务应该正确处理支付服务失败
assertThrows(PaymentFailedException.class, () -> {
service.processOrder(order);
});
// 验证:订单状态应该是"PAYMENT_FAILED",而不是"PROCESSED"
Order updatedOrder = orderRepository.findById(order.getId()).get();
assertEquals("PAYMENT_FAILED", updatedOrder.getStatus());
}
事件驱动架构中的Mock策略
事件驱动架构中,Mock的重点是事件发布和订阅。
挑战:
- 事件可能被多个消费者处理,需要验证所有消费者都被触发
- 事件可能是异步的,需要等待事件被处理
- 事件可能有顺序要求,需要验证顺序
推荐策略:
- 用InMemoryEventBus替代真实的消息队列:在单元测试中,用内存中的事件总线
- 用@Mock和@Captor验证事件发布:捕获发布的事件,验证内容和类型
- 用Awaitility验证异步处理:等待异步事件被处理完成
// 事件驱动架构的Mock策略
// 1. 定义事件总线接口
interface EventBus {
void publish(Event event);
void subscribe(Class<? extends Event> eventType, EventHandler handler);
}
// 2. 用Fake EventBus测试
class InMemoryEventBus implements EventBus {
private Map<Class<? extends Event>, List<EventHandler>> handlers = new HashMap<>();
private List<Event> publishedEvents = new ArrayList<>();
@Override
public void publish(Event event) {
publishedEvents.add(event);
List<EventHandler> eventHandlers = handlers.get(event.getClass());
if (eventHandlers != null) {
for (EventHandler handler : eventHandlers) {
handler.handle(event);
}
}
}
@Override
public void subscribe(Class<? extends Event> eventType, EventHandler handler) {
handlers.computeIfAbsent(eventType, k -> new ArrayList<>()).add(handler);
}
// 用于测试:获取所有发布的事件
public List<Event> getPublishedEvents() {
return publishedEvents;
}
// 用于测试:清空事件(每个测试前调用)
public void clear() {
publishedEvents.clear();
handlers.clear();
}
}
// 3. 测试:验证事件发布
@Test
void testOrderCancelled_publishesEvent() {
InMemoryEventBus eventBus = new InMemoryEventBus();
OrderService service = new OrderService(eventBus);
service.cancelOrder("order123");
// 验证:事件被发布
List<Event> events = eventBus.getPublishedEvents();
assertEquals(1, events.size());
assertTrue(events.get(0) instanceof OrderCancelledEvent);
OrderCancelledEvent event = (OrderCancelledEvent) events.get(0);
assertEquals("order123", event.getOrderId());
}
性能测试中的Mock策略
性能测试(如压力测试、负载测试)中,Mock的策略和单元测试不同。
为什么性能测试需要Mock?
在性能测试中,你可能需要模拟:
- 第三方服务:你无法对真实的第三方服务做压力测试(会触发限流或产生费用)
- 下游服务:如果下游服务性能差,会影响你的服务的性能测试结果
- 数据:需要大量测试数据,用Mock可以快速生成
性能测试中的Mock工具
1. WireMock:模拟HTTP服务
// 用WireMock模拟一个慢响应的服务
stubFor(post(urlEqualTo("/api/heavy-computation"))
.willReturn(aResponse()
.withFixedDelay(5000) // 固定延迟5秒
.withStatus(200)
.withBody("{\"result\": \"computed\"}")));
2. Chaos Mesh:模拟故障和延迟
# 用Chaos Mesh注入延迟
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
name: delay-payment-service
spec:
action: delay
mode: one
selector:
namespaces:
- production
labelSelectors:
app: payment-service
delay:
latency: "5s" # 注入5秒延迟
correlation: "100"
jitter: "1s"
3. Gatling:性能测试工具,内置Mock支持
// 用Gatling模拟用户注册的性能测试
class UserRegistrationSimulation extends Simulation {
val httpConf = http
.baseUrl("http://localhost:8080")
.acceptHeader("application/json")
.contentTypeHeader("application/json")
val scn = scenario("User Registration")
.exec(http("register")
.post("/users/register")
.body(StringBody("""{"email": "user${randomInt}", "password": "pass"}"""))
.check(status.is(200)))
setUp(
scn.inject(
rampUsers(1000).during(60.seconds) // 60秒内逐步增加到1000用户
)
).protocols(httpConf)
}
性能测试的最佳实践
- 用真实的Mock,不要用过于简化的Mock:如果Mock的响应时间和真实服务差太多,性能测试的结果没有意义
- 模拟真实的负载模式:不要只是均匀地发送请求,要模拟真实的用户行为(如峰值、波动)
- 监控所有层:不仅监控应用的响应时间,还要监控数据库、缓存、消息队列的性能
Mock策略的演进:从简单到复杂
Mock策略不是一成不变的,应该随着项目的成长而演进。以下是一个典型的演进路径。
阶段一:项目初期(0-3个月)
特征:
- 代码库小,业务逻辑简单
- 团队成员少,大家对代码都熟悉
- 变化快,API经常变动
推荐的Mock策略:
- 少用Mock,多用真实实现:因为业务逻辑简单,用真实实现测试更可信,且API变动时测试会及时失败
- 用InMemory的Fake:如
InMemoryUserRepository,简单易写 - 只Mock第三方API:如支付网关、短信服务
// 项目初期:简单的测试
@Test
public void testRegister() {
// 用InMemory Fake
UserRepository repo = new InMemoryUserRepository();
EmailService emailService = new FakeEmailService(); // 不真的发邮件
UserService service = new UserService(repo, emailService);
User user = service.register("alice@example.com", "password");
assertNotNull(user);
assertEquals("alice@example.com", user.getEmail());
}
为什么这样?
- 项目初期,重构频繁,如果用了太多Mock,每次重构都要改测试
- 用真实实现,重构时测试会失败,提醒你测试也需要改
- 代码库小,测试跑得快,不需要用Mock提速
阶段二:项目成长期(3-12个月)
特征:
- 代码库变大,模块变多
- 团队变大,有人不熟悉全部代码
- 业务逻辑变复杂,有很多边界条件
推荐的Mock策略:
- 开始用Mock隔离模块:不同模块的开发可以并行,不互相阻塞
- 引入Mockito等框架:手动写Fake太累,用框架更快
- 写更多的边界条件测试:用Mock轻松模拟各种异常场景
// 项目成长期:用Mock模拟异常场景
@Test
public void testRegister_whenDatabaseFails() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
when(mockRepo.save(any())).thenThrow(new DatabaseException("Connection failed"));
UserService service = new UserService(mockRepo, new FakeEmailService());
// 验证:数据库失败时,服务应该正确处理
assertThrows(RegistrationFailedException.class, () -> {
service.register("alice@example.com", "password");
});
// 验证:没有发邮件(因为注册失败)
// ...
}
为什么这样?
- 模块变多,需要隔离测试
- 异常场景变多,用Mock更容易模拟
- 团队变大,需要测试作为"活文档",说明代码的预期行为
阶段三:项目成熟期(12个月+)
特征:
- 代码库庞大,有很多遗留代码
- 有多个团队并行开发
- 系统稳定很重要,不能有回归bug
推荐的Mock策略:
- 分层测试策略:单元测试(Mock)+ 集成测试(真实数据库)+ 端到端测试(真实环境)
- Contract Testing:微服务之间用Pact等工具定义契约
- 测试金字塔:大量单元测试,适量集成测试,少量端到端测试
- 性能测试:用Mock模拟第三方服务,做压力测试
// 项目成熟期:完整的测试策略
// 1. 单元测试:用Mock
@Test
public void testProcessOrder() {
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);
// ...
}
// 2. 集成测试:用Testcontainers
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");
@Test
void testOrderWorkflow() {
// 真实的数据库操作
}
}
// 3. 端到端测试:用真实环境(或尽可能接近)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderE2ETest {
@Test
void testCompleteOrderFlow() {
// 从HTTP请求到数据库,完整流程
}
}
为什么这样?
- 系统复杂,需要多层次的测试保证质量
- 多团队并行开发,需要Contract Testing保证兼容性
- 性能要求高,需要专门的性能测试
阶段四:遗留系统现代化
特征:
- 有大量的遗留代码,没有测试或测试很少
- 需要重构,但怕改出问题
- 需要引入Mock,但遗留代码不适合Mock(有很多静态方法、硬编码依赖)
推荐的Mock策略:
- 用Spy部分Mock:先让测试覆盖现有行为,再重构
- 用PowerMock/Mockito-Inline Mock静态方法:临时方案,重构后去掉
- 逐步引入依赖注入:把硬编码的依赖改成可注入的
// 遗留系统:用Spy逐步重构
@Test
public void testLegacyCode() {
LegacyService service = new LegacyService();
// 用Spy包装真实对象
LegacyService spy = Mockito.spy(service);
// 只Mock其中一个方法(其他方法用真实实现)
doReturn("mocked").when(spy).someMethod();
// 测试
String result = spy.doSomething();
// 验证:确保重构后行为不变
}
重构路径:
- 第一步:用Spy写测试,覆盖现有行为(Characterization Test)
- 第二步:重构生产代码,引入依赖注入
- 第三步:把Spy改成Mock,测试更清晰
- 第四步:去掉不需要的测试,保留有意义的测试
团队采用Mock策略的路线图
制定Mock策略后,如何在团队中落地?以下是一个可行的路线图。
第一周:培训和规范制定
目标:让团队理解Mock的基本概念和项目规范。
活动:
- 培训会:讲解Test Double的五种类型、Mockito的基本用法
- 制定规范:讨论并确认"Mock规范"文档(参考前面的建议一)
- 准备工具:确保CI流水线能跑测试,确保IDE有Mockito插件
产出:
TESTING_GUIDE.md:项目的测试规范文档- 示例代码:在项目中加一两个好的测试示例
第二~四周:试点
目标:在1-2个模块中试用新的Mock策略,收集反馈。
活动:
- 选试点模块:选一个中等复杂度的模块(不要太简单,也不要太复杂)
- 写测试:为这个模块写完整的单元测试+集成测试
- Code Review:重点关注测试代码的质量
- 收集反馈:团队成员觉得测试好写吗?测试跑得快吗?测试可信吗?
常见问题:
- "Mock太多,测试不好写" → 说明模块职责太多,需要拆分
- "测试跑得慢" → 说明有测试在连接真实外部服务,需要Mock
- "测试经常挂" → 说明过度指定,需要减少不必要的verify
第五~八周:推广
目标:把所有模块逐步改成新的Mock策略。
活动:
- 分批改造:每次PR都要求"新代码必须有测试"
- Pair Programming:有经验的开发者带着没经验的开发者写测试
- 定期回顾:每周回顾一次测试结果(覆盖率、失败率、运行时间)
关键指标:
- 测试覆盖率:逐步提升到70%以上(不要追求100%)
- 测试运行时间:单元测试应该在1分钟内跑完
- 测试失败率:应该低于5%(高于说明测试不稳定)
长期:持续改进
目标:让测试成为开发流程的一部分,而不是负担。
活动:
- 定期重构测试代码:每季度花1-2天重构测试代码
- 分享最佳实践:每月分享一次"好的测试"和"坏的测试"案例
- 引入新工具:如发现Testcontainers好用,就引入;如发现某个Mock框架不好用,就换掉
一个完整的实战案例:电商系统的下单流程
最后,用一个完整的实战案例,展示如何为一个复杂的业务流程设计Mock策略。
业务场景
电商系统的下单流程涉及:
- 验证商品库存(调用InventoryService)
- 计算价格(调用PricingService,可能涉及促销、优惠券)
- 创建订单(调用OrderRepository)
- 扣减库存(调用InventoryService)
- 支付(调用PaymentGateway)
- 发送确认邮件(调用EmailService)
- 更新用户积分(调用LoyaltyService)
- 发布订单创建事件(调用EventPublisher)
测试策略设计
对于这个复杂的流程,应该设计多层次的测试:
1. 单元测试:测试核心逻辑
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
InventoryService inventoryService;
@Mock
PricingService pricingService;
@Mock
OrderRepository orderRepository;
@Mock
PaymentGateway paymentGateway;
@Mock
EmailService emailService;
@Mock
LoyaltyService loyaltyService;
@Mock
EventPublisher eventPublisher;
@InjectMocks
OrderService orderService;
@Test
@DisplayName("下单成功:完整流程")
void createOrder_success() {
// Given:设置所有Stub
String productId = "product1";
int quantity = 2;
when(inventoryService.checkStock(productId)).thenReturn(100); // 库存充足
when(pricingService.calculate(productId, quantity))
.thenReturn(new Price(2000, "USD"));
when(orderRepository.save(any(Order.class)))
.thenAnswer(inv -> inv.getArgument(0));
when(paymentGateway.charge(any(ChargeRequest.class)))
.thenReturn(new ChargeResult("txn_123", "success"));
// When
Order order = orderService.createOrder(
"user1",
List.of(new OrderItem(productId, quantity)),
"credit_card"
);
// Then:验证返回值
assertNotNull(order);
assertEquals("user1", order.getUserId());
assertEquals("CREATED", order.getStatus());
assertEquals(2000, order.getTotalAmount());
// Then:验证所有副作用
verify(inventoryService).deduct(productId, quantity); // 库存被扣
verify(paymentGateway).charge(argThat(req ->
req.getAmount() == 2000 && "USD".equals(req.getCurrency())
)); // 支付被正确调用
verify(emailService).sendOrderConfirmation("user1", order.getId()); // 邮件被发
verify(loyaltyService).addPoints("user1", 20); // 20积分(每100元1积分)
verify(eventPublisher).publish(argThat(event ->
event instanceof OrderCreatedEvent
)); // 事件被发布
}
@Test
@DisplayName("下单失败:库存不足")
void createOrder_insufficientStock() {
// Given
when(inventoryService.checkStock("product1")).thenReturn(1); // 只有1个
// When & Then
assertThrows(InsufficientStockException.class, () -> {
orderService.createOrder("user1", List.of(new OrderItem("product1", 2)), "credit_card");
});
// 验证:没有创建订单、没有扣库存、没有支付、没有发邮件
verify(orderRepository, never()).save(any());
verify(inventoryService, never()).deduct(anyString(), anyInt());
verify(paymentGateway, never()).charge(any());
verify(emailService, never()).sendOrderConfirmation(anyString(), anyString());
}
@Test
@DisplayName("下单失败:支付失败,触发补偿逻辑")
void createOrder_paymentFails_triggersCompensation() {
// Given
when(inventoryService.checkStock(anyString())).thenReturn(100);
when(pricingService.calculate(any(), anyInt())).thenReturn(new Price(2000, "USD"));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(paymentGateway.charge(any())).thenThrow(new PaymentException("Card declined"));
// When
assertThrows(OrderCreationFailedException.class, () -> {
orderService.createOrder("user1", List.of(new OrderItem("product1", 2)), "credit_card");
});
// Then:验证补偿逻辑(库存应该被回滚)
verify(inventoryService).deduct("product1", 2); // 先扣了
verify(inventoryService).restock("product1", 2); // 然后回滚
verify(eventPublisher).publish(argThat(event ->
event instanceof OrderFailedEvent
)); // 发布失败事件
}
}
2. 集成测试:测试真实集成
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@MockBean // Spring Boot的Mock Bean
PaymentGateway paymentGateway; // 第三方仍然Mock
@MockBean
EmailService emailService;
@Test
void createOrder_success_integration() {
// Given:准备测试数据
Product product = new Product("product1", "Test Product", 1000);
productRepository.save(product);
when(paymentGateway.charge(any())).thenReturn(new ChargeResult("txn_123", "success"));
// When
Order order = orderService.createOrder(
"user1",
List.of(new OrderItem("product1", 2)),
"credit_card"
);
// Then:验证数据库状态
Order savedOrder = orderRepository.findById(order.getId()).get();
assertEquals("CREATED", savedOrder.getStatus());
assertEquals(2000, savedOrder.getTotalAmount());
// Then:验证库存真的被扣了
Product updatedProduct = productRepository.findById("product1").get();
assertEquals(98, updatedProduct.getStock()); // 100 - 2
// Then:验证Mock被正确调用
verify(paymentGateway).charge(any());
verify(emailService).sendOrderConfirmation(anyString(), anyString());
}
}
3. 端到端测试:测试完整流程
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderE2ETest {
@Autowired
TestRestTemplate restTemplate;
@MockBean
PaymentGateway paymentGateway;
@Test
void createOrder_e2e() {
// Given
when(paymentGateway.charge(any())).thenReturn(new ChargeResult("txn_123", "success"));
CreateOrderRequest request = new CreateOrderRequest();
request.setUserId("user1");
request.setItems(List.of(new OrderItemDTO("product1", 2)));
request.setPaymentMethod("credit_card");
// When
ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
"/api/orders",
request,
OrderResponse.class
);
// Then
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody().getOrderId());
assertEquals("CREATED", response.getBody().getStatus());
}
}
这个案例的亮点
- 多层次的测试:单元测试(快速反馈)+ 集成测试(验证集成)+ 端到端测试(验证完整流程)
- 异常场景的覆盖:不仅测了"成功",还测了"库存不足"、"支付失败"等异常场景
- 补偿逻辑的验证:支付失败时,验证库存回滚
- 真实的集成测试:用Testcontainers确保数据库操作正确
- 适当的Mock:第三方API(PaymentGateway、EmailService)Mock,内部依赖用真实实现
总结
Mock是强大的工具,但需要谨慎使用。好的Mock策略应该:
- 明确测试的目标:测状态还是测行为?这决定了用Stub还是Mock
- 保持测试的可信度:不要为了"能跑通"而Mock一切,失去测试的意义
- 避免过度指定:只验证重要的东西,让实现有一定灵活性
- 组合使用不同类型的Test Double:Dummy、Fake、Stub、Mock、Spy各有适用场景
- 定期重构测试代码:测试代码也是代码,需要维护
- 根据架构调整策略:单体、微服务、事件驱动架构对Mock的要求不同
- 在性能测试中合理使用Mock:模拟第三方服务和故障场景
核心原则回顾
- 测试应该验证"What"(做了什么),而不是"How"(怎么做的)
- Mock的边界应该是"进程边界":进程内的依赖用真实实现,进程外的依赖用Mock
- 测试的价值不在于覆盖率,而在于它能捕获真正的bug
- 如果一个测试覆盖率100%但捕获不了bug,那它不如一个覆盖率50%但能捕获关键bug的测试
- Mock是手段,不是目的:如果不用Mock也能写出好的测试,那就不要用Mock
最后的建议
制定Mock策略不是一次性工作,而是需要随着项目演进不断调整的。建议:
- 项目初期:用纯单元测试 + 少量集成测试,快速迭代
- 项目成熟期:增加集成测试和端到端测试,提高可信度
- 微服务化后:引入Contract Testing和Service Mock,确保服务间的兼容性
- 定期回顾:每季度回顾一次测试策略,看看是否有改进空间
推荐阅读
- 《xUnit Test Patterns》 - Gerard Meszaros:Test Double的分类和最佳实践
- 《Growing Object-Oriented Software, Guided by Tests》 - Steve Freeman & Nat Pryce:TDD和Mock的高级用法
- 《Testing Microservices with Mountebank》 - Brandon Byars:微服务中的Mock和Contract Testing
- Mockito官方文档:https://site.mockito.org/
- Martin Fowler的测试相关文章:https://martinfowler.com/testing/
记住:测试是手段,不是目的。测试的目的是交付高质量软件,而不是追求覆盖率数字。好的测试应该让你有信心重构代码,而不是成为维护负担。
常见问题FAQ
Q1: 什么时候应该不用Mock?
A: 以下场景不应该用Mock:
- 测试值对象(Value Object):值对象通常是不可变的、无副作用的,用真实实现测试更简单
- 测试工具类:如果工具类没有外部依赖,直接用真实实现
- 集成测试:验证多个组件是否能正确协同工作,应该用真实实现
- 当Mock让测试变得更复杂时:如果发现自己在测试中写了大量的Mock setup,说明可能不应该用Mock
Q2: 如何说服团队采用更好的Mock策略?
A:
- 用数据说话:对比采用前后,测试运行时间、bug数量、重构频率的变化
- 以身作则:自己先写出好的测试示例,让大家看到好处
- 逐步推进:不要一次性要求所有代码都改,先在新代码中试行
- 定期分享:分享"好的测试"案例,讲解为什么好
Q3: Mock框架选哪个?
A: 取决于语言:
- Java: Mockito(推荐)、EasyMock、PowerMock(不推荐,用Mockito-Inline替代)
- Python: unittest.mock(标准库)、pytest-mock(推荐)
- JavaScript: Jest(内置Mock)、Sinon.js
- Go: Go语言的Mock不太方便,推荐用Interface + Fake
- C#: Moq、NSubstitute
选择原则:社区活跃、文档完善、和测试框架集成好。
Q4: 测试覆盖率应该达到多少?
A: 没有标准答案,但有一些经验值:
- 70-80%:是一个合理的目标,覆盖大部分关键逻辑
- 100%:不推荐追求,因为达到100%的成本很高,且边际效益递减
- 重点覆盖核心业务逻辑:如订单处理、支付流程,这些应该90%以上
- 工具类、简单CRUD可以不测或少量测试
更重要的是测试的质量,而不是数量。一个能捕获bug的测试,比十个不能捕获bug但覆盖率高的测试更有价值。
Q5: 如何处理遗留代码中的静态方法?
A: 有几种策略:
- 短期:用PowerMock或Mockito-Inline的
mockStatic(不推荐长期使用) - 中期:用Spy部分Mock,逐步重构
- 长期:引入依赖注入,把静态方法变成可注入的实例方法
推荐路径:先写Characterization Test(用Spy),再重构,最后去掉Spy。
附录:Mock最佳实践检查清单
在提交测试代码之前,用这个检查清单自查:
- 测试名称是否清晰表达了测试场景和预期结果?
- 是否只Mock了不可控的外部依赖(第三方API、外部服务)?
- 进程内的依赖是否用了真实实现或Fake?
- 是否验证了关键的副作用(用
verify)? - 是否过度验证了实现细节(用
verify验证了太多东西)? - Mock的setup是否简单清晰(不超过10行)?
- 如果Mock setup超过10行,是否考虑了用Fake替代?
- 是否测试了异常场景(如数据库失败、第三方API失败)?
- 测试之间是否独立(不依赖其他测试的执行顺序)?
- 测试是否快速(单元测试在1秒内跑完)?
- 是否定期重构测试代码(抽取重复代码、改善命名)?
附录二:Mock策略的十大黄金法则
基于多年的实践经验,总结出以下十大黄金法则,帮助你在项目中正确使用Mock:
法则一:测试行为,而非实现
不要验证"是否调用了某方法",而要验证"是否产生了预期的副作用"。实现可以变化,但行为应该稳定。如果重构代码后测试挂了,说明测试在验证实现细节。
错误示例:
// 验证了实现细节:process()是否调用了step1(), step2(), step3()
verify(mockService).step1();
verify(mockService).step2();
verify(mockService).step3();
正确示例:
// 验证行为:订单是否被创建、邮件是否被发送
assertEquals("CREATED", order.getStatus());
verify(emailService).sendOrderConfirmation(anyString());
法则二:Mock边界是进程边界
进程内的依赖用真实实现,进程外的依赖用Mock。不要为了"纯度为0的单元测试"而Mock同一项目内的所有类。
原则:
- Mock:数据库、第三方API、消息队列、文件系统、网络调用
- 不Mock:同一项目的其他类、值对象、工具类
法则三:每个测试只有一个Mock理由
如果你在一个测试中Mock了五六七八个依赖,说明这个测试在测太多东西,或者被测类职责太多。拆分!
信号:
- 测试中超过5个
@Mock - 测试方法有超过20行Mock setup
- 不知道哪些Mock是重要的、哪些是不重要的
法则四:用Fake替代复杂的Mock
如果为了模拟某个依赖的行为,你需要写超过10行Mock setup,说明应该用Fake。Fake一次编写,多次使用,长期来看维护成本更低。
适合用Fake的场景:
- 有状态依赖(如Repository)
- 有复杂行为的依赖(如需要模拟事务、并发)
- 在多个测试中重复使用的依赖
法则五:异常场景同等重要
不要只测"成功"。支付失败、数据库宕机、网络超时,这些异常场景往往更重要,而Mock让它们易于模拟。
建议:
- 至少写和"成功"测试一样多的"失败"测试
- 用
@ParameterizedTest测试多种异常场景 - 验证异常处理的正确性(如补偿逻辑、回滚逻辑)
法则六:测试代码也是代码
测试代码不是一次性用品。它需要和production代码一样的关注:可读性、可维护性、重构。
实践:
- 用
@BeforeEach或Factory方法抽取重复代码 - 用描述性的测试名称(如
testRegister_whenEmailExists_throwsException) - 定期重构测试代码(每季度一次)
法则七:覆盖率不是目标
100%的覆盖率如果捕获不了bug,不如70%的覆盖率但能捕获关键bug。关注测试的质量,而不是数量。
更好的指标:
- 生产环境bug是否被测试捕获?
- 重构代码时,测试是否能及时失败?
- 新成员能否通过测试理解代码行为?
法则八:定期回顾测试策略
每季度回顾一次:测试是否太慢?是否太脆弱?是否捕获了生产环境的bug?根据反馈调整策略。
回顾问题:
- 测试运行时间是否增加?是否可以并行运行?
- 是否有测试经常随机失败(flaky test)?
- 是否有测试从不失败(说明可能没测到关键逻辑)?
法则九:新代码必须有测试
这是底线。没有测试的代码不应该被合并。可以先写简单的测试,后续迭代改进。
实施方法:
- 在CI流水线中检查测试覆盖率
- Code Review时,第一件事是看测试
- 为测试不足的代码添加TODO注释,后续补充
法则十:保持学习的态度
测试框架在演进,最佳实践在更新。保持学习,定期尝试新的工具和方法。
学习资源:
- 关注Martin Fowler、Kent Beck等人的博客
- 参加测试相关的技术会议(如TestCon、Agile Testing Days)
- 在项目中实验新的测试工具(如Testcontainers、Pact)
"任何足够先进的测试技术,都与Mock无异。" —— 改编自Arthur C. Clarke
文章版本: v1.0
最后更新: 2026-05-31
作者: 技术博客作者
反馈: 如有问题或建议,欢迎讨论