单元测试框架和覆盖率统计原理简析

精选
开发 架构
单元测试又是软件研发过程中的重要一环,此文可以帮助理解单元测试插件的运行过程,了解 mock 框架以及平台覆盖率统计相关的原理,从而更好更快地编写单元测试。

作者 | 忠素

一、背景介绍

最近部门在推进质量标准化,通过标准化研发、交付、部署、运维等过程,减少缺陷率和返工率,提高整体的工作效率。而单元测试又是软件研发过程中的重要一环,此文可以帮助理解单元测试插件的运行过程,了解 mock 框架以及平台覆盖率统计相关的原理,从而更好更快地编写单元测试。

二、 单元测试与敏捷开发

在常规的测试环节中,可以较为笼统地作以下分类:

  • 单元测试:快速地检查一个类或极小范围内的功能
  • 冒烟测试:快速检查系统核心功能是否有明确缺陷
  • 集成测试:检查整个应用或系统的功能
  • 回归测试:检查变更代码是否破坏旧的功能
  • 黑盒测试:将整个系统看成一个黑盒进行不特定测试
  • 白盒测试:按照编码或运行细节,设计特定的测试过程

完全没有实施过单元测试的团队,推进过程可以按照确定覆盖率基线、覆盖率摸底、持续补充用例、持续提升单元测试质量和覆盖率几个环节。最终的目的是希望研发人员从被动编写到主动编写,不断提升代码可测性,将低级缺陷扼杀在集成测试之前。

从不写单元测试、不会写单元测试,到写单元测试、写有效的单元测试,可以从4个阶段来推进。

三、Maven & JUnit 的关系

1.Maven 的简介

名词解释

Maven 中有两个核心概念,phase 和 goal,先通过 IDEA 中的 Maven 面板,来直观地感受一下 phase 和 goal 的区别:

1)goals

goals 是属于具体 maven 插件的一个任务,可以完成一件具体的事情。例如在 Spring Boot 的官方插件中,就提供了 run 这个任务,帮助我们直接通过 maven 命令运行我们的 Spring Boot 应用。

具体实践中,会将 goal 绑定在某个 phase 运行时执行。

2)lifecycle 和 phase

phase 是 Maven 定义的一套通用编译过程,例如 compile、deploy,phase 本身并没有具体行为,需要依赖相关插件绑定任务。

可以通过在 pom 文件中的插件声明,指定某个 goal 运行的时机(phase)。

<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
<phase>package</phase>
</execution>
</executions>

三套生命周期

Maven 的整体架构采用了 core + plugin 的方式,core 可以当成一个 Launcher,启动过程中会有一些切换主类的过程,Tomcat、Spring Boot、Pandora Boot 都有类似的设计。

Maven 中定义了三套生命周期:clean、default、site,每个生命周期会包含一些阶段(phase)。三套生命周期相互独立,执行某个 phase 时,按序执行且顺序靠前 phase 先执行,直到指定的 phase 运行结束,之后的 phase 不会再执行。

1)clean 生命周期

目的是做一些构建文件的清理工作。

pre-clean..............执行清理前的工作
clean..................清理上一次构建生成的所有文件
post-clean.............执行清理后的工作

2)default 生命周期

包含了最常用的 phase,定义了构建项目时的核心过程。

validate
initialize
generate-sources
process-sources
generate-resources
process-resources......复制和处理资源文件到target目录,准备打包
compile................编译项目的源代码
process-classes
generate-test-sources
process-test-sources
generate-test-resources
process-test-resources
test-compile...........编译测试源代码
process-test-classes
test...................运行测试代码
prepare-package
package................打包成jar或者war或者其他格式的分发包
pre-integration-test
integration-test
post-integration-test
verify
install................将打好的包安装到本地仓库,供其他项目使用
deploy.................将打好的包安装到远程仓库,供其他项目使用

3)site 生命周期

pre-site
site...................生成项目的站点文档
post-site
site-deploy............发布生成的站点文档

mvn test 和 mvn surefire:test

surefire:test 是 maven-surefire-plugin 中定义的一个任务,默认绑定在 test 阶段运行

  • 当运行 mvn test命令时,先运行 test 阶段之前的 compile、test-compile 等 phase 及绑定 goal 任务;
  • 而 mvn surefire:test 则是直接运行这个任务,不会执行编译,因此需要提前手动编译好源代码和测试代码;

Maven 会自动收集当前项目的所有模块,做依赖树和插件合并。当 pom 中未声明任何插件或者插件版本号为空,Maven 会使用默认值进行填充。

maven 的 default 生命周期和插件版本关系的声明文件:maven-core-3.6.3.jar/META-INF/plexus/default-bindings.xml。

在收集到所有的插件信息后,会按照 phase 顺序依次执行。

2.单元测试框架 JUnit

JUnit 是 Java 开发测试中最常用的单元测试框架,它是由 Kent Beck (极限编程和测试驱动开发的创始人) 和 Erich Gamma 共同编写,其灵感来自于 Kent Beck 早期在 SUnit (一种针对 Smalltalk 编程语言的测试框架) 上的工作。

JUnit 属于 xUnit测试框架家族。xUnit 家族中的测试框架通常会定义这几个运行过程: setup, exercise, verify, teardown。

JUnit3 中我们能见到一些约定的类名和方法名,这是因为早期的 JDK 并不支持注解。直到 JDK 1.5 支持注解,才使得 JUnit4 基于注解声明测试用例变成可能。

JUnit4 采用 @Annotation 标注的方式,比 JUnit3 的通过类继承和特定方法名带来更大的灵活性,而且只有一个 jar 包非常易于集成。

JUnit3


public void testXxx();


pulbic void setUp();


public void tearDown();

JUnit4

  • @BeforeClass

  • @BeforeClass

  • @Before

  • @After

  • @Test

  • @RunWith

  • @Suite

JUnit5(2016)

