单元测试框架深入(二):有哪些编写单元测试的常用工具和方法?

393 阅读7分钟

一、Mock框架:Mockito

  1. Mockito既可以mock类或接口的全部方法,也可以只mock部分方法并让其它方法使用真实对象的方法,同时还可以验证mock方法的调用情况。

官网:site.mockito.org/

API文档:javadoc.io/doc/org.moc…

Tutorial:javadoc.io/doc/org.moc… dzone.com/refcardz/mo…

FAQ:github.com/mockito/moc…

引入Maven依赖:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>1.10.19</version>
</dependency>

创建mock对象:

RedirectVerifyCache redirectVerifyCache = Mockito.mock(RedirectVerifyCache.class);

Mockito既可以mock类也可以mock接口;

mock对象所有方法的默认行为是返回该方法返回类型对应的默认值,可以通过打桩来改变;

mock对象一旦创建,对这个对象的所有操作都会被记录,可以用来验证方法的调用情况。

创建spy对象:

RedirectVerifyCache realObject = new RedirectVerifyCache();
RedirectVerifyCache spyObject = Mockito.spy(realObject);

Mockito支持spy对象,只对部分方法打桩,让其它未被打桩的方法调用真实对象的方法;

Mockito在创建spy对象的时候,实际上是对真实对象进行了一次拷贝。

2、打桩(stubbing):定义mock方法的行为

支持两种打桩方式:“when.. thenXXX”和“doXXX..when”

Mockito.when(redirectVerifyCache.isValidSrcPkg(Matchers.anyString())).thenReturn(true);Mockito.doNothing().when(redirectVerifyCache).afterPropertiesSet();

(1)when.. thenXXX

Mockito.when()方法接收mock方法的返回值,这里传入对mock方法的调用;

Mockito.when()方法返回一个OngoingStubbing对象,这里调用它的thenXXX方法来定义mock方法的行为;

thenXXX支持如下方法:

Mockito会根据when方法入参的类型来对thenXXX的方法值做编译期类型检查,保证类型的一致性。所以,这种方式适合用来对返回值非void的方法打桩;

返回值为void的方式无法不能使用when.. thenXXX,只能用doXXX..when。

(2)doXXX..when

Mockito.doXXX方法返回一个Stubber对象;

Stubber对象的when方法接收mock对象,并返回mock对象;

最后再调用mock对象中被打桩的方法;

其中,doXXX所支持的方法与thenXXX类似:

doXXX..when可以用来打桩返回值为void的mock方法;

对Spy对象中方法的打桩建议用doXXX..when方式,因为when.. thenXXX方式会在实际打桩之前调用mock方法,而spy对象中方法在打桩之前的默认行为是调用真实对象的方法——触发了一次真实调用。

(3)“when.. thenXXX”和“doXXX..when”中mock方法的参数匹配

默认情况下,对mock方法参数的匹配使用对象的equals方法,只能匹配一个值;

Mockito对匹配一组值提供了多种支持,如anyInt(), anyThat(), isA()等:

查阅更多,点击这里和这里。

(4)自定义Answer

“when.. thenXXX”和“doXXX..when”分别提供了thenAnswer(Answer answer)和doAnswer(Answer answer)方法,可以用来自定义返回值,通常比较复杂的测试用例才会用到;

when(appConfig.getIntProperty(anyString(), anyInt())).thenAnswer(new Answer<Integer>() {
    @Override
    public Integer answer(InvocationOnMock invocation) throws Throwable {
        Map<String, Integer> map = new HashMap<>();
        map.put("intent.default.ability", 1);
        map.put("intent.black.ability", 1);
        map.put("ext.verify.s1", 0);
        map.put("ext.verify.s2", 1);
        return map.get(invocation.getArgumentAt(0, String.class));
    }
});

在自定义的Answer中,可以访问mock方法被调用时的入参。

3.验证(Verifying):验证mock方法的调用情况

