跳至主要內容

testable-mock

soulballad分布式其他testable-mock约 14843 字大约 49 分钟

testable-mock

码云文档: TestableMock (gitee.io)open in new window

GitHub文档: TestableMock (alibaba.github.io)open in new window

开源地址: GitHub - alibaba/testable-mock: 换种思路写Mock,让单元测试更简单open in new window

1. 快速上手

使用TestableMock


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

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

在Maven项目中使用

在项目pom.xml文件中,增加testable-all依赖和maven-surefire-plugin配置,具体方法如下。

建议先添加一个标识TestableMock版本的property,便于统一管理:

<properties>
    <testable.version>0.7.8</testable.version>
</properties>

dependencies列表添加TestableMock依赖:

<dependencies>
    <dependency>
        <groupId>com.alibaba.testable</groupId>
        <artifactId>testable-all</artifactId>
        <version>${testable.version}</version>
        <scope>test</scope>
    </dependency>
</dependencies>

最后在build区域的plugins列表里添加maven-surefire-plugin插件(如果已包含此插件则只需添加<argLine>部分配置):

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <configuration>
                <argLine>-javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar</argLine>
            </configuration>
        </plugin>
    </plugins>
</build>

若项目同时还使用了Jacocoon-the-fly模式(默认模式)统计单元测试覆盖率,则需在<argLine>配置中添加一个@{argLine}参数,添加后的配置如下:

<argLine>@{argLine} -javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar</argLine>

参见项目java-demopom.xmlopen in new windowkotlin-demopom.xmlopen in new window文件。

在Gradle项目中使用

build.gradle文件中添加TestableMock依赖:

dependencies {
    testImplementation('com.alibaba.testable:testable-all:0.7.8')
    testAnnotationProcessor('com.alibaba.testable:testable-processor:0.7.8')
}

然后在测试配置中添加javaagent:

test {
    jvmArgs "-javaagent:${classpath.find { it.name.contains("testable-agent") }.absolutePath}"
}

参见项目java-demobuild.gradleopen in new windowkotlin-demobuild.gradle.ktsopen in new window文件。

若用于Android项目,则添加TestableMock依赖方法同上,添加javaagent配置方法如下:

android {
    testOptions {
        unitTests {
            all {
                jvmArgs "-javaagent:${classpath.find { it.name.contains("testable-agent") }.absolutePath}"
            }
        }
    }
}

完整代码可参考demo/android-demo示例项目。

若项目使用Spock测试框架,需指定Groovy编译生成的JVM 1.6或以上版本字节码,方法如下(请根据实际使用的JVM版本修改属性值)。

Maven项目在pom.xml中添加<maven.compiler.source><maven.compiler.target>属性,例如:

<properties>
  <!-- 或 1.7/1.8/... -->
  <maven.compiler.source>1.6</maven.compiler.source>
  <maven.compiler.target>1.6</maven.compiler.target>
</properties>

Gradle项目在build.gradle中添加sourceCompatibility属性,例如:

sourceCompatibility = '6'  // 或7/8/9/...

注意:由于Spock采用基于字符串解析的特殊执行方式,TestableTool工具类在Spock框架中无法使用。

完整代码可参考demo/spock-demo示例项目。

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


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

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

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

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

具体使用方法如下。

0. 前置步骤,准备Mock容器

首先为测试类添加一个关联的Mock类型,作为承载其Mock方法的容器,最简单的做法是在测试类里添加一个名称为Mock的静态内部类。例如:

public class DemoTest {

    public static class Mock {
        // 放置Mock方法的地方
    }

}

1.1 覆写任意类的方法调用

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

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

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

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

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

下面这个例子展示了targetMethod参数的用法,其效果与上述示例相同:

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

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

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

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

完整代码示例见java-demokotlin-demo示例项目中的should_mock_common_method()测试用例。(由于Kotlin对String类型进行了魔改,故Kotlin示例中将被测方法在BlackBox类里加了一层封装)

1.2 覆写被测类自身的成员方法

有时候,在对某些方法进行测试时,希望将被测类自身的另外一些成员方法Mock掉(比如这个方法里有许多外部依赖或耗时操作)。

做法与前一种情况完全相同,只需将targetClass参数赋值为被测类,即可实现对被测类自身(不论是公有或私有)成员方法的覆写。

例如,被测类中有一个签名为String innerFunc(String)的私有方法,我们希望在测试的时候将它替换掉,则只需在Mock容器类定义如下方法:

// 被测类型是`DemoMock`
@MockInvoke(targetClass = DemoMock.class)
private String innerFunc(String text) {
    return "mock_" + text;
}

同样的,上述示例中的方法如需访问发起调用的原始被测对象,也可不使用targetClass参数,而是在定义Mock方法时,在方法参数列表首位加一个类型为DemoMock的参数(名字随意)。

完整代码示例见java-demokotlin-demo示例项目中的should_mock_member_method()测试用例。

1.3 覆写任意类的静态方法

对于静态方法的Mock与普通方法相同。

例如,在被测类中调用了BlackBox类型中的静态方法secretBox(),该方法签名为BlackBox secretBox(),则Mock方法如下:

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

对于静态方法的Mock,通常不使用方法参数列表的首位加参数来表示目标类型。但这种方法也依然适用,只是实际传入的第一个参数值将始终是null

完整代码示例见java-demokotlin-demo示例项目中的should_mock_static_method()测试用例。

1.4 覆写任意类的new操作

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

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

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

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

完整代码示例见java-demokotlin-demo示例项目中的should_mock_new_object()测试用例。

2. 在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方法中取出注入的参数,根据情况返回不同结果:

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

完整代码示例见java-demokotlin-demo示例项目中的should_get_source_method_name()should_get_test_case_name()测试用例。

3. 验证Mock方法被调用的顺序和参数

在测试用例中可用通过InvocationVerifier.verifyInvoked()方法,配合with()withInOrder()without()withTimes()等方法实现对Mock调用情况的验证。

详见校验Mock调用文档。

4. 特别说明

Mock只对被测类的代码有效

TestableMockIssuesopen in new window列表中,最常见的一类问题是“Mock为什么没生效”,其中最多的一种情况是“在测试用例里直接调用了Mock的方法,发现没有替换”。这是因为Mock替换只会作用在被测类的代码里。知道大家是想快速验证一下TestableMock的功能(这心意我们领了👻),不过测试用例的代码真的无需被Mock哦(~ ̄▽ ̄)~。

除去这种情况,若Mock未生效,请参考自助问题排查提供的方法对比Mock方法签名目标位置的调用方法签名。若依然无法定位原因,欢迎提交Issues告诉我们。

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

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

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