JUnit5 则诞生于一个互联网新技术大爆发的时期,它的目标是作为一个测试平台,来连接测试工具、测试引擎和用例。JUnit5 的主要组件可以分为。

JUnit4 是如何被 Maven 唤起的

前文提到,surefire 插件的会将测试任务绑定在 test 阶段,因此当运行 mvn test时会调用 surefire 插件的方法。surefire 通过 SPI 机制扫描类路径下,发现测试引擎实现类(需要实现 org.apache.maven.surefire.providerapi.SurefireProvider),从而将测试任务转嫁到具体的执行引擎 。

按测试类名称过滤

Maven Surefire 插件无其他测试框架的依赖注入时,默认使用 JUnit3Provider 作为执行引擎,因此要求测试类命名为以下模式:

  • **/Test*.java
  • **/*Test.java
  • **/*Tests.java
  • **/*TestCase.java

过程中会排除所有嵌套类(包括静态成员类),也可以通过在 pom 文件中配置include和exclude规则来覆盖默认行为。

默认引擎下扫描测试方法的规则:

  • 测试方法必须是 public, 非 static,返回类型为 void,无参的方法;
  • 测试方法必须写成testXxx形式
  • 全局变量可以在无参的构造方法中初始化
  • 每次执行一个测试用例前,执行一遍setUp(),用于对数据的初始化执行完一个测试用例后,再执行tearDown(),用于销毁还原数据;

因此当我们需要使用 JUnit4 的注解如 @Before,需要添加依赖 surefire-junit4 告诉 SurefirePlugin 优先使用 JUnit4 的 @Runner 来运行。

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.16</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit4</artifactId>
<version>2.16</version>
</dependency>
</dependencies>
</plugin>

这就是为什么在本地运行 IDEA 没问题, Aone 或者命令行运行就出错的原因,因为缺少依赖 Maven 无法识别 JUnit4 注解,导致很多变量没有被 @Before 注解标注的方法初始化。

四、Mock 编程

单元测试中,一个重要原则就是不扩大测试范围,尽可能将 mock 外部依赖,例如外部的 RPC 服务、数据库等中间件。被 mock 的对象可以称作。

「测试替身」,它来源于电影中的特技替身的概念。Meszaros 在他的文中[2]定义了五类替身。

测试替身的分类

1.fake、spy、stub、mock 如何区分

为了帮助更好的理解「测试替身」在实际单元测试中的应用,我们看几个例子:

fake

假设有一个库存系统,当有订单时会从仓库中提货,如果货物不足则无法完成订单。单元测试是不应该依赖外部服务的,例如网络,因为网络是不可靠状态,所以我们应该用一个 fake warehouse 来伪造发送邮件的功能,它需要实现 Warehouse 抽象类,是一个可用的库存服务实现,但它只会将内容维护在内存中,而不会持久化到数据库或外部存储中。

private static String APPLE = "Apple";
private static String PEACH = "Peach";
private Warehouse warehouse = new WarehouseImpl();
@Before
public void setUp() throws Exception {
warehouse.add(APPLE, 50);
warehouse.add(PEACH, 25);
}
@After
public void tearDown() {
warehouse = new WarehouseImpl();
}
@Test
public void testOrderIsFilledIfEnoughInWarehouse() {
Order order = new Order(APPLE, 50);
warehouse.fill(order);
assertTrue(order.isFilled());
assertEquals(0, warehouse.countGoods(APPLE));
}
@Test
public void testOrderDoesNotRemoveIfNotEnough() {
Order order = new Order(APPLE, 51);
warehouse.fill(order);
assertFalse(order.isFilled());
assertEquals(50, warehouse.countGoods(APPLE));
}

stub

stub 是具有固定响应行为的 mock,目的是让测试用例跑通,不作为关键测试环节。

我们假定一个测试场景,当库存不能满足订单所需要的货物数量时,我们需要自动发送一封邮件,因此邮件服务的 Stub 可以简单实现为:

public interface MailService {
public void send (Mail mail);
}
public class MailServiceStub implements MailService {
private List<Mail> sentMails = new ArrayList<Mail>();
public void send (Mail mail) {
sentMails.add(mail);
}
public int mailSent() {
return sentMails.size();
}
}

代码中由于订单需要货物不足,会发送一封邮件,我们需要验证是否发送。

private static String APPLE = "Apple";
private static String PEACH = "Peach";
private MailService mailService = new MailServiceStub()
private Warehouse warehouse = new WarehouseImpl(mailService);
// 省略 @Before 和 @After
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(APPLE, 51);
warehouse.fill(order);
assertEquals(1, mailService.mailSent());
}

我们在测试中使用了状态验证,即验证当前数据的状态和预期是否一致。如果是使用 mock 来做,两者区别就在 stub 使用了状态验证,而 mock 则使用行为验证,即验证某些方法是否被调用验证分支路径是否已覆盖或符合我们的业务设计。

public void testOrderSendsMailIfUnfilled() {
Order order = new Order(APPLE, 51);
Warehouse warehouse = new WarehouseImpl();
MailService mailService = mock(MailService.class);
warehouse.setMailer(mailService);
warehouse.fill(order);

Mockito.verify(mailService).send(Mockito.any());
}

spy

spy 这个单词是间谍的意思,顾名思义间谍主要的工作就是收集情报,因此 spy 对象的作用就是去收集每次调用的参数、返回值、调用this、抛出的异常。spy 最主要的特性就是它只收集情报,不提供任何默认行为。

mock

mock 对象只有传入的参数满足设定时,才会触发 mock 行为。因此 mock 对象多使用行为验证,其他三类对象也可以使用行为验证。

对于第一个例子,我们只是要确定对于某一个订单,在库存不足时订单 fill 会失败,主要测试对象是订单 Order,过程中依赖对象是仓库 Warehouse。

private static String APPLE = "Apple";

public void testFillingRemovesInventoryIfInStock() {
//setup
Order order = new Order(APPLE, 50);
Warehouse warehouseMock = mock(Warehouse.class);
//exercise
warehouseMock.fill(order);
//verify
Mockito.verify(warehouseMock).remove(Mockito.eq(APPLE), Mockito.eq(50));
assertTrue(order.isFilled());
}

2.Mockito

在上面的例子中,我们大量使用了 Mockito 作为 mock 工具,现在我们来简单对这个工具做个介绍,帮助大家进一步理解上面 mock 和验证过程中发生的事。下面的例子是一个 JUnit 结合 Mockito 单元测试,通过 @InjectMocks 声明被测试的对象,通过 @Mock 声明被测试类依赖的对象。通过 Mockito.doReturn 等方法即可定义 mock 对象的行为。

@RunWith(MockitoJUnitRunner.class)
public class UserControllerTest {
@InjectMocks
private UserController userController;
@Mock
private UserService userService;
@Test
public void testService() {
doReturn(null).when(userService).listUser();
userController.listUser();
}
}

@Mock 对象 @Spy 对象

在 Mockito 中,mock 对象和 spy 对象都可以进行 mock。区别是 mock 会代理的全部方法,对应方法没有 stubbing 时返回默认值。而 spy 只是将有桩实现(stubbing)的调用进行 mock,其余方法仍然是实际调用原对象的方法。

Mockito 默认使用 bytebuddy 生成类,这里的实现过程,类似动态代理问题中,常见的基于接口实现和子类实现两种代理方式。为了更好了解 mock 对象工作方式,我们先 dump 一个接口类型 mock 后的 class 文件。

public class UserService$MockitoMock$450450480 implements UserService, MockAccess {
private static final long serialVersionUID = 42L;
private MockMethodInterceptor mockitoInterceptor;
// 这里省略了 equals、toString、hashCode、clone 方法的代理
private static Method cachedValue$jWkXotML$7kplrf1;
static {
cachedValue$jWkXotML$7kplrf1 = UserService.class.getMethod("listUser");
}
public List<User> listUser() {
return (List<User>)DispatcherDefaultingToRealMethod.interceptAbstract(this, this.mockitoInterceptor, false, cachedValue$jWkXotML$7kplrf1);
}
}

被 mock 的类,方法被代理到 MockHandler;

经过上面的分析,这里可以推出两点:

  • 对于无接口的类来说,会生成被 mock 类的子类,内部调用略有不同,但最终仍然会调用到 MockHandler。
  • Mockito.spy 本质仍然是做 mock,只是添加了默认调用原始方法的策略。
// Mockito.mock or @Mock
public static <T> T mock(Class<T> classToMock,
MockSettings mockSettings) {
return MOCKITO_CORE.mock(classToMock, mockSettings);
}
// Mocktio.spy or @Spy
public static <T> T spy(Class<T> classToSpy) {
return MOCKITO_CORE.mock(classToSpy,
withSettings().useConstructor()
// 默认响应方式优先调用原类的方法
.defaultAnswer(CALLS_REAL_METHODS));
}

从 Mockito.mock 和 Mockito.spy 的方法实现可以看出,spy 方法也仅是注入了默认的 answer 行为,即调用真实方法。

这里可以推理一下 spy 一个接口后,默认的返回是什么?接口一般是没有默认实现,spy 的接口调用时又该调用什么呢?

@Mock 对象方法的调用和验证

MockHandler 最主要的实现类为 MockHandlerImpl,我们来看下这个类的主要流程。MockHandler 默认会拦截 mock 对象所有的方法调用(super、equals、toString、hashCode 等方法先不讨论)。

mock 对象方法被调用时,先查找是否已经在当前线程中植入过调用验证对象 「VerificationMode」(可以通过 Mockito.verify 植入),如果存在则执行方法调用验证,不再调用 mock 方法。

这个例子可以帮助我们理解 Mockito 做方法验证的过程。userDao 是 mock 对象,userService 内部会调用 userDao#deleteUser 方法。

@InjectMocks
UserService userService;
@Mock
UserDao userDao;
@Test
public void testAddResourcePoliciesWithoutMember() {
// setup
Long userId = 100L;
DeleteUserRequest request = new DeleteUserRequest();
request.setUserId(userId);
// exercise
DeleteUserResult result = userService.deleteUser(request);
// verify
Assert.assertTrue(result.isSuccess());
//|----- 植入验证 ------|-- 再调用触发验证 --------------|
Mockito.verify(userDao).deleteUser(request.getUserId());
}

在 verify 调用前,userService.deleteUser 内部会调用 userDao#deleteUser 记录一次方法调用,Mockito#verify 时注入验证对象「VerificationMode」,再链式调用了 deleteUser 再次调用方法触发验证。

@Mock 对象方法参数的验证

mock 对象的参数匹配,是基于栈做方法调用和参数记录。核心类是 ArgumentMatcher,当查找 mock 方法的 stub 对象时,不仅需要匹配方法的 invocation 标识,还需要匹配对应的参数,即 Mockito.eq()、 Mockito.anyList() 等。

匹配的实现原理可以类比 Java 的 equals,如使用 Mockito.anyString(),则入参必须是不为 null 的 String。

3.字节码编辑

Mockito 默认实现的 mock 也是一种动态代理技术,在方法级别进行拦截和调用我们指定的 stub 对象,与我们经常讨论的 JDK Proxy、Cglib 等 AOP 技术非常相似。

从 Java 动态代理实现上来看,可分为两种策略和手段:操作原始类字节码或者生成子类或实现接口的新类。在实际的使用中,代理类的生成仍然可能依赖字节码的动态生成方式,并没有严格的界限。

常见的动态代理仅限于实例方法级别,对于方法内部如构造方法、静态方法和静态块、初始块、new、字段访问、catch、instanceof 等字节码指令通常无能为力,只能求助于操作原始的字节码来达到目的。

生成接口实现或者子类的代理也有一定的局限性:例如父类的 final 方法是无法被动态子类代理的。

私有方法也无法通过 Mockito 进行打桩,因此在项目的单元测试编写中,Mockito 的手段有些不够用,于是就有了基于 Mockito 的 PowerMockito。

4.PowerMockito

PowerMockito 使用 Javaassist 作为字节码编辑的框架。PowerMockito 会默认对相关类的字节码做以下修改:

  • 去除 final class 的 final 修饰符
  • 将所有构造方法修改为 public
  • 为 new 对象、字段访问、构造器注入代理
  • 去除 static final fields 的 final 声明
  • 为类的修饰符添加 public
  • 为方法注入代理对象 MockGateway
  • 将 @SuppressStaticInitializationFor 声明的类的静态块替换为 {}
  • 将超长方法体(超 65535 字节)替换为抛异常

来看一个 PowerMock 的使用例子:

@RunWith(PowerMockRunner.class)
@PrepareForTest({ListUtils.class})
public class UserControllerTest {

@InjectMocks
private UserController userController;

@Mock
private UserService userService;

@Test
public void testService() {
doReturn(null).when(userService).listUser();
userController.listUser();
}
}

这里与 Mockito 的区别是 JUnit Runner 指定为 PowerMockRunner,在新注解 @PrepareForTest 中声明的类,运行测试用例时,会创建一个新的 org.powermock.core.classloader.MockClassLoader 类加载器实例,来加载声明的类,从而完成对目标类指令级别的修改。

在 surefire 插件的 Runner 创建时,可以在下面的调用栈中看到,由 surefire 插件的 JUnit4Provider 代理到 JUnit,JUnit 负责 Runner 对象的初始化和调用。在 PowerMockRunner 初始化的过程中,基于自定义类加载器做到类的修改。

有时候我们需要对某些类的静态块屏蔽,保证测试用例可以正常运行,PowerMock 提供了 @SuppressStaticInitializationFor 注解,只需要在测试类上声明即可。

需要注意的是,屏蔽静态块代码后,类的静态字段也不会被初始化,因为静态字段的初始化是被编译在静态块中,这点需要注意。如果你屏蔽了静态块中的一些方法,但仍然依赖一些静态字段,可能会产生一些异常情况,如空指针。这时候需要额外的 mock 或者手动初始化静态字段。

我们来看一段对 new 操作符修改后的方法,下面表格中提供了一些 Javassist 的变量说明便于理解下面的例子。

$0$1$2, ...    

this and actual parameters

$args

An array of parameters. The type of $args is Object[].

$$


All actual parameters.

For example, m($$) is equivalent to m($1,$2,...)

$cflow(...)

cflow variable

$r

The result type. It is used in a cast expression.

$w

The wrapper type. It is used in a cast expression.

$_

The resulting value

$sig

An array of java.lang.Class objects representing the formal parameter types.

$type

A java.lang.Class object representing the formal result type.

$class

A java.lang.Class object representing the class currently edited.

$proceed    

The name of the method originally called in the expression.

PowerMock 会将 new 对象的字节码替换为代理到 MockGateway#newInstanceCall 的静态方法调用,方法返回的对象如果不是 PROCEED,则调用原始方法,否则使用返回的构造方法进行反射调用或者直接使用 MockGateway 返回的对象。

Object instance = org.powermock.core.MockGateway.newInstanceCall($type,$args,$sig);
if(instance != org.powermock.core.MockGateway.PROCEED) {
if(instance instanceof java.lang.reflect.Constructor) {
$_ = ($r) sun.reflect.ReflectionFactory.getReflectionFactory()
.newConstructorForSerialization($type,
java.lang.Object.class.getDeclaredConstructor(null))
.newInstance(null);
} else {
$_ = ($r) instance;
}
} else {
$_ = $proceed($$);
}

通过以上替换,PowerMock 就成功将方法内部的 new 操作,代理到了 MockGateway。对于一般的方法(无论是 private 还是 static 甚至 native 方法),都是相似的,对于普通方法,会代理到 MockGateway#methodCall。methodCall 中在过滤完一些特殊方法后,如 toString、equals 等,会按照是否被抑制调用、是否有 stub、是否有 mock 等策略执行。下图中说明了 methodCall 的核心流程。

PowerMock 支持的 private 方法 mock、static 方法 mock 可以理解为在 API 层面提供了一套工具入口,剩下的 mock 对象生成、方法验证等仍旧利用了 Mockito 提供的能力。

5.mock 静态方法

有时不可避免会遇到要 mock 静态方法的地方,Mockito 2.0 版本不支持 Mock 静态方法,目前的方式是引入 PowerMock,但是引入后,JaCoCo 又会出现覆盖率统计错误的问题,需要将 JaCoCo 的采集模式改为离线方式。

新版的 Mockito 从 3.4.0 开始,已经支持了静态方法的mock。

需要引入

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>3.4.6</version>
<scope>test</scope>
</dependency>

需要注意 mock 静态方法是万不得已才去做的,在 mock 静态方法前,首先应该考虑的是优化业务代码,提高代码可测试度。

五、覆盖率统计

代码覆盖率是衡量单元测试有效性的一个指标,覆盖率又可以分为两个大类,即 「需求覆盖率」和「代码覆盖率」。

需求覆盖

指的是测试人员对需求的了解程度,根据需求的可测试性来拆分成各个子需求点,来编写相应的测试用例,最终建立一个需求和用例的映射关系,以用例的测试结果来验证需求的实现,可以理解为黑盒覆盖。

代码覆盖

为了更加全面的覆盖,我们可能还需要理解被测程序的逻辑,需要考虑到每个函数的输入与输出,逻辑分支代码的执行情况,这个时候我们的测试执行情况就以代码覆盖率来衡量,可以理解为白盒覆盖。

代码覆盖率的度量方式有以下几类:

1. 代码覆盖率的发展历史

Java 中比较流行的代码覆盖率工具有 EMMA, Cobertura,JaCoCo 等。其中 Emma 由于开发团队的原因已经停止更新,原团队目前专注于 JaCoCo 的开发和维护工作。有意思的是,EclEmma 和 JaCoCo 的官方网站是指向不同域名的同一个服务。

在 Java 领域有很多方法来收集代码覆盖度。下图展示了 JaCoCo 插件采用的技术(加色展示的部分)。

2. Jacoco 覆盖率统计过程

Jacoco 是 Java 领域目前比较主流的覆盖率统计工具,它的统计过程可以分为打桩、测试用例执行、覆盖率统计和覆盖率数据解析并生成报告。

(1)首先对需要统计覆盖率的 Java 代码进行插桩,植入覆盖率统计代码,有 On-The-Fly 和 Offline 两种方式。

(2)执行测试用例,通过用例运行收集执行轨迹信息,保存在内存中。

(3)JVM 退出前将覆盖率数据保存至磁盘(二进制)或通过网络传送出去 。

(4)解析覆盖率文件,将代码覆盖率报告图形化展示出来,如 html、xml 等文件格式。

3.  JaCoCo 的 offline 与 on-the-fly

根据插入字节码的时机不同,可以将覆盖率工具的运行方式分为offline与on-the-fly两种:

offline 模式

offline 模式会对编译后的字节码文件进行插桩并覆盖源文件,在启动 JVM 时直接加载插桩后的字节码。

offline 模式会对编译后字节码源文件进行修改,所以对用户的影响最大,但是对性能的影响最小。

  • 优点是不需要运行环境支持 java agent,不会与其他 agent 冲突。
  • 但需要添加 jacoco 编译字节码的 runtime 依赖。

on-the-fly 模式

在 JVM 加载类文件时,回调 javaagent 对字节码进行动态增强,植入覆盖率统计代码。

on-the-fly 模式会在应用启动时对加载进JVM的字节码文件进行插桩,不会改变用户的运行流程,只需要在JVM启动时配置 -javaagent 参数,更加无感。

  • 优点是直接添加启动参数即可快速进行覆盖率统计和分析。
  • 缺点是使用 javaagent 会降低一些启动速度以及 agent 冲突问题。

4. JaCoCo 与 Maven 集成

JaCoCo 提供了 maven 插件方便开发在项目中集成,提供了以下基本 goals,常用的包括 prepare-agent、report、instrument 和 restore-instrumented-classes。jacoco-maven-plugin 的 goals 与 Maven 生命周期的绑定关系如下:

validate
initialize .................. (prepare-agent 默认所属周期,注入 javaagent 参数)
generate-sources
process-sources
generate-resources
process-resources
compile
process-classes ............. (instrument 默认所属周期,offline 模式下对字节码插桩)
generate-test-sources
process-test-sources
generate-test-resources
process-test-resources
test-compile
process-test-classes
test ........................ (mvn test 执行的截止周期)
prepare-package ............. (restore-instrumented-classes 默认所属周期,offline 模式下恢复原始字节码)
package
pre-integration-test
integration-test
post-integration-test
verify ...................... (report 和 check 默认所属周期,report 用于生成覆盖率报告)
install
deploy

在默认的绑定关系中,当我们执行 mvn test 的时候,restore-instrumented-classes 和 report 默认不会被运行,因此为方便 offline 模式使用,我们需要修改下插件绑定的执行 phase,保证我们运行 mvn test 时可以正常运行,生成覆盖率报告。下面是两种不同模式的配置方案。

offline

instrument 和 restore-instrumented-classes 需要配套使用。

<dependencies>  
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>org.jacoco.agent</artifactId>
<version>0.8.6</version>
<classifier>runtime</classifier>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.16</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit4</artifactId>
<version>2.16</version>
</dependency>
</dependencies>
<configuration>
<systemPropertyVariables>
<jacoco-agent.destfile>${project.build.directory}/coverage.exec</jacoco-agent.destfile>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<id>default-instrument</id>
<goals>
<goal>instrument</goal>
</goals>
</execution>
<execution>
<id>report</id>
<goals>
<goal>report</goal>
</goals>
<configuration>
<dataFile>${project.build.directory}/coverage.exec</dataFile>
</configuration>
</execution>
<execution>
<phase>test</phase>
<id>default-restore-instrumented-classes</id>
<goals>
<goal>restore-instrumented-classes</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

on-the-fly

aone 的默认测试插件默认采用这种模式。

<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>

为什么要改默认的 phase?

当我们使用 offline 模式运行 mvn test,如果按照默认的绑定关系,可能会遇到。

Cannot process instrumented class 某个类名. Please supply original non-instrumented classes。

原因是JaCoCo 的 instrument 在 process-classes 阶段将编译好的代码打桩用于统计代码覆盖率,然后需要在 restore-instrumented-classes 恢复原始字节码。

但是执行 mvn test 时只到 test,而 restore-instrumented-classes 绑定在 prepare-package 阶段,因此 mvn test 默认不会触发 restore-instrumented-classes ,第二次 mvn test 时会重复打桩,引起报错。

如果不改默认的 phase,则需要将 mvn test 改为 mvn verify 使用。verify 会运行 intergration-test 和 package 阶段,这两个阶段针对单元测试来说,不是十分必要。

目前 Aone 代码覆盖率主要基于 JaCoCo 的 on-the-fly 模式进行代码覆盖率采集,通过自定义测试构建过程,利用 CI 插件自动注入 jacoco-maven-plugin,无需用户自己添加。配置过程可以参考下文。

Aone 的各类测试任务底层复用了同一套执行引擎,类似 Maven 的 phase 和 goals,在 aone 中称为阶段和插件,每个阶段可以添加多个插件,例如下图在单元测试阶段,添加了代码 checkout 插件、codeconverage-unittest-pre(自动在 pom 文件中植入 JaCoCo 的 on-the-fly 配置)、单测和覆盖率解析插件。

aone 测试任务任务日志解析:

(1)codecoverage-unittest-pre 自动注入 jacoco-maven-plugin。

(11:00:12) Execute codecoverage-unittest-pre plugin
(11:00:13) ignore submodules.
(11:00:13) Execute plugin command
(11:00:13) cd /root/cise/space/135295299/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135295299/source/pom.xml"
(11:00:13) Plugin unit-test code coverage pre processer begins.
(11:00:14) 2022-03-01 11:00:14 INFO PomUtils - pom file found:/root/cise/space/135295299/source/pom.xml
(11:00:14) 2022-03-01 11:00:14 INFO PomUtils - backup pom file success.

这里的日志还有两种情况:

1)pom 已经配置 jacoco。

当我们出于各种情况,例如对插件版本、运行阶段或者为了解决覆盖率统计问题,自行配置了 jacoco 插件后,aone 便不会再自动注入。

Execute codecoverage-unittest-pre plugin
(11:00:24) ignore submodules.
(11:00:24) Execute plugin command
(11:00:24) cd /root/cise/space/135294989/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135294989/source/buc.acl.shared.parent/pom.xml"
(11:00:25) Plugin unit-test code coverage pre processer begins.
(11:00:25) 2022-03-01 11:00:25 INFO PomUtils - pom file found:/root/cise/space/135294989/source/buc.acl.shared.parent/pom.xml
(11:00:25) 2022-03-01 11:00:25 INFO PomUtils - backup pom file success.
(11:00:25) 2022-03-01 11:00:25 ERROR PomUtils - pom.xml contains jacoco-maven-plugin;no need to modify pom.
(11:00:25) 2022-03-01 11:00:25 INFO PomUtils - contains jacoco plugin,no need to modify pom.

2)pom 中缺少 标签提示 jacoco.exec not found。

(16:41:13) Execute codecoverage-unittest-pre plugin
(16:41:14) ignore submodules.
(16:41:15) Execute plugin command
(16:41:15) cd /root/cise/space/135189936/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135189936/source/pom.xml"
(16:41:15) Plugin unit-test code coverage pre processer begins.
(16:41:15) 2022-02-28 16:41:15 INFO PomUtils - pom file found:/root/cise/space/135189936/source/pom.xml
(16:41:15) 2022-02-28 16:41:15 INFO PomUtils - backup pom file success.
(16:41:15) 2022-02-28 16:41:15 ERROR PomUtils - <build> element not found.

(2)JaCoCo 插件在 prepare-agent 阶段注入 javaagent 参数。

(11:00:18) [INFO] --- jacoco-maven-plugin:0.8.7:prepare-agent (jacoco-initialize) @  ---
(11:00:18) [INFO] Downloading ...
(11:00:18) [INFO] Downloaded ...
(11:00:20) [INFO] argLine set to -javaagent:/root/.m2/repository/org/jacoco/org.jacoco.agent/0.8.7/org.jacoco.agent-0.8.7-runtime.jar=destfile=/root/cise/space/135295299/source/target/jacoco.exec

这里可能会出现另一种情况,日志出现,但实际运行进程中,并没有 javaagent 参数,导致覆盖率无法统计。

grep 进程排查

执行 mvn test 过程中,日志中看到 case 已经在running中时,查看进程。

ps -ef | grep java

找到测试对应的进程,观察完整的进程命令,如果jacoco生效了会看到下图,如果没有生效,则ps -ef | grep java | grep jacoco看不到任何进程。

测试运行时为什么会没有 JaCoCo 这个 javaagent ?

我们通常会配置maven-surefire-plugin去跑测试。

<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>"some jvm args"</argLine>
</configuration>
</plugin>
</plugins>

当 surefire 配置运行参数后,mvn test 时其他插件就无法自动添加更多的 JVM 运行参数,比如 jacoco 插件。argLine的正确写法如下:

<argLine>${argLine} "some jvm args"</argLine>

(3)覆盖率报告解析

实验室是通过获取运行的标准输出,解析后得到运行结果。因此可以通过在脚本、插件执行过程中按照相应的格式输出内容,达到统计运行结果以及定义页面展现的目的。

(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":0,"branchRatio":0.0,"branchTotal":30,"buildId":125846455,"classCovered":0,"classRatio":0.0,"classTotal":17,"coverageRecordId":9579151,"env":"local","lineCovered":0,"lineRatio":0.0,"lineTotal":185,"methodCovered":0,"methodRatio":0.0,"methodTotal":75,"name":"buc.login.spi","nodeType":"JAR","parent":"mozi-im-gateway-private","type":"unnit-test","updated":0}
(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":0,"branchRatio":0.0,"branchTotal":38,"buildId":125846455,"classCovered":0,"classRatio":0.0,"classTotal":5,"coverageRecordId":9579151,"env":"local","lineCovered":0,"lineRatio":0.0,"lineTotal":124,"methodCovered":0,"methodRatio":0.0,"methodTotal":17,"name":"buc.sso.application","nodeType":"JAR","parent":"mozi-im-gateway-private","type":"unnit-test","updated":0}
(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":49,"branchRatio":0.0016,"branchTotal":29792,"buildId":125846455,"classCovered":20,"classRatio":0.0103,"classTotal":1934,"coverageRecordId":9579151,"env":"local","lineCovered":374,"lineRatio":0.0048,"lineTotal":77160,"methodCovered":115,"methodRatio":0.0069,"methodTotal":16618,"name":"mozi-im-gateway-private","nodeType":"APP","type":"unnit-test","updated":1,"updatedLineCovered":76,"updatedLineTotal":381,"updatedRatio":0.1995}
(16:56:13) *************************************************************
(16:56:13) CODE_COVERAGE_LINES: 374/77160
(16:56:13) CODE_COVERAGE_NAME_LINES: 行
(16:56:13) CODE_COVERAGE_BRANCHES: 49/29792
(16:56:13) CODE_COVERAGE_NAME_BRANCHES: 分支
(16:56:13) CODE_COVERAGE_METHODS: 115/16618
(16:56:13) CODE_COVERAGE_NAME_METHODS: 方法
(16:56:13) CODE_COVERAGE_CLASSES: 20/1934
(16:56:13) CODE_COVERAGE_NAME_CLASSES: 类
(16:56:13) CODE_COVERAGE_REPORT_LINES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_BRANCHES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_METHODS:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_CLASSES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_UPDATELINES: 76/381
(16:56:13) CODE_COVERAGE_NAME_UPDATELINES: 行增量
(16:56:13) CODE_COVERAGE_REPORT_UPDATELINES: http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) *************************************************************
(16:56:13) Execute case_result_parser plugin
(16:56:15) ignore submodules.
(16:56:15) Execute plugin command
(16:56:15) cd /root/cise/space/135192024/plugin/case_result_parser && ./parser -p "/root/cise/space/135192024/source" -d "false" -t "common" -u "https://testservice.aone.alibaba-inc.com/ak/testservice/api/pipelineResult/utApiCallBack/build/607b495e-3d6d-4a8d-a18f-3cdcd226f860/stage/单元测试"
(16:56:15) /root/cise/space/135192024/plugin/case_result_parser
(16:56:15) [INFO] Project: /root/cise/space/135192024/source
(16:56:15) [INFO] Image: /root/cise/space/135192024/source/images/-815718685
(16:56:15) [INFO] Start to parse
(16:56:16) [INFO] End parsing
(16:56:16) [INFO] Parsed test case count:53
(16:56:16) [INFO] Paased count: 53
(16:56:16) [INFO] Failed count: 0
(16:56:16) TEST_CASE_AMOUNT: {"blocked":0,"passed":53,"failed":0,"skipped":0}
(16:56:16) [INFO] Callback url: https://testservice.aone.alibaba-inc.com/ak/testservice/api/pipelineResult/utApiCallBack/build/607b495e-3d6d-4a8d-a18f-3cdcd226f860/stage/单元测试
(16:56:16) [INFO] Max data size: 45000000
(16:56:16) [INFO] Current data size: 8159
(16:56:16) log4j:WARN No appenders could be found for logger (org.apache.commons.httpclient.HttpClient).
(16:56:16) log4j:WARN Please initialize the log4j system properly.
(16:56:16) [INFO] Send result to server, time: 01
(16:56:17) [INFO] Response: {"success":true,"messages":[],"result":"https://aone.alibaba-inc.com/pipeline/build/detail?buildDetailId=156703515&pipelineId=2905023","errorCode":null,"other":null,"msgCode":null,"msgInfo":null,"message":""}
(16:56:17) TEST_REPORT: https://aone.alibaba-inc.com/pipeline/build/detail?buildDetailId=156703515&pipelineId=2905023

如果实在定位不到问题,可以看源码了解,可以看到 jacoco 插件是如何被植入 pom 文件的。

5.JaCoCo 插桩和覆盖率统计逻辑

Jacoco 的插桩原理是通过在方法表的 Code 区插入与代码覆盖率相关的执行代码,Jacoco 就可以感知字节码的执行过程。简单来讲,Jacoco 会对字节码文件进行四个修改:

  • 为类增加 $jacocoData 属性;
  • 为类增加 $jacocoInit 方法
  • 在每个方法开头创建一个 boolean 类型的数组,JaCoCo 利用这个数组来实现探针(Probe)
  • 每行代码都会有一个探针对应到此数组中,运行时修改 boolean 数组中的项来实现探针

在代码执行完对应 statement 后,会将数组对应探针位置修改为true。最终 Jacoco 通过各个类的探针数组数据计算代码覆盖率。

Jacoco 插桩后的字节码文件示例如下:

JacocoProbeTest.class
/* synthetic */
private static transient boolean[] $jacocoData;
// $jacocoInit 是 jacoco 生成的代码,以下为近似逻辑
private static /* synthetic */ boolean[] $jacocoInit() {
if ($jacocoData != null) {
return $jacocoData;
}
{
Object[] args = new Object[3];
// class id
args[0] = Long.valueOf(8060044182221863588);
// class name
args[1] = "com/example/JacocoProbeTest"; // probecount
args[2] = Integer.valueOf(4);
// Jacoco 的特殊方法,会修改args[0] 的值
new RuntimeData().equals(args);
$jacocoData = (boolean[])args[0];
}
return $jacocoData;
}
}
// 覆盖率统计,执行一行代码,即将 Probe 数组对应位置置位 true

