详细介绍:【JUnit实战3_13】第八章:mock 对象模拟技术在细粒度测试中的应用(上)
2025-11-25 17:06 tlnshuju 阅读(0) 评论(0) 收藏 举报
《JUnit in Action》全新第3版封面截图
写在前面
在上一章介绍 Stub 模拟时作者曾反复强调,细粒度的测试还得使用 mock 对象进行模拟,并且还说 Stub 是过去人们对模拟测试的认识还不准确导致的中间产物,可谓吊足了我对 mock 模拟技术的胃口。深入了解后才发现,自己之前从前端和 Postman 那里偷学来的那点 mock 技术还是太肤浅了,至少对于隔离和本地这两个概念的认识很模糊。直到看到作者演示的案例,加上 DeepSeek 的趁热打铁,对于这个 mock 才自认算是入门了。可见叙事能力和选取经典案例的极端重要性。
第八章 mock 对象模拟技术在细粒度测试中的应用(上)
本章概要
mock对象简介与用法演示- 借助
mock对象执行多种重构- 案例演示:用
mock对象模拟HTTP连接EasyMock、JMock和Mockito框架的用法及平行对比
Programming today is a race between software engineers striving to build bigger and better idiot-proof programs, and the Universe trying to produce bigger and better idiots. So far, the Universe is winning.
如今的编程是一场竞赛:软件工程师们在竭尽全力地构建更庞大、更厉害的“傻瓜式”程序,而宇宙则在不遗余力地制造更强大、更厉害的傻瓜。目前看来还是宇宙更胜一筹。—— Rich Cook
本章较为全面地介绍了 mock 对象模拟技术在单元测试中的基本原理和具体应用。
无论是 Stub 桩模拟还是 mock 对象模拟,其本质都是为了实现测试环境与真实环境的 隔离(isolation);区别在于它们实现的隔离程度不同:stub 桩的粒度更粗,常用于模拟远程 Web 服务器、文件系统、数据库等;而 mock 对象实现的隔离粒度更细,让单元测试可以精确到针对 具体某个方法 开展。
相关背景:mock 对象模拟的概念最早由 Tim Mackinnon、Steve Freeman 和 Philip Craig 在 XP2000 极限编程国际大会1 上被首次提出。
8.1 基本概念
测试环境与真实环境相隔离的最大好处在于:被测系统即便依赖了其他对象,也不会受到任何因调用了它们的方法所产生的副作用的影响。
时刻保持测试用例的简单、轻量、小巧 是第一重要的。
单元测试套件的意义:让后续扩展及重构更有底气。
mock 模拟与 Stub 模拟的差异:
| 对比维度 | mock 对象 | Stub 桩模拟 |
|---|---|---|
| 隔离级别 | 方法级(细粒度) | 系统级、模块级(粗粒度) |
| 业务逻辑实现 | 完全不涉及原逻辑,只是个 空壳 | 完全保留原逻辑,与生产环境一致 |
| 预设行为 | 完全无预设,须手动设置 | 提前预设,运行后无法变更 |
| 测试模式 | 初始化 mock ➡️ 设置期望 ➡️ 执行测试 ➡️ 验证断言 | 初始化 Stub ➡️ 执行测试 ➡️ 验证断言 |
8.2 演示案例概况
本章重点研究两个案例:简化的银行转账场景,以及第七章介绍的远程 URL 连接场景。
银行转账场景的核心设计如下图所示:

