2019 2019年6月17日 16 min · 5.0k chars by yxc023
如何有效的书写项目单元测试
NOTE: 本文为原创, 转载请注明出处 https://blog.yangxiaochen.com
前言
// 不知道大家知否会在业务项目里写单元测试, 来测试业务逻辑和流程的正确性?
// 从我工作的经历来看, 很少遇到有开发者这么做.
// 是什么原因呢?
// * 依赖于其他组件, 比如 db, redis, zk, mq 等. // * 依赖于其他系统, 比如账户中心, 支付中心. // * 业务数据难以构造, 通常需要特定数据才能测试某一场景, 而生成特定数据的前置流程很长.
// 基于以上原因, 造成测试写起来很费劲, 有很难做到可重复执行.
我近两年在几个业务项目中都尝试编写了单元测试, 用于保障业务逻辑的正确性.
这里所说的单测, 并不是常规的对单一小方法的测试, 而是对业务流程的测试.
在这个过程中, 遇到了很多问题, 也总结了一些经验, 所以写下了这篇文章, 感觉是一套比较有效和有参照意义的方法论.
如何开始写单测
第一次写单测, 是一个审批流业务相关的项目, 内部业务逻辑很复杂, 分支判断多. 不同的审批流配置, 最终审批流运行时的表现是不一样的. 在当时的开发和测试过程中, 就遇到了问题:
-
每修改一个功能或者加一个特性, 测试需要全流程来测试, 而且我还在不断重构代码, 测试的自动化 case 修改速度还跟不上我的功能开发速度.
-
另外, 我在开发时也需要自测, 我需要从头构建一个审批流配置, 然后发起流程, 走到我新加了特性的节点, 才能验证我的功能. 当发现验证失败后, 就找问题, 修改代码, 然后重复这个过程, 效率及其低下.
于是, 我就写了一套从定义审批流配置, 到后续审批节点操作, 整个流程的测试代码. 用于一键自测.
后来, 因为不同的配置会对逻辑有影响, 所以我准备了多套审批流程配置, 也就是多个测试用例.
后面, 又进行了优化和完善, 最终达到了以下目标:
- 可重复执行
- 易书写
这两点, 是单测的灵魂.
可重复执行, 保证了单测并非一次性的, 每次测试数据是相同的. 如果每次测试都是不一样的数据, 需要人工修改, 那就没有意义.
易书写, 只有易书写, 单测的编写才能持续下去. 如果编写一个 case 总是需要很长时间, 谁都没有太大动力去维护和添加单测.
// 之后, 每当业务进行了修改, 代码进行了重构, 跑一遍单测, 总是能发现遗漏的问题, 造成若干单测错误. 修改后, 单测全部跑通, 心理就非常有底了.
测试带来的好处
我之所以坚持给业务项目写单元测试, 是因为我确确实实尝到了甜头:
- 开发效率提高
开发中验证功能, 通过一键测试就可以. 不用启动项目, 手动构造数据, 再进行触发.
- 测试效率提高
开发人员是最了解代码的, 开发人员写的单元测试相当于做了白盒测试, 提前检查了问题, 也减少了漏测.
- 提升了重构的动力和信心
重构风险大? 是的, 改动那么大, 心里怎么才能有底? 重构完成后, 单测一跑全都过了, 别样的舒爽, 重构不害怕.
- 对业务逻辑进行 review
书写单测的过程, 也是对自己业务逻辑 review 的过程. + 如果构建一个单测来完成业务流程都非常费力, 这个代码结构, 逻辑结构一定是有问题的.
总而言之, 最直观的感受就是: 漏测率大大下降; 重构引发的问题几乎没有; 单测跑通后, 在 qa 那边基本不会有大的逻辑上的 bug.
下面我就罗列一些点, 来说一说如何写单元测试. 这些点都可以归纳到达成 [可重复执行] 或 [易书写] 的目标中.
如何写业务单元测试
我所做的项目都是 java 项目, 使用的都是 spring boot 框架, 所以测试也是使用 spring boot test 来进行项目启动和测试的.
具体的实现方式是跟语言框架相关的, 但是思想是通用的.
为单测准备独立的环境 [可重复执行]
因为项目总是会依赖一些基础服务环境, 比如 数据库, redis, zk 等. 我的一个原则是, 单测时使用独立的一套环境, 而不是跟开发和测试时用一套环境, 并且, 这个环境在每次单测启动时, 是要进行初始化的, 保证每次单测的初始数据是完全一致的.
介绍几个我用过的方式:
- mysql
单独建立一个 unittest 的空数据库. 使用 Flyway 来管理这个项目的所有 sql 初始化语句. 当测试用例启动时, 清空这个库, 执行所有的初始化语句, 并且将项目中的数据源改为这个库.
- redis
使用单独的 redis 实例或者 db. 在每次启动时 flushdb.
- zk
zk 可以用单独的服务或者更换节点域. 更简单是使用 zk 的 test 框架, 可以直接在测试启动时, 运行一个本地的测试 zk 服务.
总之, 最终目的是保证单测的环境及初始数据每次都是一致的, 且可预期的.
使用表达力更强的语言来书写测试 [易书写]
我使用 groovy 来给 java 的项目写测试.
- 能够无缝使用 java 的代码
- 表达力更强, 更易书写.
能够字面式的初始化对象, 列表, map. + 弱类型, 且语法足够灵活, 简练.
创建单元测试基础类 [易书写]
每个项目的业务是不一样的, 但在一个项目内部, 业务和功能大多领域是一致的.
创建一个 单测基础类 , 里面包含这个项目中比较通用的功能或者业务组件, 让所有的测试类都继承这个基础类, 都能够放点的调用自己需要的功能.
下面是一个 单测基础类 的简单的例子, 设置了测试的启动环境, 并定义了一些公用变量, 方便子类调用.
[source, java]
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@RunWith(SpringRunner)
@SpringBootTest(
classes = TestBoot,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = ["spring.profiles.active=local,unittest"]) // <1>
@ContextConfiguration(initializers = [TestZookeeperServerInitializer, TestDBinitializer, TestRedisinitializer]) // <2>
abstract class AbstractTest {
@LocalServerPort
Integer port // <3>
@Autowired
Environment environment
ObjectMapper objectMapper = new ObjectMapper()
static User USER1 = new User(10000001, "user1's name", "18812388888", "org101") // <4>
...
<1> 加载测试时加载 unittest 配置文件
<2> 用于环境初始化的组件
<3> 项目使用的随机端口
<4> 通用的用户
数据模板
数据是每个测试用例的核心, 如何方便的构造测试数据, 是一个项目测试是否可持续的关键.
我的方法是, 将项目中关键的领域对象, 实例化一个或多个 数据模板, 数据模板的数据都是默认数据. 通过修改模板中的数据, 来达到构造不同数据 case 的效果. 最后将模板生成为初始数据.
整个过程的分 3 步:
Template template = defaultTemplate() // <1>
template.field1 = case1 // <2>
template = saveTemplate(template) // <3>
<1> 获取默认数据 <2> 在默认数据基础上构造 case <3> 生成初始化数据
拿一个商店的项目作为例子, 当我想测试下单流程, 我需要有现成的商品才行, 而不同的商品的下单流程逻辑中有不同的分支. 简化的代码如下 [source, java]
class OrderTest extends AbstractTest {
ProductTemplate productTemplate
void pre() {
// 获取初始化的产品数据
// 包括产品的基本信息, 店铺信息, sku 信息, 购买时的限制策略等
productTemplate = defaultProduct() // <1>
productTemplate.productDomain.merchantCode = 'test-shop'
productTemplate.productDomain.scopeIds = [1, 2, 3] // <2>
productTemplate.productDomain.attributes += [size: 30]
productTemplate.skuDomains[0].productSku.salePrice = 100
productTemplate = saveProduct(productTemplate) // <3>
adjustInventory(productTemplate.skuDomains[0].skuId, 10) // <4>
adjustInventory(productTemplate.skuDomains[1].skuId, 10)
}
<1> 获取初始化的产品数据. 这个 defaultProduct() 是写在 单测基础类 里的 便捷方法
<2> 对初始化数据进行修改
<3> 生成初始化数据. 这个 saveProduct() 是写在 单测基础类 里的 便捷方法
<4> 对初始化好的数据进行操作, 这里是调整了库存. 这个 adjustInventory() 是写在 单测基础类 里的 便捷方法
这是一个测试下单逻辑的前置数据生成逻辑, 可以方便的构造各种产品 case.
TIP: 能够方便的生成初始数据, 是代码业务逻辑合理的表现. 当原有业务代码比较糟糕, 写测试的时候也会非常的困难
业务操作便捷方法
上面的例子中, 已经出现了 便捷方法.
便捷方法 的意图, 是对业务中的操作进行简化. 有可能我们测试逻辑, 需要很多前置逻辑, 比如: 退款逻辑. 当要测试退款逻辑时, 我们需要前置的一些列逻辑. 通过创造 便捷方法, 对原有业务代码封装, 简化参数传递, 方便在测试中完成前置动作, 集中精力测试我们要测试的逻辑部分.
便捷方法 定义在 单测基础类 中, 供所有测试类使用.
TIP: 当然, 如果业务操作并不复杂, 也可以直接调用原有业务代码
列举一些我在项目中定义的一些 便捷方法:
[source, java]
abstract class AbstractTest {
ProductTemplate defaultProduct()
ProductTemplate saveProduct(ProductTemplate productTemplate)
ProductDomain getProductDomain(Long productId) // <1>
SkuDomain getSkuDomain(Long skuId)
void adjustInventory(Long skuId, Long count)
Long createOrder(CreateOrderBizParam param)
OrderDomain getOrderDomain(Long orderId)
void payOrder(Long orderId)
void payCallback(Long orderId) // <2>
...
<1> 获取产品和 sku 信息 <2> 模拟支付回调, 让订单到达支付完成状态
mock 方法
一个项目总是会依赖其他的系统, 在单测时经常无法正常使用其他服务的接口, 比如你提供的测试用户在其他服务中根本找不到, 你的单测环境不能通过其他服务的访问验证, 还有就是单元测试不能给其他服务写入无用的测试数据.
这是我们就需要用到 mock 方法.
在 spring boot test 中, 提供了一种 mock 手段, 让我们能够使用一个 mock bean 来替换 spring 容器中特定的一个 bean.
当我们的业务逻辑执行这个特定的 bean 的方法时, 实际执行的是我们 mock bean 的对应方法.
看一个简单实例:
[source, java]
abstract class AbstractTest {...
@MockBean
UserCenterRpc userCenterRpc // <1>
static User USER1 = new User(10000001, "user1's name", "18812388888", "org101")
void mockUserCenterRpc() {
Mockito.when(userCenterRpc.getUser(10000001)).thenReturn(USER1) // <2>
}
<1> 对系统中的 UserCenterRpc 类型的 bean 声明 mock. 这时系统中原有的 UserCenterRpc 类型的 bean 会被生成的 mock bean 给替换
<2> 定义 mock bean 的行为. 之后, 当业务逻辑执行到 userCenterRpc.getUser(10000001) 时, 将不会执行真正的 user center rpc 操作, 而是直接返回我们给定的 USER1, 达到 mock 的效果.
.是否要对所有的依赖调用进行 mock?
显然, 这是一个非常繁琐的操作, 一定程度上违反了 “易书写” 的原则.
我的观点: 能不用依赖就不用依赖.
如果依赖提供的测试环境稳定, 依赖方能够一直提供你所需要的初始数据, 并且依赖方允许无意义的测试写入, 直接进行真是的依赖是最简单的方案.
当无法持久稳定的提供我们测试所需要的功能时, 并且 mock 能够提供最方便, 再选择 mock.
所以 mock 是一个解决依赖问题的手段, 并不是个强制性的规则.
测试哪些代码
究竟要测试哪些代码, 就我的经验来说, 主要测试两部分代码: 业务逻辑层, 接口层.
这两个部分的测试中, 测试重点是不同的.
业务逻辑层
业务逻辑层就是通常所说的 business 或者 service 层. 是业务逻辑的 interface 层.
举个例子:
对于审批流来说, 就是针对 ProcessDefineService 中 定义流程 的方法写测试, 对 ProcessService 中 发起流程, 撤回流程 等方法写测试.
对于商店系统来说, 就是对 OrderFlowBusiness 中 createOrder, cancelOrder 等方法做测试.
NOTE: 这里的测试核心是逻辑的正确性, 考虑代码分支覆盖.
对外接口层
对外接口层, 一般是 http 的 web 前端接口, 或者提供出来的供其他服务进行远程调用的服务 api 接口.
NOTE: 对外接口层的测试, 核心是接口定义的测试.
保证正确的参数能够通过; 错误的参数或者业务异常情况, 能够正确返回接口定义中声明的错误编码或信息.
测试重点是 不同的响应结果, 而不是业务逻辑的每个分支.
TIP: 接口测试用例, 也能够检测接口兼容性升级的正确性. 比如接口添加了一个字段, 当字段没有传递时, 后端服务是否有设定默认值来兼容.
其他场景的测试
在测试中, 也遇到过一些写测试比较困难的场景. 比如 异步逻辑测试 和 并发逻辑测试.
下面说一说我对着两个场景测试的经验.
异步逻辑测试
异步测试的问题在于经常不知道什么时候真正能拿到测试结果.
比如我提交了一个支付请求, 而系统内部对收到支付请求后, 会直接返回请求接收的答复. 真正的出款, 入款操作, 都是异步执行, 完成后进行通知回调的.
有几种处理方式:
- 等
sleep 一段时间. 这是最简单的一个方式, 绝大多数情况下异步操作都是预期能很快执行完, 是够用的. + 但如果不能预估异步执行的时间, 或者时间太长, 再或者异步操作可能不会留下方便观测的结果(比如发送了短信, 执行了请求, 但是没有写入数据的逻辑).
- 异步改同步
设置代码开关, 在测试时同步执行. 这对开发时也很有利, 可以方便的跟踪执行流程. + 但问题是跟线上真是环境有差异, 经常会有一些只有在异步情况下才会发生的错误. 异步改同步可能会漏掉这类错误.
- 点到为止
测试到异步任务提交即可. 这应该是最标准的异步测试方式了.
+
比如我测试业务中, 里面有一步需要提交一个异步任务, 去执行其他的业务逻辑.
+
那么就是用 spring boot test 提供的 @SpyBean, 使用 BBD (Behavior Driven Development) 的方式检测 SyncTaskService.submit() 方法是执行, 并且传入的 task 是我期望的. 代码类似于:
+
[source, groovy]
BDDMockito.verify(syncTaskService, BDDMockito.times(1)).submit(argument -> {
argument.taskId == 101 && argument instance of
})
异步的逻辑执行的测试单独写, 则直接手动执行特定参数的异步任务, 来测试执行结果.
并发逻辑测试
测试并发逻辑的难点在于不能稳定复现. 通常单元测试执行时不会有并发, 那么逻辑中并发场景的问题就无法被测试出.
这个需要一定的预见性: 业务逻辑哪些地方需要有并发测试? 只有先确定哪里可能会有并发问题, 才能进行测试.
比如说同一个账户的扣款请求, 比如同一个商品的购买减库存.
如何测试呢?
因为并发的测试不能稳定复现, 所以这个测试一定不是 100% 能触发问题, 只能增加触发问题的概率.
怎么增大触发并发问题出现的概率? 很简单, 增加次数.
开启多个线程, 并发调用一块业务逻辑, 完成后检查正确性. 如果正确, 重复这一过程. 可以设定一个重复次数或者测试时间, 当次数或者时间达到时, 没有出现并发问题, 那么我们就认为这个并发测试是通过的.
TIP: 并发测试是跟常规测试分开的, 通常进行构建前检测的时候是不执行的, 因为太费时间.
总结
以上就是我对写项目测试的经验.
提出了测试的两个核心要求:
1. 可重复执行
2. 易书写.
为了达到这两个要求, 采取的可实施的方案:
1. 为单测准备独立的环境 - 可重复执行
2. 使用表达力更强的语言来书写测试 - 易书写
3. 创建单元测试的基础类 - 易书写
其中, 在创建单元测试的基础类时, 主要做三件事情:
1. 数据模板
2. 业务操作的便捷方法
3. mock 方法
在测试内容上, 分为两大类: 1. 业务逻辑; 2. 对外接口. 这两种测试测重点也不一样.
最后, 介绍了异步, 并发这两种比较复杂的测试场景的测试方式.
.到底写多少测试才够?
覆盖所有测试 case 是个费时费力的工作.
根据测试覆盖率的报告, 可以看到哪些分支和代码没有覆盖.
但也不用纠结于完全覆盖, 首先让测试能够覆盖正常流程. 剩下的可以慢慢添加.
评论