2019 2019年6月17日 16 min · 5.0k chars by yxc023

如何有效的书写项目单元测试

code-and-thinkingjavatest单测单元测试

NOTE: 本文为原创, 转载请注明出处 https://blog.yangxiaochen.com

前言

// 不知道大家知否会在业务项目里写单元测试, 来测试业务逻辑和流程的正确性?

// 从我工作的经历来看, 很少遇到有开发者这么做.

// 是什么原因呢?

// * 依赖于其他组件, 比如 db, redis, zk, mq 等. // * 依赖于其他系统, 比如账户中心, 支付中心. // * 业务数据难以构造, 通常需要特定数据才能测试某一场景, 而生成特定数据的前置流程很长.

// 基于以上原因, 造成测试写起来很费劲, 有很难做到可重复执行.

我近两年在几个业务项目中都尝试编写了单元测试, 用于保障业务逻辑的正确性.

这里所说的单测, 并不是常规的对单一小方法的测试, 而是对业务流程的测试.

在这个过程中, 遇到了很多问题, 也总结了一些经验, 所以写下了这篇文章, 感觉是一套比较有效和有参照意义的方法论.

如何开始写单测

第一次写单测, 是一个审批流业务相关的项目, 内部业务逻辑很复杂, 分支判断多. 不同的审批流配置, 最终审批流运行时的表现是不一样的. 在当时的开发和测试过程中, 就遇到了问题:

  1. 每修改一个功能或者加一个特性, 测试需要全流程来测试, 而且我还在不断重构代码, 测试的自动化 case 修改速度还跟不上我的功能开发速度.

  2. 另外, 我在开发时也需要自测, 我需要从头构建一个审批流配置, 然后发起流程, 走到我新加了特性的节点, 才能验证我的功能. 当发现验证失败后, 就找问题, 修改代码, 然后重复这个过程, 效率及其低下.

于是, 我就写了一套从定义审批流配置, 到后续审批节点操作, 整个流程的测试代码. 用于一键自测.

后来, 因为不同的配置会对逻辑有影响, 所以我准备了多套审批流程配置, 也就是多个测试用例.

后面, 又进行了优化和完善, 最终达到了以下目标:

  • 可重复执行
  • 易书写

这两点, 是单测的灵魂.

可重复执行, 保证了单测并非一次性的, 每次测试数据是相同的. 如果每次测试都是不一样的数据, 需要人工修改, 那就没有意义.

易书写, 只有易书写, 单测的编写才能持续下去. 如果编写一个 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 的项目写测试.

  1. 能够无缝使用 java 的代码
  2. 表达力更强, 更易书写.

能够字面式的初始化对象, 列表, 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发起流程, 撤回流程 等方法写测试.

对于商店系统来说, 就是对 OrderFlowBusinesscreateOrder, 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 是个费时费力的工作.

根据测试覆盖率的报告, 可以看到哪些分支和代码没有覆盖.

但也不用纠结于完全覆盖, 首先让测试能够覆盖正常流程. 剩下的可以慢慢添加.


评论