相关实现如下:
AccountService服务实现类:包含一个经办人manager依赖,以及待测方法transfer():
public class AccountService {
private AccountManager accountManager;
public void setAccountManager(AccountManager manager) {
this.accountManager = manager;
}
/**
* A transfer method which transfers the amount of money
* from the account with the senderId to the account of
* beneficiaryId.
*/
public void transfer(String senderId, String beneficiaryId, long amount) {
Account sender = accountManager.findAccountForUser(senderId);
Account beneficiary = accountManager.findAccountForUser(beneficiaryId);
sender.debit(amount);
beneficiary.credit(amount);
this.accountManager.updateAccount(sender);
this.accountManager.updateAccount(beneficiary);
}
}
- 经办人接口
AccountManager:转账逻辑主要涉及两个接口实现:转账前的帐户查询、转账后的帐户更新。由于本例不考虑更新失败导致的事务回滚操作,帐户更新对转账核心逻辑就没有任何贡献,因此可以不用实现:
public interface AccountManager {
Account findAccountForUser(String userId);
void updateAccount(Account account);
}
Account帐户实体类:仅包含帐户id和余额两个成员属性,以及涉及转账的两个核心操作(支出、收入):
/**
* Account POJO to hold the bank account object.
*/
public class Account {
private String accountId;
private long balance;
public Account(String accountId, long initialBalance) {
this.accountId = accountId;
this.balance = initialBalance;
}
public void debit(long amount) {
this.balance -= amount;
}
public void credit(long amount) {
this.balance += amount;
}
public long getBalance() {
return this.balance;
}
}
8.3 模拟1:无重构模拟 transfer 方法
先从最简单的 mock 模拟开始演示。仔细观察转账方法 transfer(),其服务类已经通过依赖注入的方式引用了 accountManager,并调用了它的两个接口。在不考虑帐户更新失败导致的事务回滚的情况下,只需要模拟 findAccountForUser() 的实现即可。于是有了如下的模拟对象 MockAccountManager:
public class MockAccountManager implements AccountManager {
private Map<String, Account> accounts = new HashMap<String, Account>();
public void addAccount(String userId, Account account) {
this.accounts.put(userId, account);
}
public Account findAccountForUser(String userId) {
return this.accounts.get(userId);
}
public void updateAccount(Account account) {
// do nothing
}
}
可以看到,帐户更新方法可以不用任何模拟逻辑;新增的 addAccount() 方法也只是为了方便测试过程中的初始化。这样测试用例就能完全控制 MockAccountManager 的所有状态了:
public class TestAccountService {
@Test
void testTransferOk() {
// 1. 初始化 mock 对象
MockAccountManager mockManager = new MockAccountManager();
// 2. 设置期望值
Account senderAccount = new Account("1", 200);
Account beneficiaryAccount = new Account("2", 100);
mockManager.addAccount("1", senderAccount);
mockManager.addAccount("2", beneficiaryAccount);
AccountService service = new AccountService();
service.setAccountManager(mockManager);
// 3. 执行测试
service.transfer("1", "2", 50);
// 4. 验证断言
assertEquals(150, senderAccount.getBalance());
assertEquals(150, beneficiaryAccount.getBalance());
}
}
上述代码中——
mock对象的模拟逻辑和真实环境下的具体逻辑毫不相关,只是实现了同一个AccountManager接口而已;mock对象的所有模拟逻辑都是围绕 怎样让测试用例完全控制 mock 对象的必要状态 展开的,包括新增的HashMap<String, Account>型成员变量,以及addAccount()方法的添加;updateAccount()由于对转账核心逻辑没有实质性贡献,模拟时直接留白即可。
关于 mock 模拟的两则 JUnit 最佳实践
- 永远不要在
mock对象中编写任何真实业务逻辑;- 测试仅针对可能出错的业务逻辑(忽略
updateAccount())。
第一次看到这里时,心中是非常疑惑的:既然 mock 对象的所有逻辑都是为了方便测试用例的全权控制专门模拟出来的,那它们就和真实环境完全脱钩了,即便后期切到真实场景报错了,这些模拟逻辑也依然会通过测试。这样的单元测试又有什么实际意义呢?要模拟转账,一不考虑数据库的查询逻辑,二不考虑更新失败后的回滚逻辑,这样的测试还能叫模拟转账吗?
如果你也跟我有同样的困惑,说明对前面提到的 隔离 二字的理解仍停留在表面:mock 对象模拟的最大价值,恰恰在于依靠这些模拟逻辑真正实现了 本地逻辑 与 外部逻辑 的 完全隔离:
findAccountForUser()是transfer()方法自己的逻辑吗?- 答案是 否定的。那是
accountManager引入的外来逻辑;
- 答案是 否定的。那是
- 同理,
updateAccount()是transfer()自己的逻辑吗?- 答案也是 否定的。那也是
accountManager引入的另一个外来逻辑。
- 答案也是 否定的。那也是
查询、更新帐户是否顺利,本质上同我们真正关心的 transfer() 方法自带的业务逻辑 没有任何交集,那都是 accountManager 在具体实现时才需要考虑的问题。那么,transfer() 考虑的到底是哪些问题呢?无非是——
- 是否通过
accountManager.findAccountForUser()的调用得到指定的帐户对象; - 是否通过转账人的
debit()方法扣减了正确的金额; - 是否通过收款人的
credit()方法收入了正确的金额; - 是否利用
accountManager.updateAccount()方法更新了转账后的帐户信息。
其中,1 和 4 通过 mock 对象已经通过验证了,因为对其设置的期望值就是按这些要求来的。2 和 3 的验证需要测试用例末尾的两个断言来决定,通过比较转账后的余额是否是设置的期望值就知道了。这样,transfer() 的固有逻辑就全部通过了,一旦真实转账出现 Bug 时,可以很明确地排除是转账逻辑本身导致的问题,只可能是由 accountManager 引入的外部逻辑有问题。如果 accountManager 的两个接口方法也按这个思路进行模拟,则可以进一步缩小排查范围,第一时间找出 Bug 的位置。
解决了最核心的困惑,后面的案例理解起来就轻松多了。
注意到 transfer() 方法没有需要重构的地方,accountManager 也通过依赖注入实现了数据库持久层和转账逻辑之间的解耦,上述模拟不涉及重构原逻辑环节。下面通过另一个方法演示需要重构源码的情况。
三人在大会上发表了著名论文 Endo-Testing: Unit Testing with Mock Objects。自此,
mock objects逐渐成为软件测试的标准实践,并催生了一系列模拟框架的发展,例如JMock、EasyMock、Mockito等。 ↩︎
浙公网安备 33010602011771号