关于TestableMock的更多实现细节可参考设计和原理文档。

访问私有成员字段和方法


如今关于私有方法是否应该做单元测试的争论正逐渐消停,开发者的普遍实践已经给出事实答案。通过公有方法间接测私有方法在很多情况下难以进行,开发者们更愿意通过修改方法可见性的办法来让原本私有的方法在测试用例中变得可测。

此外,在单元测试中时常会需要对被测对象进行特定的成员字段初始化,但有时由于被测类的构造方法限制,使得无法便捷的对这些字段进行赋值。那么,能否在不破坏被测类型封装的情况下,允许单元测试用例内的代码直接访问被测类的私有方法和成员字段呢?TestableMock提供了两种简单的解决方案。

1.1 使用PrivateAccessor工具类

第一种方法是借助PrivateAccessor工具类来直接访问私有成员。这个类提供7个静态方法:

  • PrivateAccessor.get(任意对象, "私有字段名") ➜ 读取任意类的私有字段
  • PrivateAccessor.set(任意对象, "私有字段名", 新的值) ➜ 修改任意类的私有字段(或常量字段)
  • PrivateAccessor.invoke(任意对象, "私有方法名", 调用参数...) ➜ 调用任意类的私有方法
  • PrivateAccessor.getStatic(任意类型, "私有静态字段名") ➜ 读取任意类的静态私有字段
  • PrivateAccessor.setStatic(任意类型, "私有静态字段名", 新的值) ➜ 修改任意类的静态私有字段(或静态常量字段)
  • PrivateAccessor.invokeStatic(任意类型, "私有静态方法名", 调用参数...) ➜ 调用任意类的静态私有方法
  • PrivateAccessor.construct(任意类型, 构造方法参数...) ➜ 调用任意类的私有构造方法

特别说明:默认情况下,setStatic()方法不支持修改static final修饰的成员变量。在Java中此类变量通常代表业务意义上的恒定常量值,不应当在单元测试中更改。
在特殊场景下,如确实需要修改static final成员,请开启配置项private.access.enhance.enable = true,详见全局运行参数文档。

详见java-demokotlin-demo示例项目DemoPrivateAccessorTest测试类中的用例。

1.2 防代码重构机制

本质上来说,PrivateAccessor只是JVM反射机制的“易用型”封装,因此会存在与JVM反射相同的“对代码重构不友好”问题。当被测类中的私有方法名称、参数进行了修改,由于IDE无法自动订正反射访问的代码,往往相关错误要在单元测试运行时才能被发现。

为此,TestableMockPrivateAccessor进行了增强,赋予其编译期私有成员校验能力。这项功能默认关闭,需要通过@EnablePrivateAccess注解开启。(实际上是通过该注解的verifyTargetOnCompile参数控制,由于此参数默认值为true,因此只需在被测类上添加该注解即可启用私有成员校验)

注意 1:当私有成员校验功能开启时,PrivateAccessor工具类将只能用于访问被测类的私有成员,从某种角度而言,这也有助于限制将PrivateAccessor工具类用于与当前测试无关的“越权”操作。

注意 2TestableMock默认约定测试类与被测类的包路径相同,且名称为被测类+Test。若测试类名称不符合此约定时,在使用@EnablePrivateAccess注解时,需用srcClass参数显式指明实际的被测类位置。

注意 3: 此机制目前只针对Java语言实现,对于Kotlin以及其他JVM方言均无效。

DemoPrivateAccessorTest示例代码稍加修改,添加@EnablePrivateAccess注解(注意此时测试类名不符合约定,需加srcClass参数):

@EnablePrivateAccess(srcClass = DemoPrivateAccess.class)  // <- 添加此行
class DemoPrivateAccessorTest() { ... }

然后将任意一处通过PrivateAccessor访问的目标名称改为实际不存在的成员名,再次编译时即可发现该行有编译错误,提示信息为访问目标不存在。

2.1 直接访问私有成员

由于IDE语法报错原因,此特性计划在未来版本中移除,建议采用PrivateAccessor方式

第二种方法,除了借助PrivateAccessor工具类以外,凡是使用了@EnablePrivateAccess注解的测试类还会被自动赋予以下“特殊能力”:

  • 调用被测类的私有方法(包括静态方法)
  • 读取被测类的私有字段(包括静态字段)
  • 修改被测类的私有字段(包括静态字段)
  • 修改被测类的常量字段(使用final修饰的字段,包括静态常量字段)

访问和修改私有、常量成员时,IDE可能会提示语法有误,但编译器将能够正常运行测试。(使用编译期代码增强,目前仅实现了Java语言的适配)

效果见java-demo示例项目DemoPrivateProcessorTest测试类中的用例。

快速构造复杂的参数对象


在单元测试中,测试数据的准备和构造是一件既必须又繁琐的任务,面向对象的层层封装,在测试时就成为了初始化对象状态的重重阻碍。尤其遇到类型结构嵌套复杂、没有合适的构造方法、需要使用私有内部类等等状况时,常规手段往往显得力不从心。

为此TestableMock提供了OmniConstructorOmniAccessor两个极简的工具类,从此让一切对象构造不再困难。

1. 一行代码构造任何对象

不论目标类型多么奇葩,呼唤OmniConstructor,马上递给您~ 万能的对象构造器OmniConstructor有两个静态方法:

  • newInstance(任意类型) ➜ 指定任意类型,返回一个该类型的对象
  • newArray(任意类型, 数组大小) ➜ 指定任意类型,返回一个该类型的数组

用法举例:

// 构造一个WhatEverClass类型的对象
WhatEverClass obj = OmniConstructor.newInstance(WhatEverClass.class);
// 构造一个WhatEverClass[]类型,容量为5的数组
WhatEverClass[] arr = OmniConstructor.newArray(WhatEverClass.class, 5);

不仅如此,OmniConstructor构造出的绝非是所有成员值为null的简单空对象,而是所有成员、以及所有成员的所有子成员,都已经依次递归初始化的"丰满"对象。相比使用new进行构造,OmniConstructor能够确保对象结构完整,避免测试数据部分初始化导致的NullPointerException问题。

// 使用构造函数创建对象
Parent parent = new Parent();
// 内部成员未初始化,直接访问报NullPointerException异常(❌)
parent.getChild().getGrandChild();

// 使用OmniConstructor创建对象
Parent parent = OmniConstructor.newInstance(Parent.class);
// 无需顾虑,安心访问任意子成员(✅)
parent.getChild().getGrandChild().getContent();

