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

《JUnit in Action》全新第3版封面截图

写在前面

在上一章介绍 Stub 模拟时作者曾反复强调,细粒度的测试还得使用 mock 对象进行模拟,并且还说 Stub 是过去人们对模拟测试的认识还不准确导致的中间产物,可谓吊足了我对 mock 模拟技术的胃口。深入了解后才发现,自己之前从前端和 Postman 那里偷学来的那点 mock 技术还是太肤浅了,至少对于隔离和本地这两个概念的认识很模糊。直到看到作者演示的案例,加上 DeepSeek 的趁热打铁,对于这个 mock 才自认算是入门了。可见叙事能力和选取经典案例的极端重要性。

文章目录

  • [第八章 mock 对象模拟技术在细粒度测试中的应用(上)](#第八章 mock 对象模拟技术在细粒度测试中的应用(上))
    • [8.1 基本概念](#8.1 基本概念)
    • [8.2 演示案例概况](#8.2 演示案例概况)
    • [8.3 模拟1:无重构模拟 transfer 方法](#8.3 模拟1:无重构模拟 transfer 方法)

第八章 mock 对象模拟技术在细粒度测试中的应用(上)

本章概要

  • mock 对象简介与用法演示
  • 借助 mock 对象执行多种重构
  • 案例演示:用 mock 对象模拟 HTTP 连接
  • EasyMockJMockMockito 框架的用法及平行对比
    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 MackinnonSteve FreemanPhilip CraigXP2000 极限编程国际大会[1](#1) 上被首次提出。

8.1 基本概念

测试环境与真实环境相隔离的最大好处在于:被测系统即便依赖了其他对象,也不会受到任何因调用了它们的方法所产生的副作用的影响。

时刻保持测试用例的简单、轻量、小巧 是第一重要的。

单元测试套件的意义:让后续扩展及重构更有底气。

mock 模拟与 Stub 模拟的差异:

对比维度 mock 对象 Stub 桩模拟
隔离级别 方法级(细粒度) 系统级、模块级(粗粒度)
业务逻辑实现 完全不涉及原逻辑,只是个 空壳 完全保留原逻辑,与生产环境一致
预设行为 完全无预设,须手动设置 提前预设,运行后无法变更
测试模式 初始化 mock ➡️ 设置期望 ➡️ 执行测试 ➡️ 验证断言 初始化 Stub ➡️ 执行测试 ➡️ 验证断言

8.2 演示案例概况

本章重点研究两个案例:简化的银行转账场景,以及第七章介绍的远程 URL 连接场景。

银行转账场景的核心设计如下图所示:

相关实现如下:

  1. AccountService 服务实现类:包含一个经办人 manager 依赖,以及待测方法 transfer()
java 复制代码
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);
    }
}
  1. 经办人接口 AccountManager:转账逻辑主要涉及两个接口实现:转账前的帐户查询、转账后的帐户更新。由于本例不考虑更新失败导致的事务回滚操作,帐户更新对转账核心逻辑就没有任何贡献,因此可以不用实现:
java 复制代码
public interface AccountManager {
    Account findAccountForUser(String userId);
    void updateAccount(Account account);
}
  1. Account 帐户实体类:仅包含帐户 id 和余额两个成员属性,以及涉及转账的两个核心操作(支出、收入):
java 复制代码
/**
 * 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

java 复制代码
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 的所有状态了:

java 复制代码
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() 考虑的到底是哪些问题呢?无非是------

  1. 是否通过 accountManager.findAccountForUser() 的调用得到指定的帐户对象;
  2. 是否通过转账人的 debit() 方法扣减了正确的金额;
  3. 是否通过收款人的 credit() 方法收入了正确的金额;
  4. 是否利用 accountManager.updateAccount() 方法更新了转账后的帐户信息。

其中,1 和 4 通过 mock 对象已经通过验证了,因为对其设置的期望值就是按这些要求来的。2 和 3 的验证需要测试用例末尾的两个断言来决定,通过比较转账后的余额是否是设置的期望值就知道了。这样,transfer() 的固有逻辑就全部通过了,一旦真实转账出现 Bug 时,可以很明确地排除是转账逻辑本身导致的问题,只可能是由 accountManager 引入的外部逻辑有问题。如果 accountManager 的两个接口方法也按这个思路进行模拟,则可以进一步缩小排查范围,第一时间找出 Bug 的位置。

解决了最核心的困惑,后面的案例理解起来就轻松多了。

注意到 transfer() 方法没有需要重构的地方,accountManager 也通过依赖注入实现了数据库持久层和转账逻辑之间的解耦,上述模拟不涉及重构原逻辑环节。下面通过另一个方法演示需要重构源码的情况。


  1. 三人在大会上发表了著名论文 Endo-Testing: Unit Testing with Mock Objects 。自此,mock objects 逐渐成为软件测试的标准实践,并催生了一系列模拟框架的发展,例如 JMockEasyMockMockito 等。 ↩︎
相关推荐
天才测试猿12 小时前
Selenium定位元素的方法css和xpath的区别
css·自动化测试·软件测试·python·selenium·测试工具·测试用例
游戏开发爱好者812 小时前
Fiddler抓包实战教程 从安装配置到代理设置,详解Fiddler使用方法与调试技巧(HTTPHTTPS全面指南)
前端·测试工具·小程序·https·fiddler·uni-app·webview
程序员小远21 小时前
selenium元素定位---(元素点击交互异常)解决方法
自动化测试·软件测试·python·selenium·测试工具·测试用例·交互
寒月霜华1 天前
JavaWeb后端-JDBC、MyBatis
spring boot·junit·mybatis
2501_938774291 天前
Copilot 与测试工具协同?Mastering 课程中单元测试生成与结对编程的结合
测试工具·单元测试·copilot
千里镜宵烛2 天前
Lua-function的常见表现形式
开发语言·junit·lua
Hello World......2 天前
互联网大厂Java面试实战:以Spring Boot与微服务为核心的技术场景剖析
java·spring boot·redis·微服务·junit·kafka·spring security