public JacocoProbeTest() {
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
b_arr_1[0] = true;
}

public static void testSingleLineProbe() {
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
System.out.println("testSingleLineProbe");
b_arr_1[1] = true;
}

public static void testThrowExceptionProbe() {
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
System.out.println("testThrowExceptionProbe");
b_arr_1[2] = true;
b_arr_1[3] = true;
throw new IllegalArgumentException();
}

下面是 intellij-coverage-agent 覆盖率插桩的代码,它的埋点比起 jacoco 埋点使用了行号进行记录。

源代码:

插桩后的代码:

@Test
public void testGetHiddenMenuCodes() {
Object __class__data__ = ProjectData.loadClassData("com.alibaba.buc.acl.data.service.manage.impl.com.alibaba.buc.acl.data.service.manage.impl.ActionManageServiceImplTest");
ProjectData.touchLine(__class__data__, 140);
Set<String> hiddenMenuCodes = this.actionManageService.getHiddenMenuCodes(this.tenantId, this.appName);
ProjectData.touchLine(__class__data__, 142);
Assert.assertTrue(hiddenMenuCodes.containsAll(Arrays.asList(this.hiddenMenuCodes, "permissionManageGroup", "businessStripConfig", "divisionConfig", "stripOrgManager", "cooperationApplication", "cooperationList", "cooperationManagement")));
ProjectData.touchLine(__class__data__, 153);
}