注意:基于轻量优先原则,默认模式下,OmniConstructor仅利用类型原有的构造方法来创建对象,对于绝大多数POJO和Model层对象而言这种模式已经能够满足需要。
但对于更复杂的情形,譬如遇到某些类型的构造方法会抛出异常或包含其他妨碍构造正常执行的语句时,对象构造可能会失败。
此时可通过Testable全局配置omni.constructor.enhance.enable = true启用OmniConstructor的字节码增强模式,在该模式下,任何Java类型皆可构造。

除了用于构造方法的入参,OmniConstructor也可以用于快速构造Mock方法的返回值,相比将null作为Mock方法的返回值,使用完全初始化的对象能够更好保障测试的可靠性。

java-demokotlin-demo示例项目的DemoOmniMethodsTest测试类中,详细展示了当目标类型有多层嵌套结构、构造方法无法正常使用,甚至没有公开的构造方法时,如何用OmniConstructor轻松创建所需对象。

2. 一行代码访问任意深度成员

对于测试数据而言,即使是结构复杂的参数对象,与特定测试用例有关的通常也只是其中的部分属性和状态,然而要为这些深藏在对象结构内部的字段赋值有时却并非易事。

做为PrivateAccessor功能的加加加强版,OmniAccessor的灵感来自于XML语言中的XPath节点选择器open in new window,它提供了getset两个主要的静态方法:

  • get(任意对象, "访问路径") ➜ 返回根据路径匹配搜索到的所有成员对象
  • set(任意对象, "访问路径", 新的值) ➜ 根据路径匹配为指定位置的对象赋值

还有一个用于精确路径匹配时直接获取唯一目标对象的getFirst()辅助方法,其作用等效于OmniAccessor.get(...).get(0)

  • getFirst(任意对象, "访问路径") ➜ 返回根据路径匹配搜索到的第一个成员对象

只需书写符合规则的访问路径,不论什么类型和深度的成员,都可以一键直达:

// 返回parent对象中,所有符合类型是GrandChild的子对象中叫做content的成员对象
OmniAccessor.get(parent, "{GrandChild}/content");
// 将parent对象中,符合名称为children的数组第3位的任意子成员的value字段赋值为100
OmniAccessor.set(parent, "children[2]/*/value", 100);

具体路径规则如下:

1. 匹配成员名

不带额外修饰的路径名将匹配与之同名的任意成员对象

  • child: 匹配任意名字为child的子孙成员
  • child/grandChild: 匹配名字为child的子孙成员里,名为grandChild的子成员

2. 匹配成员类型

使用花括号匹配类型名称,通常用于批量获取或赋值同类的多个成员对象

  • {Child}: 匹配所有类型是Child的子孙成员
  • {Children[]}: 匹配所有类型是Children数组的子孙成员
  • {Child}/{GrandChild}: 匹配所有类型是Child的子孙成员里,所有类型是GrandChild子成员

成员名和类型可以在路径上混用(暂不支持在同一级路径同时指定成员名称和类型)

  • child/{GrandChild}: 匹配名字为child的子孙成员里,所有类型是GrandChild的子成员
  • {Child}/grandChild/content: 匹配所有类型是Child的子孙成员里,名为grandChild子成员里的,名为content的子成员

3. 使用下标访问数组成员

使用带数值的方括号表示匹配该位置为数组类型,且取指定下标的对象(不带下标时,当匹配对象为数组类型,默认匹配数组中的所有对象)

  • children[1]/content: 匹配名称为children的数组类型子孙成员,取其中第2个对象中名为content的子成员
  • parent/children[1]: 匹配名称为parent的子孙成员里,名为children的数组类型子成员,取其中第2个对象

4. 使用通配符