Mockito除了提供打桩能力,还提供了验证mock方法调用情况的能力(Mockito会记录mock对象的所有操作)。如果mock方法的调用情况不符合预期,当前测试用例就会被标记为失败。

验证mock方法被调用的次数:

RedirectVerifyCache redirectVerifyCache = Mockito.mock(RedirectVerifyCache.class);
Mockito.when(redirectVerifyCache.isValidSrcPkg(Matchers.anyString())).thenReturn(true);


redirectVerifyCache.isValidSrcPkg("some.src.pkg.name");


Mockito.verify(redirectVerifyCache, Mockito.times(1)).isValidSrcPkg(anyString());

Mockito支持多种指定次数的方式:

除了验证mock方法被调用的次数外,Mockito还支持:

验证mock方法的执行顺序;

验证mock方法被调用时所传的参数值;

验证方法执行时间不超过指定值。

4.Mockito的局限性

Mockito基于动态代理,使用继承的方式来生成代理对象,不支持mock下面这些元素:

Final类、枚举、final方法、静态方法、私有方法、hashCode方法和equals方法。

Mockito最新版本正在尝试提供某些特性,如mock final类和final方法,不过目前还只是试验版本。查阅详细,点击这里。

如果在写测试用例时遇到这些问题,可以尝试重构业务代码,让业务代码更加可测。如果确实必要,可以使用PowerMock框架。

二、Mockito框架的补充:PowerMock

PowerMock基于字节码操纵和自定义类加载器,在测试用例执行的时候运行时修改字节码,主要解决一些用常规mock框架无法处理的测试问题。通常建议慎用powermock框架。

PowerMock支持mock静态方法、构造器、final类、final方法和私有方法;支持移除静态初始化块;支持访问私有属性,获取对象的中间状态等。

官网:github.com/powermock/p…

API文档:

www.javadoc.io/doc/org.pow…

Tutorial:github.com/powermock/p…

github.com/powermock/p…

FAQ:github.com/powermock/p…

引入Maven依赖:

<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-module-junit4</artifactId>
    <version>1.7.1</version>
    <scope>test</scope>
</dependency>


<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-api-mockito</artifactId>
    <version>1.7.1</version>
    <scope>test</scope>
</dependency>

在测试类加上@RunWith(PowerMockRunner.class) 注解和 @PrepareForTest注解:

@RunWith(PowerMockRunner.class)
@PrepareForTest(ConfigService.class) //mock方法所在的类
public class TokenServiceTest {


}

Pwoermock提供了PowerMockito工具类来创建mock类,而打桩和验证仍然可以使用Mockito提供的静态方法:

PowerMockito.mockStatic(ConfigService.class);
Mockito.when(ConfigService.getAppConfig()).thenReturn(appConfig);

查阅更多用法,点击Tutorial。

三、如何处理与RPC服务有关的单元测试?

参考《202005 朱檐 单元测试》

我们的项目中有些地方对RPC的调用是以异步调用的方式进行的,最终从RPC上下文获取调用结果。

用when.. thenAnswer的方式mock RPC服务:

Mockito.when(appServiceV2.queryDetailForChangePkg(Mockito.anyString(), Mockito.any())).thenAnswer(new AppDetailByPkgAnswer());

自定义Answer时,把返回值也放一份到RPC上下文中:

public class AppDetailByPkgAnswer implements Answer<AppDetail> {


    @Override
    public AppDetail answer(InvocationOnMock invocation) throws Throwable {
        AppDetail appDetail = null;


        String pkgName = invocation.getArgumentAt(0, String.class);
        if (AppDetailAnswer.APP_DETAIL_9223372036854775806.getPkgName().equals(pkgName)) {
            appDetail = AppDetailAnswer.APP_DETAIL_9223372036854775806;
        }


        FutureAdapter<Object> futureAdapter = new FutureAdapter<>(new MockResponseFuture<>(appDetail));
        RpcContext.getContext().setFuture(futureAdapter);
        return appDetail;
    }
}

