【JUnit实战3_13】第八章:mock 对象模拟技术在细粒度测试中的应用(上)

《JUnit in Action》全新第3版封面截图
写在前面
在上一章介绍 Stub 模拟时作者曾反复强调,细粒度的测试还得使用 mock 对象进行模拟,并且还说 Stub 是过去人们对模拟测试的认识还不准确导致的中间产物,可谓吊足了我对 mock 模拟技术的胃口。深入了解后才发现,自己之前从前端和 Postman 那里偷学来的那点 mock 技术还是太肤浅了,至少对于隔离和本地这两个概念的认识很模糊。直到看到作者演示的案例,加上 DeepSeek 的趁热打铁,对于这个 mock 才自认算是入门了。可见叙事能力和选取经典案例的极端重要性。
文章目录
- 第八章 mock 对象模拟技术在细粒度测试中的应用(上)
- 8.1 基本概念
- 8.2 演示案例概况
- 8.3 模拟1:无重构模拟 transfer 方法
第八章 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 {@Testvoid 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等。 ↩︎
