详细说说什么是单元测试的边界
摘要:边界内的代码都是单元测试可以有效覆盖到的代码,而边界外的代码则是没有单元测试保障的。上一章所描述的重构过程本质上就是一个在探索中不断扩大测试边界的过程。但是单元测试的边界是不可能无限扩大的,因为实际的工程中必然有大量的不可测试部分,比如 RPC 调用,发消息,根据当前时间做计算等等,它们必然得在某个地方传入测试边界,而这一部分就是不可测试的。
理想的测试边界应该是这样的,系统中所有核心复杂的逻辑全部包含在了边界内部,然后边界外都是不包含逻辑的,非常简单的代码,比如就是一行接口调用。这样任何对于系统的改动都可以在单元测试中就得到快速且充分的验证,集成测试时只需要简单测试下即可,如果出现问题,一定是对外部接口的理解有误,而不是系统内部改错了。
清晰的单元测试边界划分有利于构建更加稳定的系统核心代码,因为我们在推进测试边界的过程中会不断地将副作用从核心代码中剥离出去,最终会得到一个完整且可测试的核心,就如同下图的对比一样:
重构的工作流
好代码从来都不是一蹴而就的,都是先写一个大概,然后逐渐迭代和重构的,从这个角度来说,重构别人的代码和写新代码没有很大的区别。从上面的内容中,我们可以总结出一个简单的重构工作流:
按照这个方法,就能够逐步迭代出一套优雅且可测试的代码,即使因为时间问题没有迭代到理想的测试边界,也会拥有一套大部分可测试的代码,后人可以在前人用例的基础上,继续扩大测试边界。
过度设计
最后再谈一谈过度设计的问题。按照本文的方法是不可能出现过度设计的问题,过度设计一般发生在为了设计而设计,生搬硬套设计模式的场合,但是本文的所有设计都有一个明确的目的--提升代码的“可测试性”,所有的技巧都是在过程中无意使用的,不存在生硬的问题。而且过度设计会导致“可测试性”变差,过度设计的代码常常是把自己的核心逻辑都给抽象掉了,导致单元测试无处可测。如果发现一段代码“写得很简洁,很抽象,但是就是不好写单元测试”,那么大概率是被过度设计了。另外一种过度设计是因为过度依赖框架而无意中导致的,Java 往往习惯于将自己的设计耦合进 Spring 框架中,比如将一段完整的逻辑拆分到几个 Spring Bean 中,而不是使用普通的 Java 类,导致根本就无法在不启动容器的情况下进行完整的测试,最后只能写一堆无效的测试提升“覆盖率”。这也是很多人抱怨“单元测试没有用”的原因。
和 TDD 的区别
本文到这里都还没有提及到 TDD,但是上文中阐述的内容肯定让不少读者想到了这个名词,TDD 是 “测试驱动开发” 的简写,它强调在代码编写之前先写用例,包括三个步骤:
·红灯:写用例,运行,无法通过用例
· 绿灯:用最快最脏的代码让测试通过
· 重构:将代码重构得更加优雅
在开发过程中不断地重复这三个步骤。但是会实践中会发现,在繁忙的业务开发中想要先写测试用例是很困难的,可能会有以下原因:
代码结构尚未完全确定,出入口尚未明确,即使提前写了单元测试,后面大概率也要修改
产品一句话需求,外加对系统不够熟悉,用例很难在开发之前写好
因此本文的工作流将顺序做了一些调整,先写代码,然后再不断地重构代码适配单元测试,扩大系统的测试边界。不过从更广义的 TDD 思想上来说,这篇文章的总体思路和 TDD 是差不多的,或者标题也可以改叫做 “TDD 实践”。
业务实例 - 导出系统重构
钉钉审批的导出系统是一个专门负责将审批单批量导出成 Excel 的系统:
大概步骤如下:
·启动一个线程,在内存中异步生成 Excel
· 上传 Excel 到钉盘/oss
· 发消息给用户
钉钉审批导出系统比常规导出系统要更加复杂一些,因为它的表单结构并不是固定的。而用户可以通过设计器灵活配置:
从上面可以看出单个审批单还具有复杂的内部结构,比如明细,关联表单等等,而且还能相互嵌套,因此逻辑很十分复杂。
我接手导出系统的时候,已经维护两年了,没有任何测试用例,代码中导出都是类似 patchXxx 的方法,可见在两年的岁月中,被打了不少补丁。系统虽然总体能用,但是有很多小 bug,基本上遇到边界情况就会出现一个 bug(边界情况比如明细里只有一个控件,明细里有关联表单,而关联表单里又有明细等等)。代码完全不可测试,完成的逻辑被 Spring Bean 隔离成一小块,一小块,就像下图一样:
我决定将这些代码重构,不能让它继续荼毒后人,但是面对一团乱麻的代码完全不知道如何下手(以下贴图仅仅是为了让大家感受下当时的心情,不用仔细看):
我决定用本文的工作流对代码进行重新梳理。
确定测试边界
首先需要确定哪些部分是单元测试可以覆盖到的,哪些部分是不需要覆盖到的,靠集成测试保证的。经过分析,我认为导出系统的核心功能,就是根据表单配置和表单数据生成 excel 文件:
这部分也是最核心,逻辑也最复杂的部分,因此我将这一部分作为我的测试边界,而其他部分,比如上传,发工作通知消息等放在边界之外:
图中 “表单配置” 是一个数据,而 “表单数据” 其实是一个函数,因为导出过程中会不断批量分页地去查询数据。
不断迭代,扩大测试边界到理想状态
我迭代的过程如下:
·异步执行导致不可测试:抽出一个同步的函数;
· 大量使用 Spring Bean 导致逻辑割裂:将逻辑放到普通的 Java 类或者静态方法中;
· 表单数据,流程与用户的相关信息查询是远程调用,含有副作用:通过高阶函数将这些副作用抽出去;
· 导入状态落入数据库,也是一个副作用:同样通过高阶函数将其抽象出去;
最终导出的测试边界大约是这个样子:
public byte[] export(FormConfig config, DataService dataService, ExportStatusStore statusStore) {
//... 省略具体逻辑, 其中包括所有可测试的逻辑, 包括表单数据转换,excel 生成
}·config:数据,表单配置信息,含有哪些控件,以及控件的配置
· dataService: 函数,用于批量分页查询表单数据的副作用
· statusStore: 函数,用于变更和持久化导出的状态的副作用
public interface DataService {
PageList<FormData> batchGet(String formId, Long cursor, int pageSize);
}public interface ExportStatusStore {
/**
* 将状态切换为 RUNNING
*/
void runningStatus();
/**
* 将状态置为 finish
* @param fileId 文件 id
*/
void finishStatus(Long fileId);
/**
* 将状态置为 error
* @param errMsg 错误信息
*/
void errorStatus(String errMsg);
}在本地即可验证生成的 Excel 文件是否正确(代码经过简化):
public void testExport() {
//这里的 export 就是刚刚展示的导出测试边界
byte[] excelBytes = export(new FormConfig(), new LocalDataService(),
new LocalStatusStore());
assertExcelContent(excelBytes, Arrays.asList(
Arrays.asList("序号", "表格", "表格", "表格", "创建时间", "创建者"),
Arrays.asList("序号", "物品编号", "物品名称", "xxx", "创建时间", "创建者"),
Arrays.asList("1", "22", "火车", "而非", "2020-10-11 00:00:00", "悬衡")
));
}其中 LocalDataService,LocalStatusStore 分别是内存中的数据服务,和状态变更服务实现,用于进行单元测试。assertExcelContent 是我用 poi 写的一个工具方法,用于测试内存中的 excel 文件是否符合预期。所有边界的用例都可以直接在本地测试和沉淀用例。
最终的代码结构大约如下(经过简化):
虽然到现在为止我的目的都是提升代码的可测试性,但是实际上我一不小心也提升了代码的拓展性,在完全没有相关产品需求的情况下:
·通过 DataService 的抽象,系统可以支持多种数据源导出,比如来自搜索,或者来自 db 的,只要传入不同的 DataService 实现即可,完全不需要改动和性逻辑;
· ExportStatusStore 的抽象,让系统有能力使用不同的状态存储,虽然目前使用的是 db,但是也可以在不改核心逻辑的情况下轻松切换成 tair 等其他中间件;
果然在我重构后不久,就接到了类似的需求,比如要支持从不同的数据源导出。我们又新增了一个导出入口,这个导出状态是存储在不同的表中。每次我都暗自窃喜,其实这些我早就从架构上准备好了。
单元测试的局限性
虽然本文是一篇单元测试布道文章,前文也将单元测试说得“神通广大”,但是也不得不承认单元测试无法解决全部的问题。
单元测试仅仅能保证应该的代码逻辑是正确的,但是应用开发中还有很多更加要紧的事情,比如架构设计,中间件选型等等,很多系统 bug 可能不是因为代码逻辑,而是因为架构设计导致的,此时单元测试就无法解决。因此要彻底保障系统的稳健,还是需要从单元测试,架构治理,技术选项等多个方面入手。
另外一点也不得不承认,单元测试是有一定成本的,一套工作流完成的话,可能会有数倍于原代码量的单元测试,因此并不是所有代码都需要这样的重构,在时间有限的情况下,应该优先重构系统中核心的稳定的代码,在权衡好成本与价值的情况下,再开始动手。
最后,单元测试也是对人有强依赖的技术,侧重于前期预防,没有任何办法量化一个人单元测试的质量如何,效果如何,这一切都是出于工程自己内心的“工匠精神” 以及对代码的敬畏,相信读到最后的你,也一定有着一颗工匠的心吧。
学习了,刚入行,向老师傅学习 学习下,刚入行:loveliness:
页:
[1]