0%

单元测试的一点思考

按照一贯风格,首先看单元测试这个词组的定义,然后根据定义,来看怎样写好单元测试,他的通用规范是什么用的。

一、单元测试定义

百度百科官方定义:

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。

维基百科官方定义:

在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

每个理想的测试案例独立于其它案例;为测试时隔离模块,经常使用stubs、mock[1]或fake等测试马甲程序。单元测试通常由软件开发人员编写,用于确保他们所写的代码符合软件需求和遵循开发目标。它的实施方式可以是非常手动的(透过纸笔),或者是做成构建自动化的一部分。

从定义,可以很清楚知道,单元测试是针对方法写代码进行自动化测试,并且,我们在做单元测试的时候,针对的是所有单元的对外接⼝,对外⾏为(即public),⽽不是关注于⼀些内部的实现或者内部逻辑。此外,从维基定义中介绍,一个理想的测试case,是针对单个方法做测试,其依赖的其他外部方法和模块,都应该做隔离的,一般方法是使用mock。

因此,总结一下,单元测试:

  • 针对类中的对外public方法;
  • 测试代码中,对于方法依赖的外部模块需要进行mock;

一般而言,一个方法,如果写单元测试非常困难,这说明这个方法设计实现的时候,存在一些问题,比如抽象的不好,内外耦合的严重等等,需要从这个方法下手去优化,而不是责怪单元测试。

至于代码覆盖率这东西,既然测试团队已经给了具体值,只能执行。其实针对各个业务,其对覆盖率的要求是不同的,具体可以参考下面这篇文章:

代码覆盖率最佳实践,谷歌权威推荐……

二、怎么写单测

回到正题,本文,主要是介绍如何写好单测。

2.1 单元测试边界

写单元测试之前,肯定先要知道哪些方法需要写单元测试。在定义里面,特别强调单测中单元,就是java类的public方法。因此,一般,针对项目中public方法,都应该写单元测试验证正确性。

但是,从定义触发,我们可以看到单元测试的目的是,验证“自己写的一小段代码是不是符合设计逻辑的“。在java项目中,有大量的POJO,DO,VO,Entity类等,这些类其实是没有逻辑的,因此是不需要写单元测试的。

同理,其他如果不包含逻辑的类,或者一些工具不是自己写的,那么也不需要对其写单元测试去验证正确性。

此外,单元测试方法中,应该验证的是本方法块的逻辑正确性。如果你在本方法中调用了依赖的外部方法或者底层方法,最佳实践方式就是将他们都进行mock。我们一直讲,我们面向接口编程,依赖的接口方法如果仅仅实现发生改变,依赖的调用方是不需要做调整的。那么,从另外一个方面说单测,对某个方法进行单测,如果依赖的基础设施发生改变或者不可用,不应该应该方法单测执行的。

额外说下,一般一个项目的单元测试,可以在任何网络下执行。因此,如果你依赖了外部网络服务,那么在网络受限或者权限受限环境下,这种单测是无法正常执行的。

因此,总结一下,单元测试的边界:自己写的有业务逻辑的代码方法块集合,并且测试范围仅限于本方法逻辑。

2.2 单元测试工具和配置

本文使用Junit 5 来构建单元测试。Junit 4 已经停止开发维护。5和4相比,使用上基本一致,只是一些方法做了调整。由于我们使用mockito作为mock工具,其在支持Junit 5上有些版本支持问题。

这里使用公司内部boot 默认的mockito版本,所以对应的Junit版本也限制了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<dependencies>
<!--引入Junit 5 和 配套的mockito-->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>2.18.3</version>
<scope>test</scope>
</dependency>
</dependencies>

为了简便,抽象了一个基类,做mockito初始化。

1
2
3
4
5
6
7
public abstract class BaseTest {

@BeforeEach
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
}
}

2.3 单元测试实践

2.3.1 业务单元测试

大部分情况下,我们都是在业务代码中,做单测。下面用一个项目中的接口来做单元测试。

业务代码如下(以下代码做了一些处理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

@Component("genericService")
@Slf4j
public class GenericServiceImpl implements GenericService {

@Resource
private ProgrammingTrafficLimitAdapter programmingTrafficLimitAdapter;

@Resource
private GenericInvokeHandler genericInvokeHandler;

@Override
public PlainResult<Object> invoke(ReferenceRequest request) {
// 定义特征
Map<String, String> features = new HashMap<>(2);
features.put("service", "com.xxx.pay.risk.yyy.api.GenericService.invoke");
if (request != null) {
StringJoiner sj = new StringJoiner(".");
sj.add(request.getInterfaceName()).add(request.getMethodName());
features.put("method", sj.toString());
}

// 执行限流计算
TrafficLimitDecision decision = programmingTrafficLimitAdapter.apply(features);

// 如果未被限流,则执行业务逻辑
if (!decision.shouldLimit()) {
return genericInvokeHandler.handle(request);
}

return failBack(request);
}


private PlainResult<Object> failBack(ReferenceRequest request) {
log.warn("指标查询接口触发限流, request = {}", request);
PlainResult<Object> plainResult = new PlainResult<>();
plainResult.setSuccess(false);
plainResult.setData(null);
plainResult.setMessage("此次查询被限流, 返回空数据");
return plainResult;
}

从上面的代码逻辑可以看出,只有有几个点,需要进行测试。

  1. 正常情况的返回。
  2. 参数为null的情况下的返回
  3. 限流情况下的返回
  4. 其他方法情况下异常(框架统一捕获,这里可以测试,也可以不需要)

因此,我们对该类的invoke方法单测有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
@DisplayName("测试通用请求接口的查询服务")
public class GenericServiceImplTest extends BaseTest {

@InjectMocks
private GenericServiceImpl genericServiceImpl;

@Mock
private ProgrammingTrafficLimitAdapter programmingTrafficLimitAdapter;

@Mock
private GenericInvokeHandler genericInvokeHandler;


@Nested
@DisplayName("测试generic接口下invoke方法")
class Test_Invokes {

@Test
@DisplayName("测试正常请求和返回场景下逻辑")
public void testInvoke() {

// 1. 数据准备
TrafficLimitDecision decision = new TrafficLimitDecision(false, "");
Mockito.when(programmingTrafficLimitAdapter.apply(Mockito.anyMap())).thenReturn(decision);

PlainResult<Object> result = new PlainResult<>();
result.setData("mock_test");
Mockito.when(genericInvokeHandler.handle(Mockito.any(ReferenceRequest.class))).thenReturn(result);

// 2. 执行
ReferenceRequest request = new ReferenceRequest();
request.setInterfaceName("com.xxx.pay.risk.yyy.api.GenericService");
request.setMethodName("invoke");
request.setParams(Lists.newArrayList(new GenericParamDTO()));
PlainResult<Object> plainResult = genericServiceImpl.invoke(request);

// 3. 验证
assertEquals("mock_test", plainResult.getData());
assertTrue(plainResult.isSuccess());

}

@Test
@DisplayName("限流场景被限流后测试")
public void testTrafficLimited() {

// 1. 数据准备
TrafficLimitDecision decision = new TrafficLimitDecision(true, "");
Mockito.when(programmingTrafficLimitAdapter.apply(Mockito.anyMap())).thenReturn(decision);

PlainResult<Object> result = new PlainResult<>();
result.setData("mock_test");
Mockito.when(genericInvokeHandler.handle(Mockito.any(ReferenceRequest.class))).thenReturn(result);

// 2. 执行
ReferenceRequest request = new ReferenceRequest();
request.setInterfaceName("com.xxx.pay.risk.yyy.api.GenericService");
request.setMethodName("invoke");
request.setParams(Lists.newArrayList(new GenericParamDTO()));
PlainResult<Object> plainResult = genericServiceImpl.invoke(request);

// 3. 验证
assertFalse(plainResult.isSuccess());
assertEquals("此次查询被限流, 返回空数据", plainResult.getMessage());
}

@Test
@DisplayName("参数请求为空情况下测试")
public void testNullRequest() {

// 1. 数据准备
TrafficLimitDecision decision = new TrafficLimitDecision(false, "");
Mockito.when(programmingTrafficLimitAdapter.apply(Mockito.anyMap())).thenReturn(decision);

// handler业务确实没有处理null情况,按照代码逻辑会抛空指针;这里按照预期应该做校验,抛参数错误 业务异常
RiskRuntimeException e = new RiskRuntimeException(CommonErrorCode.ILLEGAL_ARGUMENTS);
Mockito.when(genericInvokeHandler.handle(Mockito.nullable(ReferenceRequest.class))).thenThrow(e);

// 2. 执行
ReferenceRequest request = null;

// 3. 验证
Exception ex = assertThrows(RiskRuntimeException.class, () -> genericServiceImpl.invoke(request));
assertEquals("[errorCode = 122301400] 非法参数 ",ex.getMessage());
}

}

}

然后,跑完单侧,我们就有了如下单测结果,并且可以通过Run XXX with Coverage 获取单测覆盖率。

业务测试覆盖率

2.3.2 数据库单元测试

数据库单测,主要测试sql的正确性。单从覆盖率而言,并不会有提升,毕竟是基于interface+mybatis xml/annotation 的方式开发的。

下面给出数据库的单测示例,基于spring+h2内存数据库构建单测,由于要启动spring环境,所以单测速度比较慢(所以,在业务单测阶段,是不应该启动spring环境来支持测试用例的运行的):

三个基础类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Configuration
@EnableTransactionManagement(proxyTargetClass = true)
@MapperScan(basePackages = "com.xxx.pay.risk.xxx.infrastructure.dao",
sqlSessionFactoryRef = "testSqlSessionFactory")
public class DataSourceConfig {

@Bean
@Primary
@ConfigurationProperties("mybatis")
public MybatisProperties mybatisProperties() {
return new MybatisProperties();
}


@Bean("testDataSource")
public DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.generateUniqueName(true)
.setType(EmbeddedDatabaseType.H2)
.setScriptEncoding("UTF-8")
.addScript("risk/schema.sql")
.build();
}

@Bean("testTransactionManager")
public PlatformTransactionManager transactionManager(){
return new DataSourceTransactionManager(dataSource());
}

@Bean("testTxTemplate")
public TransactionTemplate txTemplate(){
return new TransactionTemplate(transactionManager());
}

@Bean("testSqlSessionFactory")
public SqlSessionFactory sqlSessionFactory() throws Exception {
return MybatisConfigurationSupport.sqlSessionFactoryOf(mybatisProperties(),dataSource());
}
}

上面切换成h2数据源,这样可以通过内存构造数据源,完成单测。
特别说明,单测很重要的一点是,运行不应该依赖任何外部环境和资源设定,并且可以随时随地运行测试用例,也能保持通过。因此,我们使用内存数据库完成数据sql相关测试。

1
2
3
4
5
@SpringBootApplication(scanBasePackages = "com.xxx.pay.risk.xxx.infrastructure.dao")
@Import(DataSourceConfig.class)
public class BaseTestSpringEnv {
}

这里构建基础的spring环境,应该我们只对dao的数据进行测试,这里scan范围限制在最小的dao包里面。

1
2
3
4
5
6
7
8
9

@RunWith(SpringRunner.class)
@SpringBootTest(classes ={ BaseTestSpringEnv.class}, webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("dev")
@Transactional(transactionManager = "testTransactionManager")
@SqlConfig(dataSource = "testDataSource", transactionManager = "testTransactionManager")
public class BaseTest {
}

最后,构建基础的测试基类,其他数据层测试类,继承该基类,即可开始编写测试代码,完成数据层的单元测试。

接下来,由于我们的单测是基于h2内存数据库,所以在测试之前需要做数据准备(业务测试中,是直接在代码中通过mockito mock 来完成数据准备的,也就是我们上面业务测试代码里面的第一步:数据准备):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//建表
create table `risk_core_indicator` (
`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL COMMENT '主键',
`type` VARCHAR(128) NOT NULL COMMENT '类型',
`value` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '数据值',
`extra` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '扩展字段',
`notice` INT NOT NULL COMMENT '是否已通知',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(精确到日)',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
);

//数据准备
INSERT INTO `risk_core_indicator`( id,type ,
value ,
extra,
notice,
created_at,
updated_at) VALUES (1,'kdt_in_secured_cnt', '53,689', '', 0, '2021-06-10 00:00:00.0', '2021-06-10 20:29:53.0');
INSERT INTO `risk_core_indicator`( id,type ,
value ,
extra,
notice,
created_at,
updated_at) VALUES (2,'kdt_in_secured_cnt', '53,689', '', 0, '2021-06-11 00:00:00.0', '2021-06-10 20:29:53.0');
INSERT INTO `risk_core_indicator`( id,type ,
value ,
extra,
notice,
created_at,
updated_at) VALUES (3,'kdt_in_secured_cnt', '53,689', '', 0, '2021-06-12 00:00:00.0', '2021-06-10 20:29:53.0');

最后就是针对DAO类的单测了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@DisplayName("测试指标查询数据sql")
@Sql(scripts = "/risk/indicator.sql")
class RiskCoreIndicatorDAOTest extends BaseTest {

@Resource
private RiskCoreIndicatorDAO riskCoreIndicatorDAO;

@Test
@DisplayName("测试按天查询列表")
void testListByDay() throws Exception {

List<RiskCoreIndicatorDO> indicators = riskCoreIndicatorDAO.listByDay(DateUtils.parseDate("2021-06-10", "yyyy-MM-dd"));

assertEquals(1,indicators.size());
assertEquals("53,689",indicators.get(0).getValue());

}

@Test
@DisplayName("测试删除数据")
void testDeleteByDay() throws Exception{

List<RiskCoreIndicatorDO> b_indicators = riskCoreIndicatorDAO.listByDay(DateUtils.parseDate("2021-06-11", "yyyy-MM-dd"));
assertEquals(1,b_indicators.size());

riskCoreIndicatorDAO.deleteByDay(DateUtils.parseDate("2021-06-11", "yyyy-MM-dd"));

List<RiskCoreIndicatorDO> indicators = riskCoreIndicatorDAO.listByDay(DateUtils.parseDate("2021-06-11", "yyyy-MM-dd"));
assertEquals(0,indicators.size());
}

@Test
@DisplayName("测试更新数据")
void testUpdateByDay() throws Exception{

List<RiskCoreIndicatorDO> b_indicators = riskCoreIndicatorDAO.listByDay(DateUtils.parseDate("2021-06-12", "yyyy-MM-dd"));
assertEquals(0, b_indicators.get(0).getNotice().intValue());

riskCoreIndicatorDAO.updateByDay(DateUtils.parseDate("2021-06-12", "yyyy-MM-dd"),1);

List<RiskCoreIndicatorDO> a_indicators = riskCoreIndicatorDAO.listByDay(DateUtils.parseDate("2021-06-12", "yyyy-MM-dd"));
assertEquals(1, a_indicators.get(0).getNotice().intValue());


}
}

另外专门写一下,引入的maven配置:

1
2
3
4
5
6
7
8
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

执行单测结果:

数据测试覆盖率

2.3.3 其他单元测试点

一、事务模板的mockito部分:

1
2
3
4
// 将template模板mock,执行里面的TransactionCallback具体方法     
Mockito.when(transactionTemplate.execute(Mockito.any(TransactionCallback.class)))
.then(invocation -> ((TransactionCallback) invocation.getArgument(0))
.doInTransaction(null));

二、异常的测试部分:

1
Assertions.assertThrows(RiskBizException.class, () -> xxxxBiz.addyyy(commandBo));

三、其他

三、 总结

系统的可观测性,在云原生时代非常的重要。可观测性,指的是,通过外部的输出来断定内部系统状态的运行情况。而,单元测试,其本质上,是我们在编码阶段的可观测性实施手段。

单元测试,我们通过给定的条件,去观察输出是否符合预期,来判断系统代码是否正确。因此,单元测试的实施,是开发同学,在最早期介入系统可观测性实施的初级手段。正确合理的执行单元测试,可以在最早期发现系统的问题,使得处理问题的成本最低。