当前位置: 首页 > 工具软件 > TestableMock > 使用案例 >

单元测试框架TestableMock快速入门(一):快速Mock任意调用

段哲圣
2023-12-01

目录

一、TestableMock概述

二、​快速Mock被测类的任意方法调用​

三、覆写任意类的方法调用

四、覆写任意类的new操作

五、在Mock方法中区分调用来源

六、注意点


一、TestableMock概述

TestableMock是阿里云效团队开发的一款快速Mock的单元测试框架,旨在"让Java没有难测的代码"。

TestableMock现在已不仅是一款轻量易上手的单元测试Mock工具,更是以简化Java单元测试为目标的综合辅助工具集,包含以下功能:

  • 快速Mock任意调用:使被测类的任意方法调用快速替换为Mock方法,实现"指哪换哪",解决传统Mock工具使用繁琐的问题;
  • 访问被测类私有成员:使单元测试能直接调用和访问被测类的私有成员,解决私有成员初始化和私有方法测试的问题;
  • 快速构造参数对象:生成任意复杂嵌套的对象实例,并简化其内部成员赋值方式,解决被测方法参数初始化代码冗长的问题;
  • 辅助测试void方法:利用Mock校验器对方法的内部逻辑进行检查,解决无返回值方法难以实施单元测试的问题;

二、​快速Mock被测类的任意方法调用​

在单元测试中,Mock方法的主要作用是替代某些需要外部依赖、执行过程耗时、执行结果随机或其他影响测试正常开展,却并不影响关键待测逻辑的调用。通常来说,某个调用需要被Mock,往往只与其自身特征有关,而与调用的来源无关。

基于上述特点,TestableMock设计了一种极简的Mock机制。与以往Mock工具以作为Mock的定义粒度,在每个测试用例里各自重复描述Mock行为的方式不同,TestableMock让每个业务类(被测类)关联一组可复用的Mock方法集合(使用Mock容器类承载),并遵循约定优于配置的原则,按照规则自动在测试运行时替换被测类中的指定方法调用。

实际规则约定归纳起来只有两条:

  • Mock非构造方法,拷贝原方法定义到Mock容器类,加@MockMethod注解;
  • Mock构造方法,拷贝原方法定义到Mock容器类,返回值换成构造的类型,方法名随意,加@MockContructor注解;

具体使用方法如下。

【a】新建一个SpringBoot项目或者Maven项目,pom.xml加入Testable的依赖,以及Maven插件:

笔者选择使用0.6.8版本的testable