6.JaCoCo 与 PowerMock 的冲突原理分析

下面是我在网络上找到的一段关于 JaCoCo 与 PowerMock 的冲突分析的说明。

JaCoCo 以 Javaagent 方式启动时,会为每一个加载进 JVM 的类插桩埋点。而 PowerMock 使用的 javaassist 接收到的某个类的字节码数据(原始字节码已经被其他非 javassist 的方式增强之后的字节码)和此类对应的 class 文件的数据不一致时,javassist 就会废弃掉接收到的已经被改变的字节码数据,转而使用此类最原始的 class 文件进行增强。这就导致 JaCoCo 埋入类字节码中的监控点被覆盖,导致其无法感知到被重新加载的类的执行过程,使得类的代码覆盖率结果为 0。

这段话有没有问题呢?真的是覆盖率统计代码被覆盖吗[1]?

进一步想一想,为什么在 IDEA 中运行时可以统计到代码覆盖率? 而在 Aone 却又未覆盖?

提示 1:PowerMock 调用 javaassist 修改类后是不是会再次触发 javaagent 的修改

提示 2:IDEA 运行覆盖率统计时,默认采用 intellij-converage-agent

提示 3:jacoco 生成覆盖率报告的方式,ClassID [2]

jacoco.exec 是记录覆盖率的二进制文件,有时候会命名为 coverage.exec。