四、Spring对单元测试的支持

  1. 我们的项目都是建立在Spring的基础上,代码风格受Spring影响,比如@Autowired字段注入和IoC容器依赖等。Spring本身也提供了对单元测试和集成测试的支持,提供了一些非常实用的工具。

官网:

docs.spring.io/spring/docs…

引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <version>1.5.3.RELEASE</version>
    <scope>test</scope>
</dependency>

spring-boot-starter-test已经包含了Mockito等框架:

  1. 通用的测试工具类

Org.springframework.test.util包下提供了一组通用的测试工具类,如反射工具类ReflectionTestUtils和APO工具类AopTestUtils等;

ReflectionTestUtils支持修改常量值、读写非public字段和调用非public方法等,可以用来处理被测对象的@Autowired私有字段注入、私有生命周期方法等依赖问题。

@Component
public class TokenService {
    
    @Autowired
    private RedirectVerifyCache redirectVerifyCache;


    @Autowired
    private CpdInfoData cpdInfoData;
 
RedirectVerifyCache redirectVerifyCache = Mockito.mock(RedirectVerifyCache.class);
when(redirectVerifyCache.isValidSrcPkg(anyString())).thenReturn(true);
ReflectionTestUtils.setField(tokenService, "redirectVerifyCache", redirectVerifyCache, RedirectVerifyCache.class);

3、为测试用例提供Spring容器支持:Spring TestContext Framework.

集成测试以及有些单元测试需要启动Spring容器,从而让测试对象和测试方法能够访问Spring容器中的bean。

(1)Spring TestContext Framework的使用

在测试类上添加如下2个注解:

@RunWith(SpringRunner.class)
@SpringBootTest(classes=Main.class)
public abstract class IntegrationTestBase {


}

可以通过@SpringBootTest注解的classes参数指定Spirng容器的启动配置,这里指定为跟业务代码一样的配置。如果classes参数缺省,将默认按照如下规则搜索配置:

从包含该测试类的包开始向上搜索,直到找到一个由@SpringBootApplication或@SpringBootConfiguration注解的类,使用该类的配置。

在测试类中使用Spring容器的依赖注入,获取容器中的bean

@RunWith(SpringRunner.class)
@SpringBootTest(classes=Main.class)
public abstract class IntegrationTestBase {


    @Autowired
    private RedirectVerifyCache redirectVerifyCache;


    @MockBean
    private AppServiceV2 appServiceV2; 

其中,@MockBean注解创建mock对象并用该mock对象替换Spring容器中所有同类型的bean。可以在测试用例运行之前用Mockito框架对mock对象打桩。

(2)测试对象/测试方法与Spring TestContext

1)如果一个测试类中有多个测试方法,JUnit会为每个测试方法各创建一个测试对象;

2)Spring TestContext不参与测试对象的创建,测试对象也不会被放入Spring TestContext;

测试对象中的依赖注入是由DependencyInjectionTestExecutionListener完成的。

(3)Spring TestContext的缓存机制

1)从理论上来说,各个测试方法彼此独立,Spring TestContext Framework会为每个测试方法各提供一个Spring TestContext;

2)由于Spring TestContext的启动是一个非常耗时的过程,Spring TestContext Framework会缓存已启动的Spring TestContext,缓存所用的key是用于加载它的配置参数组合(包括xml文件或配置class等);

3)如果两个测试方法在同一个JVM运行并且使用相同的配置启动容器,Spring TestContext Framework事实上给它们提供的是同一个Spring TestContext;

4)可以在测试类或测试方法上加上@DirtiesContext注解,让Spring移除缓存中的Spring TestContext并在运行下一个测试用例时重新启动一个Spring TestContext。

五、如何处理与数据库有关的单元测试?

  1. 建议使用内存数据库,一方面让单元测试不受外界环境影响,另一方面避免单元测试污染真实数据库(尤其是现在开发环境直接连测试环境数据库)。

