测试是保障软件质量的关键,对一个成熟稳定的产品而言开发的质量未必一定很高,但绝对是非常注重测试以把守好发布上线的最后一关。
我们先从最基础的单元测试说起,单元测试顾名思义,它要求的是测试单元化,针对某个点测试,一般都是方法级别,力求将测试的干扰控制在最小粒度,但我们现实环境下很多的逻辑都不是函数化的,会有很多的外部交互,如数据库、MQ、缓存等,函数化意味着什么样的输入决定什么样的输出,比如我们一些核心的算法就必须要有此保证,这种函数化的方法非常容易构建单元测试,因为影响结果的因素只来自输入参数,但更多的场景会是诸如传入一个请求,先判断请求是否合法(参数校验),通过后查询一段逻辑,再写数据库、写缓存,完成后发个MQ事件,最后返回结果。此时我们发现如果要为这样的方法构建单元测试会变得异常困难,因为影响结果的因素太多了,传入参数、数据及缓存中已有数据的干扰、数据写入一致性冲突(如唯一索引)等都会左右测试结果,此时从开发角度看我们可以进行一步的方法优化,将一个方法的拆分成多个方法以避免一个方法中存在过多逻辑、去掉全局变量,但这未能解决单元测试的核心问题:如何控制除传入参数外其它影响返回结果的不确定因素
。而最麻烦的不确定因素就是各中间件,常见于数据库、缓存、MQ,这些中间件的历史数据或单元测试时交叉并发产生的数据(如多个人在跑同一个单元测试或是同时跑不同单元测试但产生了相互影响的数据)都是单元测试所要杜绝的。我们对应大概有三个方案:
- 使用真实环境,执行自动清理 以DBUtil为代表,这类工具会使用真实的中间件,但在测试完成后执行自动清理工作,还原测试中变更的数据,这一方案会影响单元测试的独立性,测试时准备外部环境,对持续集成中的自动化测试会有比较大干扰,如无必要最好不要使用
- 使用模拟环境 以Mockito为例,这类工具会要求定义Mock的类型及对应方法的期望返回,核心的代码示例如下:
这一方案解决了上一方案的问题,使单元测试更为内聚,是比较理想的手段,它的不足在于需要针对性地定义Mock代码,对复杂逻辑而言不是很友好,更为严重的是它无法发现由中间件引发的数据问题,例如在一段代码中由于开发失误连续调用了两次相同的插入数据命令,实际环境下要返回主键冲突,但Mock下就不容易发现// 定义要Mock的对象 private UserDao userDao=mock(UserDao.class); // 定义方法及模拟的返回 when(userDao.findAll()).thenReturn(10); // 测试 assertEquals(10, userDao.findAll());
- 使用内嵌的可替代环境 比如线上是MySQL,测试时使用H2,Redis缓存测试时可使用embedded-redis等,这一方案的好处是测试完全不用加任何Mock代码,非常干净,同时又可以比较好地模拟真实的环境,缺点在于有些中间件没有相应的内嵌版本。笔者整理了主流中间件对应的内嵌环境: | 中间件 | 内嵌环境 | 备注 | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 各类关系型数据库 | H2 | 可替代主流的标准SQL语法 | | Redis | embedded-redis | 引用的是原生Redis到tmp目录,支持Redis的版本 | | Kafka | kafka-unit | 支持的Kafka版本,注意它有一些限定,比如auto.create.topics.enable=false,如要定制可修改info.batey.kafka.unit. KafkaUnit类 | | Rabbit | embedded-rabbitmq | 笔者没做过大范围的使用,内嵌版本的Rabbit比较麻烦,需要安装Erlang环境 | | Mongo | de.flapdoodle.embed.mongo | 支持Mongo版本 |
除关系型数据库用H2替换外,其它几个内嵌工具都是使用真实的中间件进行包装(比如Windows下embedded-redis在每次单元测试时会创建/tmp//redis-server-X.exe的文件),所以可以很好地还原功能。笔者比较推崇这一方案,项目中我们也特意做了相关的优化,比如我们的开源的Dew框架(下文会有介绍)统一了MQ的行为,无论是Rabbit、Kafka还是Redis,对外提供的都是相对一致的接口,这样我们线上可以配置成Rabbit或Kafka,测试时选择Redis,用embedded-redis来模拟MQ操作。
总结一下,单元测试的要点是尽可能做到:
- 目标功能单一 一个测试只针对一到几个功能
- 减少数据干扰 这是上文着重强调的点
- 降低环境依赖 要求可以在开发人员自己环境执行,也必须可以在CI(持续集成,后续会有介绍)下执行
- 编写简单 单元测试规范的项目其测试点会覆盖所有的核心方法,其工作量很大,所以必须要简单化可修改
与其它的测试不同,单元测试要求由开发人员编写,它是测试的第一步也是非常重要的一步,良好的单元测试可以尽早地发现绝大部分的问题,一般流程下只有通过单元测试的功能才能提交转测单转由测试人员进一步验证。
下一节:单元测试的重要性不言而喻,这是所有测试的第一道关,是所有测试的基础。在微服务下会有很多个服务,对各服务组件自身的测试也会很重要,组件测试会覆盖当前服务的所有对外接口实现对各个服务的功能边界验证。单元测试和组件测试可以很好地发现各单点或各服务的问题,但它无法检查系统的流程行为,而这就需要集成测试来验证组件之间的通信路径和交互,集成测试关注的一次请求或一个业务流程是否正常,集成测试多针对接口,某些情况下我们可能会特别关注用户交互,而UI测试(或End-To-End测试)可以比较好发现这类问题。