【JUnit实战3_14】第八章:mock 对象模拟技术在细粒度测试中的应用(中):为便于模拟重构原逻辑的两种策略

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

写在前面

本篇为第八章自学笔记的中篇,作者通过极其细致的案例演示和倾囊相授的讲解风格,将各个关键节点同第六章介绍过的、编写可测试代码的基本原则前后呼应,让人既了解这些原则的心法,又熟悉具体的招式打法,真正让测试用例和功能模块的正确打开方式深深印入每一位开发者的脑海中,读罢让人去繁就简、豁然开朗。

文章目录

    • [8.4 模拟2:重构并模拟 findAccountForUser 方法](#8.4 模拟2:重构并模拟 findAccountForUser 方法)
    • [8.5 模拟3:重构并模拟远程连接的 getContent 方法](#8.5 模拟3:重构并模拟远程连接的 getContent 方法)
      • [8.5.1 基于 Method Factory 工厂方法的 mock 模拟](#8.5.1 基于 Method Factory 工厂方法的 mock 模拟)
      • [8.5.2 基于 Class Factory 类工厂的 mock 模拟](#8.5.2 基于 Class Factory 类工厂的 mock 模拟)
    • [8.6 模拟4:从 IO 流的 mock 模拟探究被测系统的内部状态监控](#8.6 模拟4:从 IO 流的 mock 模拟探究被测系统的内部状态监控)
    • [8.7 主流 mock 模拟框架对比(下篇)](#8.7 主流 mock 模拟框架对比(下篇))

(接 上篇

8.4 模拟2:重构并模拟 findAccountForUser 方法

本例考察数据库持久层的 accountManager.findAccountForUser() 方法的模拟。真实场景可以涉及日志信息的输出以及 SQL 语句的获取:

java 复制代码
public class DefaultAccountManager1 implements AccountManager {

    private static final Log logger = LogFactory.getLog(DefaultAccountManager1.class);

    public Account findAccountForUser(String userId) {
        logger.debug("Getting account for user [" + userId + "]");
        ResourceBundle bundle = PropertyResourceBundle.getBundle("technical");
        String sql = bundle.getString("FIND_ACCOUNT_FOR_USER");

        // 以下代表通过 JDBC 等方式加载帐户信息的具体逻辑(从略)
        return null;
    }
    // -- snip --
}

有了上个案例的深入剖析,不难看出上述代码存在的问题:查询的核心逻辑深度绑定了日志的具体实现,以及获取 SQL 配置的具体实现。此外,loggerbundle 都是在方法体内直接赋值的,完全没有预留出切入点,以便测试期间换成相应的 mock 对象,因此有必要先重构再模拟,充分利用依赖注入模式:

java 复制代码
public class DefaultAccountManager2 implements AccountManager {
    private Log logger;
    private Configuration configuration;

    public DefaultAccountManager2() {
        this(LogFactory.getLog(DefaultAccountManager2.class),
                new DefaultConfiguration("technical"));
    }

    public DefaultAccountManager2(Log logger, Configuration configuration) {
        this.logger = logger;
        this.configuration = configuration;
    }

    public Account findAccountForUser(String userId) {
        this.logger.debug("Getting account for user [" + userId + "]");
        this.configuration.getSQL("FIND_ACCOUNT_FOR_USER");

        // 以下代表通过 JDBC 等方式加载帐户信息的具体逻辑(从略)
        return null;
    }
    // -- snip --
}

// 重构引入的新接口
public interface Configuration {
    String getSQL(String sqlString);
}

这样,模拟日志和 SQL 语句配置的 mock 对象可以轻松定义并替换原有注入逻辑:

java 复制代码
import org.apache.commons.logging.Log;
public class MockLog implements Log {
    // -- snip --
}

public class MockConfiguration implements Configuration {
    public void setSQL(String sqlString) { }
    public String getSQL(String sqlString) {
        return null;
    }
}

MockLog 看起来代码很多,其实都是 org.apache.commons.logging.Log 接口自身的原因,与重构本身无关。

相应的测试用例写起来就更简单了:

java 复制代码
public class TestDefaultAccountManager {
    @Test
    void testFindAccountByUser() {
        // 1. 初始化
        MockLog logger = new MockLog();
        MockConfiguration configuration = new MockConfiguration();
        // 2. 设置期望值
        configuration.setSQL("SELECT * [...]");
        DefaultAccountManager2 am = new DefaultAccountManager2(logger, configuration);
        // 3. 运行测试
        @SuppressWarnings("unused")
        Account account = am.findAccountForUser("1234");

        // 以下为断言逻辑(略)
    }
}

关于 IoC 控制反转模式

本例的重构基于设计模式中的 控制反转(IoC,Inversion of Control 。对某个类应用 IoC 模式,则意味着需要移除该类不直接负责的所有对象实例的创建,所需的任何实例均通过构造函数或 setter 方法传入,或者通过方法参数传入。这样一来,正确配置这个类所需的其他实例的职责,就全权交由调用该类的调用方身上,而非这个类本身了(详见 https://spring.io)。

8.5 模拟3:重构并模拟远程连接的 getContent 方法

本节是全章的重点内容,既详细介绍了 mock 模拟的两种常用策略(Method FactoryClass Factory),又与上一章相呼应,对比了基于 Stub 桩代码的工厂方法与 mock 对象模拟的不同思路。需要认真分辨各自的特点和异同点。

演示案例还是第七章最后模拟的远程 URL 连接的案例,测试场景如图所示:

被测系统的原逻辑 WebClient.getContent() 方法如下所示:

java 复制代码
public class WebClient {
    public String getContent(URL url) {
        StringBuffer content = new StringBuffer();
        try {
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setDoInput(true);
            InputStream is = connection.getInputStream();
            byte[] buffer = new byte[2048];
            int count;
            while (-1 != (count = is.read(buffer))) {
                content.append(new String(buffer, 0, count));
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return content.toString();
    }
}

上述代码中,getContent() 已经通过传入 URL 参数的方式注入了该依赖,并得到一个 connection 实例。按照之前的思路,本来应该继承 URL 类自定义一个 mock 对象,让其调用重写的 openConnection() 方法返回一个类型为 MockHttpURLConnectionconnection 模拟实例,并创建测试用例如下:

java 复制代码
@Test
public void testGetContentOk() throws Exception {
    MockHttpURLConnection mockConnection = new MockHttpURLConnection();
    mockConnection.setupGetInputStream(new ByteArrayInputStream("It works".getBytes()));
    MockURL mockURL = new MockURL();
    mockURL.setupOpenConnection(mockConnection);
    WebClient client = new WebClient();
    String workingContent = client.getContent(mockURL);
    assertEquals("It works", workingContent);
}

但很可惜,URLjava.net 包中被 JDK 声明为 final 的类,因此继承这条路就彻底断了,只有像第七章的第二个方案那样,寻求某个工厂方法的 mock 模拟。

这通常有两种实现策略:**工厂方法(Method Factory )**和 类工厂(Class Factory

在尝试这两种方法前,还要牢固树立一个意识:代码要主动为测试开路,否则就该重构原逻辑

8.5.1 基于 Method Factory 工厂方法的 mock 模拟

刚才说了 java.net.URL 无法继承新的测试类,因此不利于后期测试,需要重构。重构思路是:将引入外部逻辑的代码片段提取到某个方法内,让原逻辑从直接接收 URL 实例的返回结果,重构为调用一个包含 URL 实例参数的新方法,然后将返回结果赋给原来的变量(L6):

java 复制代码
public class WebClient1 {
    public String getContent(URL url) {
        StringBuffer content = new StringBuffer();

        try {
            HttpURLConnection connection = createHttpURLConnection(url);
            InputStream is = connection.getInputStream();

            int count;
            while (-1 != (count = is.read())) {
                content.append(new String(Character.toChars(count)));
            }
        } catch (IOException e) {
            return null;
        }

        return content.toString();
    }

    /**
     * Creates an HTTP connection.
     */
    protected HttpURLConnection createHttpURLConnection(URL url) throws IOException {
        return (HttpURLConnection) url.openConnection();
    }
}

注意 L23 行的方法修饰符 protected,暗示了后续的测试类需要继承 WebClient1 并重写该方法。按照隔离外部逻辑的总思路,只要最后返回一个 HttpURLConnection 对象即可,至于是不是通过 url.openConnection() 方法获取的 根本不重要。于是有了如下的测试类(内部类):

java 复制代码
private static class TestableWebClient extends WebClient1 {

    private HttpURLConnection connection;

    public void setHttpURLConnection(HttpURLConnection connection) {
        this.connection = connection;
    }

    @Override
    public HttpURLConnection createHttpURLConnection(URL url) throws IOException {
        return this.connection;
    }
}

这样一改造,connection 的控制权就完全由模拟的测试类来控制,再次体现了 mock 对象模拟的隔离本质:

java 复制代码
@Test
public void testGetContentOk() throws Exception {
    MockHttpURLConnection mockConnection = new MockHttpURLConnection();
    mockConnection.setExpectedInputStream(new ByteArrayInputStream("It works".getBytes()));

    TestableWebClient client = new TestableWebClient();
    client.setHttpURLConnection(mockConnection);

    String result = client.getContent(new URL("http://localhost"));
    assertEquals("It works", result);
}

这种抽离核心外部逻辑到一个父级方法、并在专供测试的子类中重写该方法的模拟策略,就叫 Method Factory(工厂方法)。为了让控制权完全由测试用例掌控,新的 mock 对象还可以自行添加一些辅助逻辑(如上述 setter)进一步方便测试。

但这种策略的问题也很突出:新增的测试类只是通过继承模拟了原逻辑,但并完美:为什么一定要从 URL 引入需要的输入流呢?就不能实际和测试都走同一条路吗?类工厂(Class Factory 的模拟策略就是为了回答这个问题的。

8.5.2 基于 Class Factory 类工厂的 mock 模拟

既然 Method Factory 不甚完美,说明原方法还有改进空间。为了让测试和实际代码逻辑保持一致,被测系统的签名可以重构为传入一个新的工厂接口 ConnectionFactory,该接口规定了获取输入流必须遵循的格式:

java 复制代码
/**
 * A connection factory interface. Different connection
 * factories that we have, must implement this interface.
 */
public interface ConnectionFactory {
    /**
     * Read the data from the connection.
     *
     * @return
     * @throws Exception
     */
    InputStream getData() throws Exception;
}

对应的新的被测方法就重构成了:

java 复制代码
public class WebClient2 {
    /**
     * Open a connection to the given URL and read the content
     * out of it. In case of an exception we return null.
     */
    public String getContent(ConnectionFactory connectionFactory) {
        String workingContent;

        StringBuffer content = new StringBuffer();
        try (InputStream is = connectionFactory.getData()) {
            int count;
            while (-1 != (count = is.read())) {
                content.append(new String(Character.toChars(count)));
            }

            workingContent = content.toString();
        } catch (Exception e) {
            workingContent = null;
        }

        return workingContent;
    }
}

这样,利用面向对象编程中的多态机制,后期只要实现了该接口的实体类都可以作为参数传入,Method Factory 的问题就完美解决了:测试时传入 mock 实现、实际传入真实场景下的实现;这种解决方案甚至还解绑了具体的通信协议,不再拘泥于 URL 实例背后的 http 协议:

java 复制代码
public class MockConnectionFactory implements ConnectionFactory {

    private InputStream inputStream;
    public void setData(InputStream stream) {
        this.inputStream = stream;
    }

    @Override
    public InputStream getData() throws Exception
    {
        return inputStream;
    }
}

相应的测试用例写起来同样很简单:

java 复制代码
public class TestWebClient1 {
    @Test
    public void testGetContentOk() throws Exception {
        MockConnectionFactory mockConnectionFactory = new MockConnectionFactory();
        mockConnectionFactory.setData(new ByteArrayInputStream("It works".getBytes()));

        WebClient2 client = new WebClient2();
        String workingContent = client.getContent(mockConnectionFactory);

        assertEquals("It works", workingContent);
    }
}

像这样完全从方便测试的角度出发重构原逻辑、将外部逻辑通过传入一个工厂接口来代理的模拟策略,就叫 Class Factory 类方法策略。

8.6 模拟4:从 IO 流的 mock 模拟探究被测系统的内部状态监控

本章模拟 Connection 对象的示例还留了个不大不小的坑:测试逻辑涉及 IO 流的创建,但并没有显式地、手动地关闭。谁能保证今后绝不会有内存泄露的风险?mock 对象模拟技术的另一个强大之处,就在于测试时还能监控 SUT 的内部状态。

实现思路:替换原来的 ByteArrayInputStream 的初始化,改成一个可以监控输入流是否关闭的模拟 IO 流测试类:

java 复制代码
public class MockInputStream extends InputStream {

    private String buffer;

    // Current position in the stream.
    private int position = 0;

    // How many times the close method was called.
    private int closeCount = 0;

    // Sets the buffer.
    public void setBuffer(String buffer) {
        this.buffer = buffer;
    }

    // Reads from the stream.
    public int read() throws IOException {
        if (position == this.buffer.length()) {
            return -1;
        }
        return buffer.charAt(this.position++);
    }

    // Close the stream.
    public void close() throws IOException {
        closeCount++;
        super.close();
    }

    /**
     * Verify how many times the close method was called.
     *
     * @throws java.lang.AssertionError
     */
    public void verify() throws java.lang.AssertionError {
        if (closeCount != 1) {
            throw new AssertionError("close() should have been called once and once only");
        }
    }
}

注意观察 close() 方法和 verify() 方法,一旦测试逻辑没有正确关闭该输入流,后者就会抛出一个断言错误(AssertionError),最终会在 IDEA 中显示为未通过的用例。

重构完模拟的输入流,而测试用例的写法也要作相应调整:

java 复制代码
public class TestWebClient {
    @Test
    public void testGetContentOk() throws Exception {
        MockConnectionFactory mockConnectionFactory = new MockConnectionFactory();
        MockInputStream mockStream = new MockInputStream();
        mockStream.setBuffer("It works");
        mockConnectionFactory.setData(mockStream);
        
        WebClient2 client = new WebClient2();
        String workingContent = client.getContent(mockConnectionFactory);

        assertEquals("It works", workingContent);
        mockStream.verify();
    }
}

注意第 L13 行手动调用了 verify() 方法,一旦输入流对象 mockStream 没有关闭(即没有被精确关闭过一次),测试用例就会抛出一个 AssertionError 而宣告失败(重构后的读取逻辑已经采用 try-with-resources 语法糖,因此不会报错)。

关于模拟测试中的期望(expectation)的概念

在讨论 mock 对象时,期望(expectation 常常作为模拟对象的一个固有特性,专门用于验证调用该模拟对象的外部类的某种行为 是否正确 ,例如这里的 close() 方法是否被精确执行过一次、模拟数据库的连接是否正常等。

期望机制还可以验证不同的生命周期方法是否按预期的顺序执行测试逻辑,或者验证模拟对象接收的某个参数是否满足具体的限定条件。其核心设计理念是:

  • 验证预期的行为模式;
  • 对模拟对象的使用情况、中间状态等提供有价值的反馈信息。

8.7 主流 mock 模拟框架对比(下篇)

mock 对象模拟技术固然强大,但写起来也是真繁琐。好在先行者已经提前踩完这些坑并推出了多种 mock 框架简化这一过程。作为本章最后一节内容,作者选取了三个主流框架进行演示:EasyMockJMockMockito,让大家通过示例代码考察它们的区别和联系。篇幅原因,具体注意事项及横向对比复盘放到下篇中介绍,敬请关注!

(未完待续)

相关推荐
.hopeful.3 小时前
Selenium常用方法
selenium·测试工具
l1t9 小时前
用Lua访问DuckDB数据库
数据库·junit·lua·duckdb
l1t18 小时前
Lua与LuaJIT的安装与使用
算法·junit·单元测试·lua·luajit
安冬的码畜日常18 小时前
【JUnit实战3_10】第六章:关于测试的质量(上)
测试工具·junit·单元测试·测试覆盖率·1024程序员节·junit5
千里镜宵烛18 小时前
Lua-迭代器
开发语言·junit·lua
安冬的码畜日常18 小时前
【JUnit实战3_11】第六章:关于测试的质量(下)
junit·单元测试·tdd·1024程序员节·bdd·变异测试
大汉堡玩测试20 小时前
使用kafka造测试数据进行测试
测试工具·kafka
沐雨风栉1 天前
告别设备限制!CodeServer+cpolar让VS Code随时随地在线编程
云原生·eureka·重构·pdf·开源
胜天半月子1 天前
性能测试 | 性能测试工具JMeter直连数据库和逻辑控制器的使用
数据库·测试工具·jmeter·性能测试