先从 jacococli 命令行生成报告的参数做个推理。

参数中指定了覆盖率报告、类的字节码以及输出目录,可以推测 JaCoCo 会根据原始字节码与覆盖率数组做匹配。在 jacocoagent 和 PowerMock 同时使用的情况下,jacoco.exec 中记录的 ClassID 是两次增强后的字节码计算的结果,生成覆盖率报告时,根据原始字节码计算出的 ClassID 无法匹配 jacoco.exec 中的记录,直接判断为 noMatch,因此未生成有效的覆盖率报告。

这段计算逻辑可以在分析覆盖率报告的这个方法中看到[3],有兴趣可以下载源码分析。

jacoco 的官网中已经解释了为什么要使用字节码生成的 ClassID 作为类标识。

Why can't JaCoCo simply use the class name to identify classes?

To understand why JaCoCo can't rely on class names we need to have a look at the way how JaCoCo measures code coverage.JaCoCo tracks execution with so called probes. Probes are additional byte code instructions inserted in the original class file which will note when they are executed and report this to the JaCoCo runtime. This process is called instrumentation. To keep the runtime overhead minimal, only a few probes are inserted at "strategic" places. These probe positions are determined by analyzing the control flow of all methods of a class. As a result every instrumented class produces a list of n boolean flags indicating whether the probe has been executed or not. A JaCoCo *.exec file simply stores a boolean array per class id.At analysis time, for example for report generation, the *.exec file is used to get information about probe execution status. But as probes are stored in a plain boolean array there is no information like corresponding methods or lines. To retrieve this information we need the original class files and perform the exact same control flow analysis than at instrumentation time. Because this is a deterministic process we get the same probe positions. With this information we can now interfere the execution status of every single instruction and branch of a method. Using the debug information embedded in the class files we can also calculate line coverage.If we would use just slightly different classes at analysis time than at runtime — e.g. different method ordering or additional branches — we would end-up with different probes. For example the probe at index i would be in method a() and not in method b(). Obviously this will create random coverage results.