Spring提供了对内存数据库的支持,原生支持HSQL、H2和Derby等3种类型的内存数据库。

引入Maven依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
    <scope>test</scope>
</dependency>
  1. 获取数据源

以H2数据库为例,引入Maven依赖:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.200</version>
    <scope>test</scope>
</dependency>

方式1:XML方式

<jdbc:embedded-database id="dataSource" generate-name="true">

<jdbc:script location="classpath:schema.sql"/>

<jdbc:script location="classpath:test-data.sql"/>

</jdbc:embedded-database>

其中,schema.sql和test-data.sql分别是用于建表和初始化数据库的SQL脚本;此时一个类型为javax.sql.DataSource的bean将被注入到Spring容器中。

或者用下面这种方式:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:jdbc="http://www.springframework.org/schema/jdbc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc-4.3.xsd">


    <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
        <property name="driverClassName" value="org.h2.Driver"/>
        <property name="url" value="jdbc:h2:mem:appstore_resource;mode=mysql;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1"/>
    </bean>


    <jdbc:initialize-database>
        <jdbc:script location="classpath:mappers/resource/schema.sql"/>
    </jdbc:initialize-database>


</beans>

“mode=mysql”指定H2数据库以MySQL模式启动,避免产生SQL语法兼容问题。

方式2:编程方式

EmbeddedDatabase db = new EmbeddedDatabaseBuilder()

.generateUniqueName(true)

.setType(H2)

.setScriptEncoding("UTF-8")

.ignoreFailedDrops(true)

.addScript("schema.sql")

.addScripts("user_data.sql", "country_data.sql")

.build();

// perform actions against the db (EmbeddedDatabase extends javax.sql.DataSource)

db.shutdown()

这种方式不依赖于Spring容器,其中EmbeddedDatabase为javax.sql.DataSource的子类。

  1. 执行SQL语句/脚本

方式1:借助直接操作数据库的工具类

1)org.springframework.test.jdbc包下的JdbcTestUtils提供了一些直接进行数据库操作的静态方法:

countRowsInTable(..): 查询指定表中的记录条数;

countRowsInTableWhere(..): 查询指定表中符合where条件的记录条数;

deleteFromTables(..): 删除指定表中的所有记录;

deleteFromTableWhere(..): 删除指定表中符合where条件的记录条数;

dropTables(..): 删除指定的表。

例如:

int result = intentTokenMapper.insertIntentToken(intentToken);
int row = JdbcTestUtils.countRowsInTableWhere(new JdbcTemplate(dataSource), "intent_token", "valid_state=1");

2)执行SQL脚本的工具类

org.springframework.jdbc.datasource.init.ScriptUtils

org.springframework.jdbc.datasource.init.ResourceDatabasePopulator

org.springframework.test.context.junit4.AbstractTransactionalJUnit4SpringContextTests

org.springframework.test.context.testng.AbstractTransactionalTestNGSpringContextTests

这些工具类都需要传入一个JdbcTemplate类型的参数,用于指定数据源。这种方式不依赖于Spring容器。

方式2:使用Spring容器中的@sql注解

@Test
@Sql(scripts = {"classpath:mappers/resource/insert_test_intent_key.sql"}, executionPhase = BEFORE_TEST_METHOD)
@Sql(scripts = {"classpath:mappers/resource/cleanup.sql"}, executionPhase = AFTER_TEST_METHOD)
public void testQueryKey() {
    List<IntentKeyEntity> intentKeyEntities = intentTokenMapper.queryKey(0, 10);


    assertEquals(1, intentKeyEntities.size());
    assertEquals(1, intentKeyEntities.get(0).getType().intValue());
    assertEquals("c97a9e97ddd4096c", intentKeyEntities.get(0).getPrivateKey());
}

@sql注解可以指定执行SQL语句或SQL脚本,默认使用Spring容器中的数据源,还需要指定在测试方法之前还是之后执行相应的SQL脚本。