王者 Mockito
不知从何时开始,Mockito 成了 Java 的单元测试框架王者,目前(2019年7月)Github 上 star 数直逼 10K。看看其他的单元测试工具:PowerMock 2K(无疑是沾了 Mockito 的光),easymock 600,JMockit 300。跟 Mockito 一比,好可怜啊,一个能打的都没有。
Mockito 当然很好。我从2012年还是2013年开始用 Mockito,看着它从 1.0x 版本一路走来,今年晚些时候估计会正式发布 3.0 版本。应该有不少人都跟我有类似的体验,从 Mockito 开始接触 mock / stub,一边赞叹 Mockito 语法的简练,一边享受着 mock 带来的单元测试的便利性。总说单元测试应该要隔离外部依赖和实现,很难想象,如果没有 mock,怎么写单元测试呢?
public void test() {
when(userDao.update(any(User.class))).thenReturn(1);
int actual = userService.update(aUser);
Assert.assertTrue(acutal > 0);
verify(userDao).update(aUser);
}
看看上面这个 Mockito 的例子,when(…).thenReturn(…),verify(…).doSomething(),这代码就像人类语言,多么简明易懂!
但是(没错转折来了),已经2019年了,Mockito 依然不支持 mock 静态方法、构造方法等。你可以说,这是设计理念,Mockito 首页上一直写着一句话 “Don’t mock everything” ,认为说应该做好功能代码的设计,尽量避免静态方法等,尽量使你的代码易于测试。这个理念,在理论上没问题,但这么多年的开发经验告诉我,理想归理想,实际上要你去维护的遗留代码总是一箩筐一箩筐的,避无可避。
Static methods, to mock or not to mock, that is the question
单元测试中是否要 mock 静态方法,一直争论不休,网上有 一个 一个 又一个 的讨论,各种意见都有。
我的个人意见,跟 这个观点 一样,我认为测试工具不应该替用户决定什么是好、什么是不好,而应该尽量提供选择,让用户自行判断、采取合适的方案。理论很美好,但实际情况就是,google 搜 “mockito how to mock static methods”,有近15万条结果,可想而知,全世界的开发者在这个问题上浪费了多少时间。
真要用 Mockito 来 mock 静态方法,一般都是结合 PowerMock 使用。这两年 PowerMock 发展的怎么样我不太清楚,但14、15年那会儿我用过 PowerMock,感受就是,真他妈累啊!理论上来说是可以的,但实际做起来就总是各种问题,然后各种 google 、解决,然后又继续各种问题,排查的我都快怀疑人生了。最终我是放弃了 PowerMock 的,这么费力地去结合两个工具一起用,往后很难说还有多少坑。
Mockito、EasyMock 等工具不支持 mock 静态方法,原理上是因为它们都是基于 cglib 的,只能通过创建子类或实现接口的方式去 mock。那除了 cglib ,就没有其他的 mock 实现方法了吗?当然有,修改字节码呀!
另辟蹊径的 JMockit
和其他大多数使用 cglib 实现的单元测试工具不同,JMockit 使用 JDK6 的 java.lang.instrument 包和 ASM,动态地在运行时修改字节码,从而实现 “Mock Anything” 。什么静态方法、构造函数,随时随地想 mock 就 mock。一个 JMockit ,解决了 Mockito + PowerMock 两个工具都解决不了的问题,那为啥不用 JMockit 呢?JMockit 为啥流行不起来呢?
public class UserServiceTest {
@Tested
private UserService userService;
@Injectable
private UserDao userDao;
public void test() {
new Expectations() {
{
userDao.update(withInstanceOf(User.class));
result = 1;
}
};
int actual = userService.update(aUser);
Assert.assertTrue(acutal > 0);
new Verifications() {
{
userDao.update(withInstanceOf(User.class));
}
};
}
}
功能更强大的 JMockit 却流行不起来,我觉得其中一个原因,是它的语法不太友好。看看上面这个 JMockit 的例子,这坨 new Expectations(){…} 和 new Verifications(){…} 是什么鬼?匿名类?为啥里面又有一层大括号?别说测试代码了,在普通的功能代码中,我们都极少见到这样的语法。多数人可能觉得不习惯,然后就此打住,放弃 JMockit 了。
JMockit 的这种语法,是基于它的 record-replay-verify 模型。new Expectations() 是录制期望,new Verifications() 是校验,二者中间的就是回放——正常调用业务方法。而在匿名内部类类中间的那层大括号,是 Java 的“实例初始化块” (Instance Initialization Blocks),我们平时可能用“静态初始化块”比较多,“实例初始化块”确实较少见,它的其中一种用途,就是用来初始化匿名内部类,因为匿名内部类不能有构造函数。理解了这些语法之后,其实 JMockit 不难懂,用法跟其他测试框架也大致一样,就是功能更强大了。
JMockit 不够流行的另一个原因,我猜可能跟社区有关。没办法,Mockito 太受欢迎了,社区一片火热,贡献者一大堆。反观 JMockit,虽然开源,但只有原作者 Rogério Liesenfeld 自己一个人在开发维护。这种单人维护的项目,说不定哪一天就停更了,大家都会有这种担忧。我也担心啊,但看看近几年 JMockit 的 release notes,基本上固定每一、两个月一次发布,并且还会提前订好下一次发布的计划,真想对作者说一句:老哥,稳!所以,至少目前看来,JMockit 的稳定性、活跃性是不用担心的,毕竟有个这么稳的作者。
JUnit5 + JMockit + Surefire + Jacoco 的配置例子
想要安心用上 Junit5 和 JMockit,还想要单元测试覆盖率?那还是有些坑要踩的。以 Maven 为例,有几个留意点:
- 默认的 maven-surefire-plugin (运行单元测试的) 的版本不支持 JUnit5,得手动指定新版本号才行。
- 想用 jacoco-maven-plugin 得到单元测试覆盖率的话,因为 jacoco 也用了修改字节码的方案,默认配置下会和 JMockit 有冲突。需要一些额外配置才行。
完整的 Maven 配置例子:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.github.renial</groupId>
<artifactId>java-utils</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>java-utils</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<jmockit.version>1.46</jmockit.version>
<jupiter.version>5.4.2</jupiter.version> <!-- 别用 junit.version!否则可能会影响其他使用 junit4 库 -->
<surefire.version>2.22.2</surefire.version> <!-- 指定版本,以支持 JUnit5 -->
<jacoco.version>0.8.4</jacoco.version>
</properties>
<dependencies>
<dependency>
<groupId>org.jmockit</groupId>
<artifactId>jmockit</artifactId>
<version>${jmockit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${jupiter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire.version}</version>
<!-- 分别指定 jmockit 和 jacoco 两个 agent,以支持运行 jmockit 测试,支持 jacoco 的覆盖率 -->
<!-- 可参考 https://github.com/jacoco/jacoco/issues/193 -->
<configuration>
<argLine>
-javaagent:${settings.localRepository}/org/jmockit/jmockit/${jmockit.version}/jmockit-${jmockit.version}.jar -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco.version}/org.jacoco.agent-${jacoco.version}-runtime.jar=destfile=${project.build.directory}/jacoco.exec
</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<!-- prepare-agent 这个 goal,原本的效果就是带上 -javaagent:*** 参数,以指定 jacoco agent -->
<!-- 但是,maven-surefire-plugin 手工配置了 jacoco 的 agent 之后,这里的 prepare-agent 实际上不会生效 -->
<!-- 不过,maven-surefire-plugin 中引用的 jacoco jar 包,需要运行一次该 prepare-agent 的 goal 之后才有 -->
<!-- 所以,还是配置上吧,没啥坏处 -->
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>