解决方案

目前比较主流的解决方法为使用 JaCoCo 的 offline 模式先对字节码文件进行插桩。在应用启动时加载进 JVM 的字节码文件就不需要动态插桩了,可以被 Powermock 正常增强。但是这种方式需要修改字节码文件,对业务的执行与发布过程有影响,需要测试完成后进行二次部署。

优化方向

因为 JaCoCo 生成覆盖率报告时依赖源码和原字节码,而 intellij-coverage-agent 则不需要,原因是它的插桩代码自带行数,因此不需要对照源码和原字节码进行分析,或许可以参考修改 JaCoCo 的插桩代码,但这比起修改一个数组的插槽,会带来一些性能损耗。

[1]https://github.com/powermock/powermock/wiki/Code-coverage-with-JaCoCo

[2]https://www.jacoco.org/jacoco/trunk/doc/classids.html‍‍

[3] org.jacoco.core.analysis.Analyzer#createAnalyzingVisitor

责任编辑:武晓燕 来源: 阿里技术
相关推荐

2017-01-14 23:42:49

单元测试框架软件测试

2024-04-01 08:26:30

单测覆盖率字节码

2015-08-07 11:07:21

IOS9code covera每天积累一点

2022-05-12 09:37:03

测试JUnit开发

2021-10-15 13:47:19

覆盖率检测 istanbul 总代码的比例

2023-10-27 08:49:00

JCovOpenJDK

2009-08-19 09:00:48

单元测试框架自动化测试

2012-04-11 11:21:57

ibmdw

2017-02-23 15:59:53

测试MockSetup

2022-08-25 06:27:39

vivoJaCoCo代码覆盖率

2009-06-01 10:47:32

jboss seam例jboss seam开jboss seam

2023-12-24 10:00:35

Java单元测试

2010-04-20 20:46:01

负载均衡

2011-06-20 17:25:02

单元测试

2015-11-09 17:56:57

WebPHP函数覆盖

2019-09-25 09:20:41

谷歌代码开发者

2016-01-13 10:14:15

WebPHP函数覆盖

2023-10-07 08:49:56

测试驱动开发Xunit 框架

2011-11-01 10:10:48

ScriptCover

2010-08-27 09:11:27

Python单元测试
点赞
收藏

51CTO技术栈公众号