<properties>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <junit.version>5.6.2</junit.version>
        <testable.version>0.6.8</testable.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.testable</groupId>
            <artifactId>testable-all</artifactId>
            <version>${testable.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M5</version>
                <configuration>
                    <argLine>@{argLine} -javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar</argLine>
                </configuration>
            </plugin>
            <!--            <plugin>-->
            <!--                <groupId>com.alibaba.testable</groupId>-->
            <!--                <artifactId>testable-maven-plugin</artifactId>-->
            <!--                <version>${testable.version}</version>-->
            <!--                <executions>-->
            <!--                    <execution>-->
            <!--                        <id>prepare</id>-->
            <!--                        <goals>-->
            <!--                            <goal>prepare</goal>-->
            <!--                        </goals>-->
            <!--                    </execution>-->
            <!--                </executions>-->
            <!--            </plugin>-->
            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.6</version>
                <executions>
                    <execution>
                        <id>prepare-agent</id>
                        <goals>
                            <goal>prepare-agent</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>report</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>report</goal>
                        </goals>
                        <configuration>
                            <dataFile>target/jacoco.exec</dataFile>
                            <outputDirectory>target/jacoco-ut</outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

【b】新建一些实体类,后续使用

package com.wsh.testable.mock.testablemockdemo;

public interface Color {

    String getColor();

}
package com.wsh.testable.mock.testablemockdemo;

abstract public class Box {

    protected String data;

    abstract public void put(String something);

    public String get() {
        return data;
    }

}
package com.wsh.testable.mock.testablemockdemo;

public class BlackBox extends Box implements Color {

    public BlackBox(String data) {
        this.data = data;
    }

    public static BlackBox secretBox() {
        return new BlackBox("secret");
    }

    @Override
    public void put(String something) {
        data = something;
    }

    @Override
    public String getColor() {
        return "black";
    }

}

【c】编写被测试类

注意包名:com.wsh.testable.mock.testablemockdemo;

package com.wsh.testable.mock.testablemockdemo;

import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.stream.Collectors;

/**
 * TestableMock: 基本的Mock功能
 */
public class DemoMock {

    /**
     * method with new operation
     */
    public String newFunc() {
        BlackBox component = new BlackBox("something");
        return component.get();
    }

    /**
     * method with member method invoke
     */
    public String outerFunc(String s) throws Exception {
        return "{ \"res\": \"" + innerFunc(s) + staticFunc() + "\"}";
    }

    /**
     * method with common method invoke
     */
    public String commonFunc() {
        return "anything".trim() + "__" + "anything".substring(1, 2) + "__" + "abc".startsWith("ab");
    }

    /**
     * method with static method invoke
     */
    public BlackBox getBox() {
        return BlackBox.secretBox();
    }

    /**
     * two methods invoke same private method
     */
    public String callerOne() {
        return callFromDifferentMethod();
    }

    public String callerTwo() {
        return callFromDifferentMethod();
    }

    private static String staticFunc() {
        return "_STATIC_TAIL";
    }

    private String innerFunc(String s) throws Exception {
        return Files.readAllLines(Paths.get("/a-not-exist-file")).stream().collect(Collectors.joining());
    }

    private String callFromDifferentMethod() {
        return "realOne";
    }

}

【d】编写测试类

注意包名:com.wsh.testable.mock.testablemockdemo; 

package com.wsh.testable.mock.testablemockdemo;

import com.alibaba.testable.core.annotation.MockConstructor;
import com.alibaba.testable.core.annotation.MockMethod;
import org.junit.jupiter.api.Test;

import java.util.concurrent.Executors;

import static com.alibaba.testable.core.matcher.InvokeVerifier.verify;
import static com.alibaba.testable.core.tool.TestableTool.MOCK_CONTEXT;
import static com.alibaba.testable.core.tool.TestableTool.SOURCE_METHOD;
import static org.junit.jupiter.api.Assertions.assertEquals;

/**
 * 演示基本的Mock功能
 */
class DemoMockTest {

    private DemoMock demoMock = new DemoMock();

    /**
     * 承载Mock方法的容器,用于存放所有Mock方法,最简单的做法是在测试类里添加一个名称为Mock的静态内部类
     */
    public static class Mock {
        @MockConstructor
        private BlackBox createBlackBox(String text) {
            return new BlackBox("mock_" + text);
        }

        @MockMethod(targetClass = DemoMock.class)
        private String innerFunc(String text) {
            return "mock_" + text;
        }

        @MockMethod(targetClass = DemoMock.class)
        private String staticFunc() {
            return "_MOCK_TAIL";
        }

        @MockMethod(targetClass = String.class)
        private String trim() {
            return "trim_string";
        }

        @MockMethod(targetClass = String.class, targetMethod = "substring")
        private String sub(int i, int j) {
            return "sub_string";
        }

        @MockMethod(targetClass = String.class)
        private boolean startsWith(String s) {
            return false;
        }

        @MockMethod(targetClass = BlackBox.class)
        private BlackBox secretBox() {
            return new BlackBox("not_secret_box");
        }

        @MockMethod(targetClass = DemoMock.class)
        private String callFromDifferentMethod() {
            if ("special_case".equals(MOCK_CONTEXT.get("case"))) {
                return "mock_special";
            }
            //在Mock方法中通过TestableTool.SOURCE_METHOD变量可以识别进入该Mock方法前的被测类方法名称
            switch (SOURCE_METHOD) {
                case "callerOne":
                    return "mock_one";
                default:
                    return "mock_others";
            }
        }
    }

    /**
     * 1. Mock构造方法
     * <p>
     * 解析:
     * a.Mock容器类中使用@MockConstructor注解Mock了BlackBox构造方法,
     * b.然后newFunc()方法在被测试类()中实现为:new BlackBox("something");
     * c.故这里demoMock.newFunc(),实际上调用的构造方法是Mock容器类里面的Mock的createBlackBox(String text)方法
     */
    @Test
    void should_mock_new_object() {
        assertEquals("mock_something", demoMock.newFunc());
        //检查在执行被测方法newFunc()时,名称是createBlackBox的Mock方法是否有被调用过,且调用时收到的参数值是否为"something"
        verify("createBlackBox").with("something");
    }

    /**
     * 2. Mock成员方法
     * 解析:
     * a.demoMock.outerFunc("hello")方法在被测类DemoMock中分别调用了innerFunc(s) + staticFunc()两个方法
     * b.在Mock容器类中分别Mock了innerFunc(s) + staticFunc()这两个方法;
     * c.所以在真正运行单元测试的时候,实际上调用的是Mock里面innerFunc(String text)和staticFunc()方法
     */
    @Test
    void should_mock_member_method() throws Exception {
        assertEquals("{ \"res\": \"mock_hello_MOCK_TAIL\"}", demoMock.outerFunc("hello"));
        //检查在执行被测方法outerFunc()时,名称是innerFunc的Mock方法是否有被调用过,且调用时收到的参数值是否为"hello"
        verify("innerFunc").with("hello");
        //检查在执行被测方法outerFunc()时,名称是staticFunc的Mock方法是否有被调用过
        verify("staticFunc").with();
    }

    /**
     * 3. Mock公共方法
     * 解析:
     * a.demoMock.commonFunc()方法在被测类DemoMock中分别调用了trim()、substring(1, 2)、startsWith("ab")三个方法
     * b.在Mock容器类中也分别Mock了String类的trim()、substring(1, 2)、startsWith("ab")三个方法
     * c.所以在真正运行单元测试的时候,实际上调用的是Mock里面trim()、sub(int i, int j)、startsWith(String s)方法
     */
    @Test
    void should_mock_common_method() {
        assertEquals("trim_string__sub_string__false", demoMock.commonFunc());
        //检查在执行被测方法commonFunc()时,名称是trim的Mock方法是否有被调用过一次,并忽略对调用参数的检查
        verify("trim").withTimes(1);
        //检查在执行被测方法commonFunc()时,名称是sub的Mock方法是否有被调用过一次,并忽略对调用参数的检查
        verify("sub").withTimes(1);
        //检查在执行被测方法commonFunc()时,名称是startsWith的Mock方法是否有被调用过一次,并忽略对调用参数的检查
        verify("startsWith").withTimes(1);
    }

    /**
     * 4. Mock静态方法
     * 解析:
     * a. demoMock.getBox()原来的实现是调用了BlackBox.secretBox();
     * b. 在Mock容器类中Mock了BlackBox类的secretBox()方法
     * c. 所以运行时真正调用的是Mock容器类里面的Mock#secretBox()方法
     */
    @Test
    void should_mock_static_method() {
        assertEquals("not_secret_box", demoMock.getBox().get());
        //检查在执行被测方法getBox()时,名称是secretBox的Mock方法是否有被调用过一次,并忽略对调用参数的检查
        verify("secretBox").withTimes(1);
    }

    /**
     * 5.SOURCE_METHOD:识别进入Mock方法前的被测类方法名称
     * 解析:
     * a. demoMock.callerOne()/demoMock.callerTwo()在被测试类中都调用了callFromDifferentMethod()方法
     * b. 在测试类Mock容器中Mock了callFromDifferentMethod()方法,并通过TestableTool.SOURCE_METHOD识别了进入Mock方法前的被测类方法名称
     */
    @Test
    void should_get_source_method_name() throws Exception {
        // synchronous同步调用
        assertEquals("mock_one_mock_others", demoMock.callerOne() + "_" + demoMock.callerTwo());
        // asynchronous异步调用
        assertEquals("mock_one_mock_others",
                Executors.newSingleThreadExecutor().submit(
                        () -> demoMock.callerOne() + "_" + demoMock.callerTwo()).get()
        );
        //检查在执行被测方法callerOne()/callerTwo()时,名称是callFromDifferentMethod的Mock方法是否有被调用过四次,并忽略对调用参数的检查
        verify("callFromDifferentMethod").withTimes(4);
    }

    /**
     * 6. MOCK_CONTEXT:Mock额外的上下文参数
     * 解析:
     * a. demoMock.callerOne()在被测试类中调用了callFromDifferentMethod()方法
     * b. 在Mock容器中Mock了callFromDifferentMethod()方法,通过MOCK_CONTEXT标注了上下文参数为”special_case“
     * c. Mock#callFromDifferentMethod()方法中,如果是”special_case“,直接返回"mock_special"
     */
    @Test
    void should_set_mock_context() throws Exception {
        MOCK_CONTEXT.put("case", "special_case");
        // synchronous
        assertEquals("mock_special", demoMock.callerOne());
        // asynchronous
        assertEquals("mock_special", Executors.newSingleThreadExecutor().submit(
                () -> demoMock.callerOne()).get()
        );
        //检查在执行被测方法callerOne()时,名称是callFromDifferentMethod的Mock方法是否有被调用过二次,并忽略对调用参数的检查
        verify("callFromDifferentMethod").withTimes(2);
    }

}

如上我们可以看到,我们为测试类添加一个关联的Mock类型,作为承载其Mock方法的容器,做法是在测试类里添加一个名称为Mock的静态内部类。并且我们使用@MockMethod注解定义了一些Mock方法,在每个单元测试中我都写了比较详细的解析步骤,应该还是比较容易理解的。

注意被测试类和测试类的包名尽量保持一致,Testable框架默认的规则是需要保持一致的,当然如果真不一致,可以参照官网进行一些调整也可以扫描出,日常开发没什么特殊情况,我们保持一致即可。

三、覆写任意类的方法调用

在Mock容器类中定义一个有@MockMethod注解的普通方法,使它与需覆写的方法名称、参数、返回值类型完全一致,并在注解的targetClass参数指定该方法原本所属对象类型。

此时被测类中所有对该需覆写方法的调用,将在单元测试运行时,将自动被替换为对上述自定义Mock方法的调用。

例如,被测类中有一处"something".substring(0, 4)调用,我们希望在运行测试的时候将它换成一个固定字符串,则只需在Mock容器类定义如下方法:

// 原方法签名为`String substring(int, int)`
// 调用此方法的对象`"something"`类型为`String`
@MockMethod(targetClass = String.class)
private String substring(int i, int j) {
    return "sub_string";
}

当遇到待覆写方法有重名时,可以将需覆写的方法名写到@MockMethod注解的targetMethod参数里,这样Mock方法自身就可以随意命名了。如下:

// 使用`targetMethod`指定需Mock的方法名
// 此方法本身现在可以随意命名,但方法参数依然需要遵循相同的匹配规则
@MockMethod(targetClass = String.class, targetMethod = "substring")
private String use_any_mock_method_name(int i, int j) {
    return "sub_string";
}

有时,在Mock方法里会需要访问发起调用的原始对象中的成员变量,或是调用原始对象的其他方法。此时,可以将@MockMethod注解中的targetClass参数去除,然后在方法参数列表首位增加一个类型为该方法原本所属对象类型的参数

TestableMock约定,当@MockMethod注解的targetClass参数未定义时,Mock方法的首位参数即为目标方法所属类型,参数名称随意。通常为了便于代码阅读,建议将此参数统一命名为selfsrc。举例如下:

// Mock方法在参数列表首位增加一个类型为`String`的参数(名字随意)
// 此参数可用于获得当时的实际调用者的值和上下文
@MockMethod
private String substring(String self, int i, int j) {
    // 可以直接调用原方法,此时Mock方法仅用于记录调用,常见于对void方法的测试
    return self.substring(i, j);
}

Mock静态方法、成员方法的规则大体与上述类似,只是targetClass参数指定为需要重写的类类型。

四、覆写任意类的new操作

在Mock容器类里定义一个返回值类型为要被创建的对象类型,且方法参数与要Mock的构造函数参数完全一致的方法,名称随意,然后加上@MockContructor注解。

此时被测类中所有用new创建指定类的操作(并使用了与Mock方法参数一致的构造函数)将被替换为对该自定义方法的调用。

例如,在被测类中有一处new BlackBox("something")调用,希望在测试时将它换掉(通常是换成Mock对象,或换成使用测试参数创建的临时对象),则只需定义如下Mock方法:

// 要覆写的构造函数签名为`BlackBox(String)`
// Mock方法返回`BlackBox`类型对象,方法的名称随意起
@MockContructor
private BlackBox createBlackBox(String text) {
    return new BlackBox("mock_" + text);
}

五、在Mock方法中区分调用来源

在Mock方法中通过TestableTool.SOURCE_METHOD变量可以识别进入该Mock方法前的被测类方法名称;此外,还可以借助TestableTool.MOCK_CONTEXT变量为Mock方法注入“额外的上下文参数”,从而区分处理不同的调用场景。

例如,在测试用例中验证当被Mock方法返回不同结果时,对被测目标方法的影响:

@Test
public void testDemo() {
    MOCK_CONTEXT.put("case", "data-ready");
    assertEquals(true, demo());
    MOCK_CONTEXT.put("case", "has-error");
    assertEquals(false, demo());
}

在Mock方法中取出注入的参数,根据情况返回不同结果:

@MockMethod
private Data mockDemo() {
    switch((String)MOCK_CONTEXT.get("case")) {
        case "data-ready":
            return new Data();
        case "has-error":
            throw new NetworkException();
        default:
            return null;
    }
}

六、注意点

Mock只对被测类的代码有效

TestableMockIssues列表中,最常见的一类问题是“Mock为什么没生效”,其中最多的一种情况是“在测试用例里直接调用了Mock的方法,发现没有替换”。这是因为Mock替换只会作用在被测类的代码里

测试类和Mock容器的命名约定

默认情况下,TestableMock假设测试类与被测类的包路径相同,且名称为被测类名+Test(通常采用MavenGradle构建的Java项目均符合这种惯例)。 同时约定测试类关联的Mock容器为在其内部且名为Mock的静态类,或相同包路径下名为被测类名+Mock的独立类

当测试类或Mock容器路径不符合此约定时,可使用@MockWith注解显式指定,详见使用MockWith注解

本文大部分内容来源于TestableMock官方文档学习总结,感兴趣的小伙伴可以前往TestableMock官网学习更多功能的使用:TestableMock 

 类似资料: