关于后端:代码重构面向单元测试

41次阅读

共计 9026 个字符,预计需要花费 23 分钟才能阅读完成。

简介:重构代码时,咱们经常纠结于这样的问题:须要进一步形象吗?会不会导致适度设计?如果须要进一步形象的话,如何进行形象呢?有什么通用的步骤或者法令吗?为了保障直观,本文将以一个“生产者消费者”的代码重构示例贯通始终。最初还会以业务上常见的 Excel 导出零碎为例简略论述一个业务上的重构实例。

作者 | 杜沁园 (悬衡) 起源 | 阿里开发者公众号重构代码时,咱们经常纠结于这样的问题:须要进一步形象吗?会不会导致适度设计?如果须要进一步形象的话,如何进行形象呢?有什么通用的步骤或者法令吗?单元测试是咱们罕用的验证代码正确性的工具,然而如果只用来验证正确性的话,那就是真是“大炮打蚊子”– 大材小用,它还能够帮忙咱们评判代码的形象水平与设计程度。本文还会提出一个以“可测试性”为指标,一直迭代重构代码的思路,利用这个思路,面对任何简单的代码,都能逐渐推导出重构思路。为了保障直观,本文会以一个“生产者消费者”的代码重构示例贯通始终。最初还会以业务上常见的 Excel 导出零碎为例简略论述一个业务上的重构实例。浏览本文须要具备根本的单元测试编写教训(最好是 Java),然而本文不会波及任何具体的单元测试框架和技术,因为它们都是不重要的,学习了本文的思路,能够将它们用在任意的单测工具上。不可测试的代码程序员们重构一段代码的动机是什么?可能七嘴八舌:代码不够简洁?不好保护?不合乎集体习惯?适度设计,不好了解?这些都是比拟主观的因素,在一个幼稚程序员看来恰到好处的设计,一个老手程序员却可能会感觉过于简单,不好了解。然而让他们同时坐下来为这段代码增加单元测试时,他们往往可能产生相似的感触,比方“单测很容易书写,很容易就全笼罩了”,那么这就是可测试的代码;“尽管能写得进去,然而费了老大劲,应用了各种框架和技巧,才笼罩齐全”,那么这就是可测试性比拟差的代码;“齐全不晓得如何下手写”,那么这就是不可测试的代码;一般而言,可测试的代码个别都是同时是简洁和可保护的,然而简洁可保护的代码却不肯定是可测试的,比方上面的“生产者消费者”代码就是不可测试的:public void producerConsumer() {

    BlockingQueue< Integer> blockingQueue = new LinkedBlockingQueue<>();
    Thread producerThread  = new Thread(() -> {for (int i = 0; i < 10; i++) {blockingQueue.add(i + ThreadLocalRandom.current().nextInt(100));
        }
    });
    Thread consumerThread = new Thread(() -> {
        try {while (true) {Integer result = blockingQueue.take();
                System.out.println(result);
            }
        } catch (InterruptedException ignore) {}});
    producerThread.start();
    consumerThread.start();}

下面这段代码做的事件非常简单,启动两个线程:生产者:将 0-9 的每个数字,别离加上 [0,100) 的随机数后通过阻塞队列传递给消费者;消费者:从阻塞队列中获取数字并打印;这段代码看上去还是挺简洁的,然而,算得上一段好代码吗?尝试下给这段代码加上单元测试。仅仅运行一下这个代码必定是不够的,因为咱们无奈确认生产生产逻辑是否正确执行。我也只能收回“齐全不晓得如何下手”的感叹,这不是因为咱们的单元测试编写技巧不够,而是因为代码自身存在的问题:1、违反繁多职责准则:这一个函数同时做了 数据传递,解决数据,启动线程三件事件。单元测试要兼顾这三个性能,就会很难写。2、这个代码自身是不可反复的,不利于单元测试,不可反复体现在须要测试的逻辑位于异步线程中,对于它什么时候执行?什么时候执行完?都是不可控的;逻辑中含有随机数;消费者间接将数据输入到规范输入中,在不同环境中无奈确定这里的行为是什么,有可能是输入到了屏幕上,也可能是被重定向到了文件中;因为第 2 点的起因,咱们就不得不放弃单测了呢?其实只有通过正当的模块职责划分,仍旧是能够单元测试。这种划分不仅仅有助于单元测试,也会“顺便”帮忙咱们形象一套更加正当的代码。可测试意味着什么?所有不可测试的代码都能够通过正当的重构与形象,让其外围逻辑变得可测试,这也重构的意义所在。本章就会具体阐明这一点。首先咱们要理解可测试意味着什么,如果说一段代码是可测试的,那么它肯定合乎上面的条件:1、能够在本地设计齐备的测试用例,称之为 齐全笼罩的单元测试;2、只有齐全笼罩的单元测试用例全副正确运行,那么这一段逻辑必定是没有问题的;第 1 点常会令人感到难以置信,但事实比设想的简略,假如有这样一个分段函数:

f(x) 看起来有有限的定义域,咱们永远无奈穷举所有可能的输出。然而再认真想想,咱们并不需要穷举,其实只有上面几个用例能够通过,那么就能够确保这个函数是没有问题的:<-50f(-51) == -100[-50, 50]f(-25) == -50f(25) == 5050f(51) == 100 边界状况 f(-50) == -100f(50) == 100 日常工作中的代码当然比这个简单很多,然而没有本质区别,也是依照如下思路进行单元测试笼罩的:每一个分段其实就是代码中的一个条件分支,用例的分支覆盖率达到了 100%;像 2x 这样的逻辑运算,通过几个适合的采样点就能够保障正确性;边界条件的笼罩,就像是分段函数的转折点;然而业务代码仍旧比 f(x) 要简单很多,因为 f(x) 还有其余好的性质让它能够被齐全测试,这个性质被称作援用通明:函数的返回值只和参数无关,只有参数确定,返回值就是惟一确定的事实中的代码大多都不会有这么好的性质,反而具备很多“坏的性质”,这些坏的性质也常被称为副作用:代码中含有近程调用,无奈确定这次调用是否会胜利;含有随机数生成逻辑,导致行为不确定;执行后果和以后日期无关,比方只有工作日的早上,闹钟才会响起;好在咱们能够用一些技巧将这些副作用从外围逻辑中抽离进去。高阶函数“援用通明”要求函数的出参由入参惟一确定,之前的例子容易让人产生误解,感觉出参和入参肯定要是数据,让咱们把视线再关上一点,出入参能够是一个函数,它也能够是援用通明的。一般的函数又能够称作一阶函数,而接管函数作为参数,或者返回一个函数的函数称为高阶函数,高阶函数也能够是援用通明的。对于函数 f(x) 来说,x 是数据还是函数,并没有实质的不同,如果 x 是函数的话,仅仅意味着 f(x) 领有更加广大的定义域,以至于没有方法像之前一样只用一个一维数轴示意。对于高阶函数 f(g) (g 是一个函数)来说,只有对于特定的函数 g,返回逻辑也是固定,它就是援用通明的了,而不必在乎参数 g 或者返回的函数是否有副作用。利用这个个性,咱们很容易将一个有副作用的函数转换为一个援用通明的高阶函数。一个典型的领有副作用的函数如下:public int f() {

    return ThreadLocalRandom.current().nextInt(100) + 1;
}它生成了随机数并且加 1,因为这个随机数,导致它不可测试。然而咱们将它转换为一个可测试的高阶函数,只有将随机数生成逻辑作为一个参数传入,并且返回一个函数即可:

下面的 g 就是一个援用通明的函数,只有给 g 传递一个数字生成器,返回值肯定是一个“用数字生成器生成一个数字并且加 1”的逻辑,并且不存在分支条件和边界状况,只须要一个用例即可笼罩:

理论业务中能够略微简化一下高阶函数的表白,g 的返回的函数既然每次都会被立刻执行,那咱们就不返回函数了,间接将逻辑写在办法中,这样也是可测试的:

这里我尽管应用了 Lambda 表达式简化代码,然而“函数”并不仅仅是指 Lambda 表达式,OOP 中的充血模型的对象,接口等等,只有其中含有逻辑,它们的传递和返回都能够看作“函数”。因为这个例子比较简单,“可测试”带来的收益看起来没有那么高,实在业务中的逻辑个别比 +1 要简单多了,此时如果能构建无效的测试将是十分无益的。面向单测的重构第一轮重构咱们本章回到结尾的生产者消费者的例子,用上一章学习到的常识对它进行重构。那段代码无奈测试的第一个问题就是职责不清晰,它既做数据传递,又做数据处理。因而咱们思考将生产者消费者数据传递的代码独自抽取进去:

这一段代码的职责就很清晰了,咱们给这个办法编写单元测试的指标也十分明确,即验证数据可能正确地从生产者传递到消费者。然而很快咱们又遇到了之前提到的第二个问题,即异步线程不可控,会导致单测执行的不稳固,用上一章的办法,咱们将执行器作为一个入参抽离进来:

这时咱们就为它写一个稳固的单元测试了:

只有这个测试可能通过,就能阐明生产生产在逻辑上是没有问题的。一个看起来比之前的分段函数简单很多的逻辑,实质上却只是它定义域上的一个恒等函数(因为只有一个用例就能笼罩全副状况),是不是很诧异。如果不太喜爱上述的函数式编程格调,能够很容易地将其革新成 OOP 格调的抽象类,就像上一章提到的,传递对象和传递函数没有实质的区别:

此时单元测试就会像是这个样子:

看到这些类,相熟设计模式的读者们肯定会想到“模板办法模式”,然而咱们在下面的过程素来没有刻意去用任何设计模式,正确的重构就会让你在无意间“从新发现”这些罕用的设计模式,个别这种状况下,设计模式的应用都是正确的,因为咱们始终在把代码往更加可测试的方向举荐,而这也是掂量设计模式是否应用正确的重要规范,谬误的设计模式应用则会让代码更加的割裂和不可测试,后文探讨“适度设计”这个主题时会进一步深刻探讨这一部分内容。很显然这种测试无奈验证多线程运行的状况,但我成心这么做的,这部分单元测试的次要目标是验证逻辑的正确性,只有先验证逻辑上的正确性,再去测试并发才比拟有意义,在逻辑存在问题的状况下就去测试并发,只会让问题暗藏得更深,难以排查。个别开源我的项目中会有专门的单元测试去测试并发,然而因为其编写代价比拟大,运行工夫比拟长,数量会远少于逻辑测试。通过第一轮重构,主函数变成了这个样子(这里我最终采纳了 OOP 格调):public void producerConsumer() {

    new ProducerConsumer< Integer>(Executors.newFixedThreadPool(2)) {
        @Override
        void produce() {for (int i = 0; i < 10; i++) {produceInner(i + ThreadLocalRandom.current().nextInt(100));
            }
        }

        @Override
        void consume() {while (true) {Integer result = consumeInner();
                System.out.println(result);
            }
        }
    }.start();}在第一轮重构中,咱们仅仅保障了数据传递逻辑是正确的,在第二轮重构中,咱们还将进一步扩充可测试的范畴。第二轮重构代码中影响咱们进一步扩充测试范畴因素还有两个:随机数生成逻辑打印逻辑只有将这两个逻辑像之前一样抽出来即可:

这次采纳 OOP 和 函数式 混编的格调,也能够思考将 numberGenerator 和 numberConsumer 两个办法参数改成形象办法,这样就是更加纯正的 OOP。它也只须要一个测试用例即可实现齐全笼罩:

此时主函数变成:

通过两轮重构,咱们将一个很随便的面条代码重形成了很优雅的构造,除了更加可测试外,代码也更加简洁形象,可复用,这些都是面向单测重构所带来的附加益处。你可能会留神到,即便通过了两轮重构,咱们仍旧不会间接对主函数 producerConsumer 进行测试,而只是有限靠近笼罩外面的全副逻辑,因为我认为它不在“测试的边界”内,我更偏向于用集成测试去测试它,集成测试则不在本篇文章探讨的范畴内。下一章则重点探讨测试边界的问题。单元测试的边界边界内的代码都是单元测试能够无效笼罩到的代码,而边界外的代码则是没有单元测试保障的。上一章所形容的重构过程实质上就是一个在摸索中不断扩大测试边界的过程。然而单元测试的边界是不可能有限扩充的,因为理论的工程中必然有大量的不可测试局部,比方 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 可能不是因为代码逻辑,而是因为架构设计导致的,此时单元测试就无奈解决。因而要彻底保障系统的持重,还是须要从单元测试,架构治理,技术选项等多个方面动手。另外一点也不得不抵赖,单元测试是有肯定老本的,一套工作流实现的话,可能会有数倍于原代码量的单元测试,因而并不是所有代码都须要这样的重构,在工夫无限的状况下,应该优先重构零碎中外围的稳固的代码,在衡量好老本与价值的状况下,再开始入手。最初,单元测试也是对人有强依赖的技术,侧重于后期预防,没有任何方法量化一个人单元测试的品质如何,成果如何,这一切都是出于工程本人心田的“工匠精力”以及对代码的敬畏,置信读到最初的你,也肯定有着一颗工匠的心吧。技术人如何打造集体品牌影响力?五大顶级创作者独家传授!开发者社区联结 InfoQ 特地策动《技术创作训练营》,2 位金牌导师 + 5 位顶级作者独家传授写作方法论,带您开启高质量技术创作之路。点击这里,查看详情。
原文链接:https://click.aliyun.com/m/10…
本文为阿里云原创内容,未经容许不得转载。

正文完
 0