通配符可以用于成员名或类型名的匹配

  • child*: 匹配名称以child开头的所有子孙成员
  • {*Child}: 匹配类型以Child结尾的所有子孙成员
  • c*ld/{Grand*ld}: 匹配名称以c开头ld结尾的子孙成员里,类型以Grand开头ld结尾的成员
  • child/*/content: 此时*将匹配任意成员,即child对象任意子成员中,包含的content子成员

详见java-demokotlin-demo示例项目DemoOmniMethodsTest测试类中的用例。

3. 特别说明

你真的需要用到OmniAccessor吗?

OmniAccessor具有基于Fail-Fast机制的防代码重构能力,当用户提供的访问路径无法匹配到任何成员时,OmniAccessor将立即抛出NoSuchMemberError错误,使单元测试提前终止。然而相比常规的成员访问方式,OmniAccessor在IDE重构方面的支持依然偏弱。

对于复杂对象的内容赋值,大多数情况下,我们更推荐使用构造者模式open in new window,或者暴露Getter/Setter方法实现。这些常规手段虽然稍显笨拙(尤其在需要为许多相似的成员批量赋值的时候),但对业务逻辑的封装和重构都更加友好。
仅当原类型不适合改造,且没有其它可访问目标成员的方法时,OmniAccessor才是最后的终极手段。

测试无返回值的方法


如何对void类型的方法进行测试一直是许多单元测试框架在悄悄回避的话题,由于以往的单元测试手段主要是对被测单元的返回结果进行校验,当遇到方法没有返回值时就会变得无从下手。

从功能的角度来说,虽然void方法不返回任何值,但它的执行一定会对外界产生某些潜在影响,我们将其称为方法的"副作用",比如:

  1. 初始化某些外部变量(私有成员变量或者全局静态变量)
  2. 在方法体内对外部对象实例进行赋值
  3. 输出了日志
  4. 调用了其他外部方法
  5. ... ...

不返回任何值也不产生任何"副作用"的方法没有存在的意义。

这些"副作用"的本质归纳来说可分为两类:修改外部变量调用外部方法

通过TestableMock的私有字段访问和Mock校验器可以很方便的实现对"副作用"的结果检查。

1. 修改外部变量的void方法

例如,下面这个方法会根据输入修改私有成员变量hashCache

class Demo {
    private Map<String, Integer> hashCache = mapOf();

    public void updateCache(String domain, String key) {
        String cacheKey = domain + "::" + key;
        Integer num = hashCache.get(cacheKey);
        hashCache.put(cacheKey, count == null ? initHash(key) : nextHash(num, key));
    }

    ... // 其他方法省略
}

若要测试此方法,可以利用TestableMock直接读取私有成员变量的值,对结果进行校验:

@EnablePrivateAccess  // 启用TestableMock的私有成员访问功能
class DemoTest {
    private Demo demo = new Demo();

    @Test
    public void testSaveToCache() {
        Integer firstVal = demo.initHash("hello"); // 访问私有方法
        Integer nextVal = demo.nextHash(firstVal, "hello"); // 访问私有方法
        demo.saveToCache("demo", "hello");
        assertEquals(firstVal, demo.hashCache.get("demo::hello")); // 读取私有变量
        demo.saveToCache("demo", "hello");
        assertEquals(nextVal, demo.hashCache.get("demo::hello")); // 读取私有变量
    }
}

2. 调用外部方法的void方法

例如,下面这个方法会根据输入打印信息到控制台:

class Demo {
    public void recordAction(Action action) {
        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss ");
        String timeStamp = df.format(new Date());
        System.out.println(timeStamp + "[" + action.getType() + "] " + action.getTarget());
    }
}

若要测试此方法,可以利用TestableMock快速Mock掉System.out.println方法。在Mock方法体里可以继续执行原调用(相当于并不影响本来方法功能,仅用于做调用记录),也可以直接留空(相当于去除了原方法的副作用)。

在执行完被测的void类型方法以后,用InvocationVerifier.verifyInvoked()校验传入的打印内容是否符合预期:

class DemoTest {
    private Demo demo = new Demo();

    public static class Mock {
        // 拦截System.out.println调用
        @MockInvoke
        public void println(PrintStream ps, String msg) {
            // 执行原调用
            ps.println(msg);
        }
    }

    @Test
    public void testRecordAction() {
        Action action = new Action("click", ":download");
        demo.recordAction();
        // 验证Mock方法println被调用,且传入参数格式符合预期
        verifyInvoked("println").with(matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} \\[click\\] :download"));
    }
}

2. 使用指南

校验Mock调用


在测试中,除了需要将某些含有外部依赖的方法替换为Mock,经常还会需要验证该方法被调用时的参数是否符合预期。

TestableMock中提供了校验器(verifier)和匹配器(matcher)来实现这一功能。譬如:

@Test
public test_case() {
    int res = insToTest.methodToTest();
    verifyInvoked("mockMethod").with(123, "abc");
}

这个用例会检查在执行被测方法methodToTest()时,名称是mockMethod的Mock方法是否有被调用过,且调用时收到的参数值是否为123"abc"(假设被Mock的mockMethod方法有两个参数)。

除了这种简单校验以外,TestableMock当前已经支持了多种校验器,以及能够模糊匹配参数特征的匹配器

在示例项目java-demokotlin-demo中的DemoMatcherTest测试类详细展示了这些校验器和匹配器的用法。

基本校验器

  • with(Object... args) → 验证方法是否被指定参数调用过
  • withInOrder(Object... args) → 如果指定方法被调用了多次,依据实际调用顺序依次匹配
  • withTimes(int expectedCount) → 验证方法是否被调用过指定次数,忽略对调用参数的检查
  • without(Object... args) → 验证方法从未被使用指定参数调用过
  • times(int count) → 连在with()withInOrder()方法之后使用,验证该方法被同样条件的参数调用过了指定次数

基本匹配器

  • any() → 匹配任何值,包括Null
  • any(Class<?> clazz) → 匹配任何指定类型或子类型的值
  • anyTypeOf(Class<?>... classes) → 匹配在列表中的任意一种类型的值
  • anyString() → 匹配任何字符串
  • anyNumber() → 匹配任何数值(整数或浮点数)
  • anyBoolean() → 匹配任何布尔值
  • anyByte() → 匹配任何单字节类型的值
  • anyChar() → 匹配任何单字符类型的值
  • anyInt() → 匹配任何整数类型的值
  • anyLong() → 匹配任何长整数类型的值
  • anyFloat() → 匹配任何浮点数类型的值
  • anyDouble() → 匹配任何双精度浮点数类型的值
  • anyShort() → 匹配任何短整数类型的值
  • anyArray() → 匹配任何数组
  • anyArrayOf(Class<?> clazz) → 匹配任何指定类型的数组
  • anyList() → 匹配任何列表
  • anyListOf(Class<?> clazz) → 匹配任何指定类型的列表
  • anySet() → 匹配任何集合
  • anySetOf(Class<?> clazz) → 匹配任何指定类型的集合
  • anyMap() → 匹配任何映射
  • anyMapOf(Class<?> keyClass, Class<?> valueClass) → 匹配任何指定类型的映射
  • anyCollection() → 匹配任何容器
  • anyCollectionOf(Class<?> clazz) → 匹配任何指定类型的容器
  • anyIterable() → 匹配任何迭代器
  • anyIterableOf(Class<?> clazz) → 匹配任何指定类型的迭代器
  • eq(Object obj) → 匹配与指定值相等的对象
  • refEq(Object obj) → 匹配指定对象(非值相等,而是就是同一个对象)

空值匹配器

  • isNull() → 匹配Null
  • notNull() → 匹配除Null以外的任何值
  • nullable(Class<?> clazz) → 匹配空或指定类型的任何值

字符串匹配器

  • contains(String substring) → 匹配包含特定子串的字符串
  • matches(String regex) → 匹配符合指定正则表达式的字符串
  • endsWith(String suffix) → 匹配以指定子串结尾的字符串
  • startsWith(String prefix) → 匹配以指定子串开头的字符串

万能匹配器

  • any(MatchFunction matcher) → 匹配符合指定表达式的值

Mock的生效范围


@MockInvoke@MockNew注解上都有一个scope参数,其可选值有两种

  • MockScope.GLOBAL:该Mock方法将全局生效
  • MockScope.ASSOCIATED:该Mock方法仅对Mock容器关联测试类中的测试用例生效

对于常规项目而言,单元测试里需要被Mock的调用都是由于其中包含了不需要或不便于测试的逻辑,譬如“依赖外部系统”、“包含随机结果”、“执行非常耗时”等等,这类调用在整个单元测试的生命周期里都应该被Mock方法置换,不论调用的发起者是谁。因此TestableMock默认所有Mock方法都是全局生效的,即scope默认值为MockScope.GLOBAL

举例来说,CookerServiceSellerService是两个需要被测试的类,假设CookerService的代码里的hireXxx()cookXxx()方法都需要依赖外部系统。因此在进行单元测试时,开发者在CookerService关联的Mock容器里使用@MockInvoke注解定义了这些调用的替代方法。

此时,若该Mock方法的scope值为MockScope.GLOBAL,则不论是在SellerServiceTest测试类还是在CookerServiceTest测试类的测试用例,只要直接或间接的执行到这行调用,都会被置换为调用Mock方法。若该Mock方法的scope值为MockScope.ASSOCIATED,则Mock只对CookerServiceTest类中的测试用例生效,而SellerServiceTest类中的测试用例在运行过程中执行到了CookerService类的相关代码,将会执行原本的调用。

参见Java和Kotlin示例中SellerServiceTest测试类的用例。

在一些大型项目中,会有“下层模块编写单元测试,上层模块编写端到端集成测试,两者混合在一起运行”的情况,这时候大部分Mock方法都应该使用MockScope.ASSOCIATED作为生效范围。针对这种情况,TestableMock支持通过mock.scope.default运行参数来修改默认的Mock方法生效范围,详见全局运行参数文档。

特别说明。若要Mock静态块里的调用,Mock方法的scope必须为MockScope.GLOBAL,因为静态块中的代码在程序初始化时就会执行,不属于任何测试用例。典型场景是在使用JNI开发的项目中Mock系统库的加载方法。

static {
    System.loadLibrary("native-lib");
}

若默认的scope参数不是MockScope.GLOBAL,则相应Mock方法应当显式的声明scope值,例如:

@MockInvoke(targetClass = System.class, scope = MockScope.GLOBAL)
private void loadLibrary(String libname) {
    System.err.println("loadLibrary " + libname);
}

复用Mock类与方法


“Don't Repeat Yourself”是软件开发过程当中的一项重要原则(即“DRY原则”),在编写测试代码时,有些通用的基础功能调用语句常常出现在许多相似的业务类里,若测试中需要Mock这些调用,就要在各个测试类中重复提供同样的Mock方法。而通过Mock方法的复用机制,能够很好的避免编写臃肿重复Mock代码的麻烦。

TestableMock支持两种粒度的Mock复用方式:复用Mock类复用Mock方法

复用Mock类

如果有两个或以上测试类需要Mock的方法近乎相同,那么采用类级别的Mock复用就是最省心的一种方式。

进行类级别的Mock复用,只需将Mock容器定义为独立的类,然后在要使用它的测试类上通过@MockWith进行引用。例如:

@MockWith(ServiceMock.class)
public class AaaServiceTest {
    ...
}

@MockWith(ServiceMock.class)
public class BbbServiceTest {
    ...
}

public class ServiceMock {
    ...
}

这样在AaaServiceTestBbbServiceTest类中的测试用例在执行时,都会用ServiceMock容器类中定义的Mock方法进行调用匹配和Mock替换。

复用Mock方法

实际场景中,相比一次性复用整个Mock类的情况,更常见的是对部分高频Mock方法进行复用。

Mock方法的复用可以通过Mock容器类的继承来实现,父类中定义的所有Mock方法都会在子类中自然存在,例如:

public class AaaServiceTest {
    public static class Mock extends BasicMock {
        ...
    }
    ...
}

public class BbbServiceTest {
    public static class Mock extends BasicMock {
        ...
    }
    ...
}

public class BasicMock {
    @MockInvoke(targetClass = UserDao.class)
    protected String getById(int id) {
        ...
    }
}

则名为getById的Mock方法在AaaServiceTestBbbServiceTest的测试用例执行时都会生效。

使用MockWith注解


@MockWith注释的功能是为测试显式指定Mock容器,通常用于测试类或Mock容器类没有在标准约定位置的情况,以下列举几种典型使用场景。

1. 非标准位置的Mock容器类

TestableMock会依次在以下两个位置寻找Mock容器:

  • 默认位置测试类中名为Mock的静态内部类(譬如原类型是Demo,Mock容器类为DemoTest.Mock
  • 同包路径下名为被测类+Mock的独立类(譬如原类型是Demo,Mock容器类为DemoMock

倘若实际要使用的Mock容器类不在这两个位置,就需要在测试类上使用@MockWith注释了。一般来说,造成Mock容器类不在默认位置的原因可能有两种:复用Mock容器、集中管理Mock容器。

当对一批功能近似的类型进行测试的时候,由于需要进行Mock的外部调用基本一致,可以将这些类型所需的所有Mock方法集中写在一个Mock容器类里,然后让相关测试类共同引用这个公共Mock容器。详见复用Mock类与方法文档。

另一种情况是开发者希望将Mock方法的定义与测试类本身分开,以便进行集中管理(或规避某些扫描工具的路径规则),譬如形成下面这种目录结构:

src/
  main/
    com/
      demo/
        service/
          DemoService.java
  test/
    com/
      demo/
        service/
          DemoServiceTest.java
      mock/
        service/
          DemoServiceMock.java

此时需要在测试类上显式的指定相应的Mock容器类,不过这种情况在实际中并不太常见。

2. 非标准位置的测试类

TestableMock的原理来说,测试类的位置其实只是作为“被测类”与“Mock容器类”之间建立关联的参照物。当测试类的位置不是默认约定的被测类+Test时,上述首选的Mock容器位置就不成立了。但此时次选Mock位置依然可用,即如果Mock容器类的位置是被测类+Mock,那么Mock置换就依然能够正常进行。

但此时测试类与Mock容器之间的关联丢失了,因此需要为测试类使用@MockWith注解来显式的建立关联。例如:

public class DemoService {       // 被测类
    ...
}

public class DemoServiceMock {   // Mock容器类
    ...
}

@MockWith(DemoServiceMock.class) // 测试类由于丢失与Mock容器的关联,需要@MockWith注解
public class ServiceTest {
    ...
}

另一种相对少见的情况是Mock容器采用测试类的静态内部类,但测试类由于某些原因无法置于约定位置(或无法遵循约定命名)。此时测试类与Mock类的关联能够自动建立,但被测类无法找到自己的Mock容器,因此需要在被测类上添加@MockWith注解来显式的建立关联。例如:

@MockWith(ServiceTest.Mock.class)
public class DemoService {
    ...
}

public class ServiceTest {
    public static class Mock extends BasicMock {
        ...
    }
}

对于更特殊的一种情况,即测试类与Mock容器类均不在约定位置的时候,则需要同时在测试类被测类上都使用@MockWith指向同一个Mock容器类来建立三者的关联。复杂的关联对代码阅读会造成一定不便,在实际运用中应当尽量避免这种情况发生。

特别说明:@MockWith默认使用被注解类名字的尾缀判断当前类是被测类(名字非Test结尾)还是测试类(名字Test结尾),若遇到不符合此规则的类型,应使用注解的treatAs参数显式的指定(ClassType.SourceClass-被测类/ClassType.TestClass-测试类)

Q:为何当测试类在非约定位置时,是在被测类上使用@MockWith,而不在测试类上指定被测类?

A:从原则上来说,凡是能只改测试类就实现的,肯定不应该为了测试而去动业务代码(被测类)。
然而由于JavaAgent只能在类首次加载进内存的时候进行字节码处理,实际情况无法保证被测的类一定在测试类之后加载(可能在其他测试用例执行的时候就被提前加载进内存了),等读取到测试类上的信息时,可能已经无法对被测类进行Mock处理。因此对于测试类和被测类相互不知道对方位置的情况,采用了两边都用@MockWith指定Mock容器类的折中设计。

3. 在一个测试类中测试多个被测类

这是非标准位置测试类的一种特殊情况,当一个测试类里同时测试了多个业务类(被测类),其名称要么只能与其中某个被测类有+Test的命名符合,要么不与其中任何一个被测类有命名相关性。

假设所有被测类的Mock容器均采用被测类+Mock约定命名(否则参考前一条规则,被测类也需要显式加@MockWith)。若该测试类本身命名不符合其中任何一个被测类+Test约定的情况,需要为该测试类加一个无参数的@MockWith注解(即使用默认值,相当于@MockWith(NullType.class)),用于标识此类需参与TestableMock的预处理。

完整代码示例见java-demokotlin-demo示例项目中OneToMultiSvcTest测试类的用例。

由于测试类无法通过@MockWith与多个Mock容器关联,目前这种用法仅支持生效范围为MockScope.GLOBAL的Mock方法。

使用包路径映射


虽然在规范的单元测试中,测试编写者应当关注于被测类自身的业务逻辑,将无关外部调用按需替换为Mock,然而现实中的单元测试通常总是会调用到一部分外部类的逻辑。

TestableMock基于"让每个业务类提供自己的Mock方法集合"的设计原则,默认约定包含Mock方法集合的容器类型应与业务类具有相同的包路径,否则需显式的使用MockWith注解。当遇到需Mock的逻辑属于某个三方包时,由于无法直接修改代码添加@MockWith注解,为了符合包路径约定,会导致在测试代码里出现孤立的"飞包"现象:

src
├── main
│   └── java
│       └── com
│           └── demo
│               └── biz
│                   ├── service
│                   │   ├── AaService.java
│                   │   ├── BbService.java
│                   │   ...
│                   ├── util
│                   │   └── ToolUtil.java
│                   ...
└── test
    └── java
        └── com
            ├── demo
            │   └── biz
            │       ├── service
            │       │   ├── AaServiceTest.java
            │       │   ├── BbServiceTest.java
            │       │   ...
            │       ├── util
            │       │   └── ToolUtilTest.java
            │       ...
            └── 3rd
                └── party
                    └── pkg
                        └── SomethingMock.java  <- 孤立的"飞包"

包路径映射功能就是用来解决这个问题的。在testable.properties配置文件中,添加以mock.package.mapping开头的配置项,具体格式为:

mock.package.mapping.<业务类所在包路径> = <Mock类所在包路径>

例如:

mock.package.mapping.com.3rd.party.pkg = com.demo.biz.3rd

此时若需要Mock的代码位于com.3rd.party.pkg下的Something类型里,则只需在测试目录的com.demo.biz.3rd包中创建SomethingMock类型,然后在其中添加需Mock的目标方法即可。

使用映射后的测试包目录结构如下,"飞包"问题不复存在了。

src
├── main
│   └── ...
└── test
    └── java
        └── com
            ├── demo
            │   └── biz
            │       ├── 3rd
            │       │   └── SomethingMock.java  <- 三方包Mock容器类
            │       ├── service
            │       │   ├── AaServiceTest.java
            │       │   ├── BbServiceTest.java
            │       │   ...
            │       ├── util
            │       │   └── ToolUtilTest.java
            │       ...

可根据实际情况,在配置文件中添加任意多条不同的路径映射。

注意:上述示例中的3rd是非法的Java包路径名称,仅用于功能介绍目的

对于包映射功能的几项提醒:

  • 对三方包使用范围为ASSOCIATED的Mock方法没有任何意义(该方法将永远不会生效),请使用scopeGLOBAL的Mock方法
  • 虽然映射后的路径同样支持用测试类中的Mock内部类作为Mock容器,但通常更建议直接使用<业务类>+Mock方式命名的独立Mock容器类,以便于代码阅读
  • 在多模块的Maven或Gradle工程中,由于模块之间是Jar包集成,若需要进行跨模块Mock,应在测试用例所在模块里提供单独的Mock容器类,并根据情况决定是否需要进行包路径映射

使用IntelliJ IDEA插件


在使用TestableMock工具的过程中经常涉及一些固定操作,比如定义Mock容器类、复制需Mock的调用定义并创建Mock方法,在执行测试时若发现Mock匹配的效果不符合预期,则需要通过自助问题排查方法在运行期进行校验。通过IDE插件辅助,能够将部分模式化的操作自动完成,进一步优化TestableMock的使用体验。

目前TestableMock提供了 IntelliJ IDEAopen in new window 的插件支持(根据JRebel发布的 2020open in new window2021open in new window Java技术趋势报告,IntelliJ IDEA已成为Java开发者使用比例最高的首选IDE)。

使用方法

打开配置项,在插件市场中搜索“Testable-Mock”,选中Testable-Mock Helper插件,点击“安装”,然后重启IDE。

install

1. 创建Mock容器类

安装完插件后,在任意Java类点击右键,点击“生成Testable测试类”(英文版为“Generate Testable TestClass”)

mock-class

在项目test目录下与当前类相同的包路径位置会自动生成一个包含Mock容器类的测试类(已当前类+Test命名)。

2. 创建Mock方法

选中任意方法调用,点击右键,点击“复制方法为Testable-Mock”(英文版为“Copy Testable Mock-Method”)或“复制Testable-Mock方法到测试类”(英文版为“Copy Mock-Method To TestClass”)

mock-method

前者会将被选中调用的方法签名,并转换为Mock方法定义存放到剪贴板;后者则会将转换好的Mock方法直接插入到相应的Mock类里。

源码构建

除了通过插件市场安装,也可以直接从源码构建Testable-Mock Helper插件。

本地准备JDK 11环境,然后执行以下命令:

git clone https://github.com/zcbbpo/testable-idea
cd testable-idea
./gradlew clean build

构建完成后,在build/distributions/目录下可以找到构建好的插件zip包,通过“从本地磁盘安装”菜单加载该插件。

from-local

在此,特别感谢 @zcbbpoopen in new windowTestableMockIntelliJ IDEA插件的贡献。

Mock线程池内的调用


TestableMock采用来自transmittable-thread-localopen in new window项目的TransmittableThreadLocal类型存储测试用例运行期的MOCK_CONTEXT内容和Mock方法调用过程。

当线程池中的执行对象未经过TtlRunnableTtlCallable处理时,TransmittableThreadLocal将自动降级为与InheritableThreadLocal等效的类型,即只对父子线程有效,无法在线程池上下文中正常传递存储数据。因而会导致MOCK_CONTEXT内容丢失和verifyInvoked()方法校验结果不正确的情况。

为此,可以启用Testable全局配置thread.pool.enhance.enable=true,来自动在测试启动时自动封装程序中的普通RunnableCallable对象,使TransmittableThreadLocal恢复跨线程池存储数据的能力。

同时还需要配合修改项目pom.xmlbuild.gradle文件,将transmittable-thread-local中的类型增加到TestableMock运行的Classpath里,具体方法如下。

使用Maven构建

首先增加一个属性,以便将来修改该依赖的版本值:

<properties>
    <transmittable.thread.local.version>2.12.1</transmittable.thread.local.version>
</properties>

然后,在maven-surefire-plugin插件的argLine参数中添加运行参数-Xbootclasspath/a:${settings.localRepository}/com/alibaba/transmittable-thread-local/${transmittable.thread.local.version}/transmittable-thread-local-${transmittable.thread.local.version}.jar

添加后的完整maven-surefire-plugin配置参考:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <configuration>
                <argLine>-Xbootclasspath/a:${settings.localRepository}/com/alibaba/transmittable-thread-local/${transmittable.thread.local.version}/transmittable-thread-local-${transmittable.thread.local.version}.jar -javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar</argLine>
            </configuration>
        </plugin>
    </plugins>
</build>

使用Gradle构建

test区块内添加测试参数jvmArgs "-Xbootclasspath/a:${classpath.find { it.name.contains("transmittable-thread-local") }.absolutePath}"

添加后的完整test区块配置参考:

test {
  jvmArgs "-javaagent:${classpath.find { it.name.contains("testable-agent") }.absolutePath}"
  jvmArgs "-Xbootclasspath/a:${classpath.find { it.name.contains("transmittable-thread-local") }.absolutePath}"
  ... // 其他测试配置
}

使用Testable Maven插件


在使用Maven构建的项目里,除了直接修改maven-surefire-plugin插件的运行参数,也可通过testable-maven-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>

**注意:**当使用testable-maven-plugin插件时,应该移除maven-surefire-plugin插件上的TestableMock相关配置。

testable-maven-plugin插件能够与Jacoco插件直接同时使用,无需额外适配,因此能使pom.xml文件编写起来更简单且美观。

还有一种特殊情况,当jacoco插件是通过maven命令行参数引入的时候,若要使用TestableMock功能,则也必须通过命令行参数引入testable-maven-plugin插件。详见issue-14open in new window

但需要注意的是,使用testable-maven-plugin插件后,通过IntelliJ IDE运行单个测试用例时,Mock功能会失效。

这是由于IntelliJ IDE运行单个测试用例时只会运行maven-surefire-plugin插件,跳过了testable-maven-plugin插件执行,导致Mock功能所需的JavaAgent未随测试注入。

该问题可以通过额外配置IDE的测试参数绕过。以IntelliJ为例,打开运行菜单的"编辑配置..."选型,如图中位置①

modify-run-configuration.png

在"虚拟机参数"属性值末尾添加JavaAgent启动参数:-javaagent:${HOME}/.m2/repository/com/alibaba/testable/testable-agent/x.y.z/testable-agent-x.y.z.jar,如图中位置②

PS:请将路径中的x.y.z替换成实际使用的版本号

add-testable-javaagent.png

最后点击运行单元测试,如图中位置③

总体来说,由于当下的IDE支持问题,使用testable-maven-plugin带来的额外复杂性依然高于其对配置的简化作用。目前直接在pom.xml文件中修改maven-surefire-plugin插件配置还是相对推荐的实用方案。

3. 技术参考

全局运行参数


TestableMock的许多功能采用了基于JavaAgent的运行时字节码修改实现。在JavaAgent启动时,可以通过全局配置文件调整其执行过程。

1.使用全局配置文件

在项目的src/test/resources目录下创建一个名为testable.properties的文本文件,其中每行是一条配置项,配置项的名称与值之间用=连接。

详细的可用配置项列说明下:

配置项描述可用值和示例
dump.path将修改过后的字节码保存到本地指定目录(用于排查问题)相对项目根目录的位置,例如:target/bytecode
enhance.pkgPrefix.includesTestableMock仅在特定包内生效,通常用于大型项目使用,分隔的包路径前缀列表,例如:com.demo.biz,com.3rd.biz
log.file指定TestableAgent日志文件位置相对项目根目录的位置,例如:target/testable/agent.log,特殊值null表示禁用日志文件
log.level修改全局日志级别可用值为:mute(禁止打印警告) / debug(打印调试信息) / verbose(打印非常详细的调试信息)
mock.innerClass.name修改测试类中的内置Mock容器类名任意符合Java类名的值,默认值为Mock
mock.scope.default修改默认的Mock生效范围(详见Mock生效范围可用值为:global(全局生效) / associated(只对关联的测试用例生效)
mock.target.checking.enable是否启用Mock目标有效性的前置检查可用值为:true / false,当前默认值为false
omni.constructor.enhance.enable是否启用OmniConstructor的字节码增强模式可用值为:true / false
omni.constructor.enhance.pkgPrefix.excludes对特定包禁用OmniConstructor的字节码增强模式使用,分隔的包路径前缀列表,例如:com.demo.model
private.access.enhance.enable是否启用PrivateAccessor的字节码增强模式可用值为:true / false
thread.pool.enhance.enable是否启用基于TransmittableThreadLocal的Mock上下文存储可用值为:true / false

参见demo目录各示例项目中的testable.properties文件。

2.修改配置文件位置

除了配置文件,TestableMock也支持通过引入testable-agent包时,在末尾加上一个=符号,然后连接额外参数来实现全局配置。这种方法与使用配置文件基本等效,因此通常无需使用,但有唯一特例是configFile配置项,该配置项可用于修改全局配置文件的位置。

参数描述可用值和示例
configFile修改全局配置文件位置可用相对路径或绝对路径,当为相对路径时,表示相对项目根目录的位置,默认值为src/test/resources/testable.properties

对于Maven项目,可将参数追加到maven-surefire-plugin参数testable-agent包尾部,紧接着.jar的位置。例如将配置文件位置修改为项目根路径下的config/testable.properties文件:

<configuration>
    <argLine>-javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar=configFile=config/testable.properties</argLine>
</configuration>

对于Gradle项目,同样是直接将参数追加到引入testable-agent的配置末尾。例如:

    jvmArgs "-javaagent:${classpath.find { it.name.contains("testable-agent") }.absolutePath}=configFile=config/testable.properties"

3. 全局配置技巧

对于一般的小型项目,TestableMock的所有功能都是开箱即用的,正确引用依赖包后,无需进行额外配置。

默认情况下,TestableMock会在构建目录中(Maven构建的target目录或Gradle构建的build目录)自动生成一个记录执行过程的testable-agent.log文件。若希望禁用此日志文件,可将log.file参数赋值为null

若测试中使用了OmniConstructor且遇到构造出错的情况,可开启omni.constructor.enhance.enable配置(倘若开启后依然报错,请提Issue告诉我们)。omni.constructor.enhance.pkgPrefix.excludes配置主要用于当开启OmniConstructor字节码增强模式报错时,临时绕过某些无法处理的类型,通常无需使用。

若项目较大(构建生成的jar包大于100MB),可考虑使用enhance.pkgPrefix.includes参数来减少TestableMock在测试启动前建立Mock关联和进行OmniConstructor预处理的扫描时长,从而加快单元测试启动速度。通常将值设置为当前项目自身的<group>.<artifact>路径即可,如需Mock三方包中的调用,或通过OmniConstructor构造来自三方包中的类型,则还应该加上相应的三方包路径。

若项目测试中,既包含真实的单元测试,又包含了使用单元测试框架编写的集成测试时。为了让集成测试的执行过程不受Mock影响,可能需要使用mock.scope.default将默认的Mock方法范围限制为仅对所属类型的单元测试用例生效。

若需Mock的调用发生在线程池中,且遇到verifyInvoked()结果或MOCK_CONTEXT内容不正确的时候,则需考虑开启thread.pool.enhance.enable配置,详见Mock线程池内的调用

注解参数清单


基于轻量的原则,TestableMock为开发者提供了尽可能精炼、易用的注解组合,以下参数信息可供开发参考。

@EnablePrivateAccess

启用对被测类的私有成员访问编译期增强私有目标存在性的编译期校验功能。

  • 作用于:测试类
参数类型是否必须默认值作用
srcClassClassN/A当测试类命名不符合约定时,指定实际被测类
verifyTargetOnCompilebooleantrue是否启用私有目标的编译期存在性校验

@MockInvoke

将当前方法标识为待匹配的Mock成员方法。

  • 作用于:Mock容器类中的方法
参数类型是否必须默认值作用
targetClassClassN/A指定Mock目标的调用者类型
targetMethodStringN/A指定Mock目标的方法名
scopeMockScopeMockScope.GLOBAL指定Mock的生效范围

@MockNew

将当前方法标识为待匹配的Mock构造方法。

  • 作用于:Mock容器类中的方法
参数类型是否必须默认值作用
scopeMockScopeMockScope.GLOBAL指定Mock的生效范围

@MockWith

显式指定当前类型关联的Mock容器类。

  • 作用于:测试类、被测类
参数(N/A为默认参数)类型是否必须默认值作用
N/AClassNullType.class指定使用的Mock容器类
treatAsClassTypeClassType.GuessByName指定当前类是测试类或被测类

@MockDiagnose

启用或禁止Mock相关的诊断信息输出。

  • 作用于:Mock容器类
参数(N/A为默认参数)类型是否必须默认值作用
N/ALogLevelN/A指定当前Mock容器关联测试用例的诊断日志级别

@DumpTo

将指定类型被TestableMock处理过后的字节码导出到文件。

  • 作用于:任意类型
参数(N/A为默认参数)类型是否必须默认值作用
N/AStringN/A存放导出文件的目录(相对当前执行目录的相对路径),若目录不存在,将自动创建

TestableMock的设计和原理


这篇文档主要介绍TestableMock中Mock功能的设计思想和实现原理。

与常见的Mock工具在每个测试用例里写Mock定义不同,TestableMock让每个业务类直接提供自己的Mock方法集合,描述自身在测试时需要被Mock的调用以及相应替代逻辑(即每个业务类有自己的独立Test类和独立Mock类)。采用约定优于配置,降低Mock学习理解成本、减少冗余信息。

这种设计基于两项基本假设:

  1. 同一个测试类里,一个测试用例里需要Mock掉的方法,在其他测试用例里通常也都需要Mock。因为这些被Mock的方法往往访问了不便于测试的外部依赖。
  2. 每个单元测试只关注被测单元内部的逻辑,单元外的无关调用应该被替换为Mock。即需要被Mock的调用应该都在被测类的代码中。

据此通过约定来简化符合以上假设的单元测试场景,通过配置来支持其余更复杂的使用场景。

TestableMock的原理可以用一句话概括:利用JavaAgent动态修改字节码,把被测的业务类中与所有与Mock方法定义匹配的调用在单元测试运行时替换成对Mock方法的调用

最终达到的效果则是,不论代码用什么服务框架、什么对象容器,不论要Mock的目标对象是注入的、new出来的、全局的还是局部的,不论要Mock的目标方法是私有的、外部的、静态的、继承来的或者重载过的,全部无差别通吃,让单元测试回归简单。

划重点:Mock的目标是被测类中的方法调用。测试用例里的代码不会被Mock,方法的定义本身没有变化,只是发起调用的代码被替换了。

具体来说,在单元测试启动时,TestableMock会对加载到内存中的类进行预处理,同时分别建立“被测类”、“测试类”、“Mock容器类”之间的关联关系(可以是一对一,也可以是多对一)。这个关联一方面是为了在测试用例执行时能够正确匹配Mock调用并进行替换,另一方面则是为了能控制Mock方法的生效范围。

对于被测类,将匹配到的调用换成对Mock容器方法的调用。

对于测试类,在每个测试用例开头插入Mock上下文初始化代码。

对于Mock容器类,增加testableIns()方法变成单例类,在每个Mock方法开头插入记录调用的代码。

以上是整个Mock的核心逻辑,更多实现细节,请参考源码。若有任何问题、建议、改进提议,都欢迎通过Github Issue和Pull Request参与讨论、贡献😃

部分类

ClassFileTransformer

Instrumentation与ClassFileTransformer--字节码转换工具-CSDN博客open in new window
Java程序员必知:深入理解Instrument - 简书 (jianshu.com)open in new window

Processor

Java javax.annotation.processing.Processor (注解处理器)_思-路的博客-CSDN博客open in new window

com.alibaba.testable.core.tool.OmniConstructor#handleCircleReference(java.lang.Object, java.lang.Class<?>, java.util.Map<java.lang.Class<?>,java.lang.Object>)

上次编辑于:
贡献者: soulballad