按照一贯风格,首先看单元测试这个词组的定义,然后根据定义,来看怎样写好单元测试,他的通用规范是什么用的。
一、单元测试定义 百度百科 官方定义:
单元测试(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; }
从上面的代码逻辑可以看出,只有有几个点,需要进行测试。
正常情况的返回。
参数为null的情况下的返回
限流情况下的返回
其他方法情况下异常(框架统一捕获,这里可以测试,也可以不需要)
因此,我们对该类的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 () { 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); 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); assertEquals("mock_test" , plainResult.getData()); assertTrue(plainResult.isSuccess()); } @Test @DisplayName("限流场景被限流后测试") public void testTrafficLimited () { 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); 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); assertFalse(plainResult.isSuccess()); assertEquals("此次查询被限流, 返回空数据" , plainResult.getMessage()); } @Test @DisplayName("参数请求为空情况下测试") public void testNullRequest () { TrafficLimitDecision decision = new TrafficLimitDecision(false , "" ); Mockito.when(programmingTrafficLimitAdapter.apply(Mockito.anyMap())).thenReturn(decision); RiskRuntimeException e = new RiskRuntimeException(CommonErrorCode.ILLEGAL_ARGUMENTS); Mockito.when(genericInvokeHandler.handle(Mockito.nullable(ReferenceRequest.class))).thenThrow(e); ReferenceRequest request = null ; 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 Mockito.when(transactionTemplate.execute(Mockito.any(TransactionCallback.class))) .then(invocation -> ((TransactionCallback) invocation.getArgument(0 )) .doInTransaction(null ));
二、异常的测试部分:
1 Assertions.assertThrows(RiskBizException.class, () -> xxxxBiz.addyyy(commandBo));
三、其他
三、 总结 系统的可观测性,在云原生时代非常的重要。可观测性,指的是,通过外部的输出来断定内部系统状态的运行情况。而,单元测试,其本质上,是我们在编码阶段的可观测性实施手段。
单元测试,我们通过给定的条件,去观察输出是否符合预期,来判断系统代码是否正确。因此,单元测试的实施,是开发同学,在最早期介入系统可观测性实施的初级手段。正确合理的执行单元测试,可以在最早期发现系统的问题,使得处理问题的成本最低。